143 lines
9.4 KiB
Python
143 lines
9.4 KiB
Python
"""Physical PNG analysis with predeclared logical ROIs; no border exclusions."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
from PIL import Image, ImageChops, ImageDraw, ImageStat
|
|
|
|
ROIS = {'left': {'interior': (300,100,700,500), 'boundary': (720,50,800,550)},
|
|
'right': {'boundary': (0,50,80,550), 'reference': (250,250,550,500)}}
|
|
COLORS = {'A': (224,192,32), 'B': (32,208,160)}
|
|
|
|
def sha(p):
|
|
return hashlib.sha256(p.read_bytes()).hexdigest()
|
|
|
|
def nonzero(d):
|
|
r,g,b=d.split()
|
|
return ImageChops.lighter(ImageChops.lighter(r,g),b)
|
|
|
|
def diff(a,b):
|
|
d=ImageChops.difference(a,b)
|
|
return {'compared_pixels': a.width*a.height,
|
|
'modified_pixels': a.width*a.height-nonzero(d).histogram()[0],
|
|
'bbox': d.getbbox()}
|
|
|
|
def physical(box,scale):
|
|
# CONTRACT: conservative ROI containment; not a substituted render-box rule.
|
|
return [math.floor(box[0]*scale), math.floor(box[1]*scale),
|
|
math.ceil(box[2]*scale), math.ceil(box[3]*scale)]
|
|
|
|
def analyze(run):
|
|
events=[json.loads(x) for x in (run/'events.jsonl').read_text().splitlines()]
|
|
geoms={(e['tag'],e['output']):e for e in events if e['kind']=='capture_geometry'}
|
|
log=(run/'compositor.log').read_text(errors='replace')
|
|
# WHY: source audit establishes these extents are already buffer-local.
|
|
# INVARIANT: no second scale conversion of TEMPORAL_DAMAGE.
|
|
damages=[(int(ns),out,list(map(int,box))) for ns,out,*box in
|
|
re.findall(r'TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)',log)]
|
|
captures=[];previous={};images={};transitions=[]
|
|
for e in events:
|
|
if e['kind']!='capture':continue
|
|
path=run/e['path'];g=geoms[(e['tag'],e['output'])]
|
|
side='left' if e['output']=='HEADLESS-1' else 'right'
|
|
im=Image.open(path).convert('RGB');images[(side,e['tag'])]=im
|
|
assert list(im.size)==g['dimensions']==[g['mode']['width'],g['mode']['height']]
|
|
obs={'file':path.name,'sha256':sha(path),'geometry':g,'before_ns':e['before_ns'],
|
|
'after_ns':e['after_ns'],'coordinate_source':'get_outputs + explicit per-output grim scale',
|
|
'roi':{},'client_pixels':{},'edge_columns':{},
|
|
'hit_evidence':{'client_log_sha256':sha(run/'client.log') if (run/'client.log').exists() else None,
|
|
'pointer_log_sha256':sha(run/'pointer.log') if (run/'pointer.log').exists() else None,
|
|
'scope':'ordered run events; no atomic capture/input identity'}}
|
|
for name,box in ROIS[side].items():
|
|
pb=physical(box,g['scale']);roi=im.crop(pb)
|
|
obs['roi'][name]={'logical':box,'physical':pb,'pixels':roi.width*roi.height}
|
|
for name,col in COLORS.items():
|
|
delta=ImageChops.difference(im,Image.new('RGB',im.size,col))
|
|
mask=nonzero(delta).point(lambda v: 255 if v == 0 else 0)
|
|
obs['client_pixels'][name]={'exact_pixels':mask.histogram()[255], 'bbox':mask.getbbox()}
|
|
# Entire predeclared columns, not selected after inspecting anomalies.
|
|
cols=list(range(0,min(4,im.width))) if side=='right' else list(range(im.width-4,im.width))
|
|
for x in cols:
|
|
obs['edge_columns'][str(x)]={name:sum(im.getpixel((x,y))==c for y in range(im.height)) for name,c in COLORS.items()}
|
|
if side in previous:
|
|
older,oldim=previous[side]
|
|
intervals=[box for ns,out,box in damages if out==e['output'] and older['after_ns']<ns<=e['after_ns']]
|
|
mask=Image.new('1',im.size,0);draw=ImageDraw.Draw(mask)
|
|
for box in intervals:draw.rectangle((box[0],box[1],box[2]-1,box[3]-1),fill=1)
|
|
delta=ImageChops.difference(oldim,im)
|
|
changed=nonzero(delta).point(lambda v: 255 if v else 0)
|
|
uncovered=ImageChops.subtract(changed,mask.convert('L'))
|
|
outside=uncovered.histogram()[255]
|
|
transitions.append({'side':side,'from':older['tag'],'to':e['tag'],**diff(oldim,im),
|
|
'damage_coordinate_source':'TEMPORAL_DAMAGE physical buffer-local',
|
|
'damage_extents':intervals,'changed_outside_damage':outside if damages else None})
|
|
previous[side]=(e,im);captures.append(obs)
|
|
comparisons=[]
|
|
for side in ROIS:
|
|
base=images.get((side,'FX0-off'))
|
|
if base:
|
|
scale=next(g['scale'] for (tag,out),g in geoms.items() if out==('HEADLESS-1' if side=='left' else 'HEADLESS-2'))
|
|
for tag in ('FX1-shadow','FX2-blur','FX4-combined'):
|
|
if (side,tag) not in images:continue
|
|
im=images[(side,tag)]
|
|
entry={'side':side,'phase':tag,'whole':diff(base,im),'rois':{}}
|
|
for name,box in ROIS[side].items():
|
|
pb=physical(box,scale);aa=base.crop(pb);bb=im.crop(pb)
|
|
entry['rois'][name]=diff(aa,bb)
|
|
# Luminance and contrast measured in the predeclared ROI.
|
|
metrics=[]
|
|
for img in (aa,bb):
|
|
gray=img.convert('L')
|
|
horizontal=ImageChops.difference(gray.crop((1,0,img.width,img.height)),gray.crop((0,0,img.width-1,img.height)))
|
|
metrics.append({'luminance_stddev':ImageStat.Stat(gray).stddev[0],
|
|
'mean_adjacent_contrast':ImageStat.Stat(horizontal).mean[0]})
|
|
entry['rois'][name]['filter_metrics_fx0_effect']=metrics
|
|
comparisons.append(entry)
|
|
for side in ROIS:
|
|
ref_tag='S1-effects-off-reference' if (side,'S1-effects-off-reference') in images else 'S1-opaque-reference'
|
|
if (side,ref_tag) in images and (side,'S2-chain-stable') in images:
|
|
first=images[(side,ref_tag)];second=images[(side,'S2-chain-stable')]
|
|
scale=next(g['scale'] for (tag,out),g in geoms.items() if out==('HEADLESS-1' if side=='left' else 'HEADLESS-2'))
|
|
comparisons.append({'side':side,'phase':'S2-popup-vs-reference','whole':diff(first,second),
|
|
'rois':{name:diff(first.crop(physical(box,scale)),second.crop(physical(box,scale))) for name,box in ROIS[side].items()}})
|
|
boxes=[]
|
|
for chain in (e for e in events if e['kind']=='chain'):
|
|
for name,point,offset,cfg in [('A',chain['a_global'],(40,35),chain['a_configure']),('B',chain['b_global'],(35,30),chain['b_configure'])]:
|
|
gx,gy=point[0]-offset[0],point[1]-offset[1]
|
|
for output,ox in [('HEADLESS-1',-800),('HEADLESS-2',0)]:
|
|
g=next(v for (tag,out),v in geoms.items() if out==output);sc=g['scale']
|
|
def rounded(v):return math.floor(v+0.5) if v>=0 else math.ceil(v-0.5)
|
|
edges=[rounded((gx-ox)*sc),rounded(gy*sc),rounded((gx-ox+cfg[2])*sc),rounded((gy+cfg[3])*sc)]
|
|
boxes.append({'popup':name,'output':output,'global_logical':[gx,gy,cfg[2],cfg[3]],
|
|
'rounded_destination_edges':edges,'source':'configured geometry and successfully targeted local offsets; SceneFX scale_box edge rule',
|
|
'viewport_physical':[0,0,g['mode']['width'],g['mode']['height']]})
|
|
returns=[]
|
|
for side in ROIS:
|
|
if (side,'S5-return') in images:
|
|
returns.append({'side':side,**diff(images[(side,'S2-chain-stable')],images[(side,'S5-return')])})
|
|
client=(run/'client.log').read_text(errors='replace') if (run/'client.log').exists() else ''
|
|
grabs=[]
|
|
for name,target,serial,used in re.findall(r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\S+) serial=(\d+) used=(\d+)',client):
|
|
valid=serial==used and bool(re.search(rf'POINTER_BUTTON target={target} serial={serial} button=272 state=1',client))
|
|
grabs.append({'popup':name,'parent':target,'received_serial':int(serial),'used_serial':int(used),'valid':valid,
|
|
'mapped':f'{name} MAP' in client})
|
|
return {'run':run.name,'exit_status':json.loads((run/'exit-status.json').read_text()),
|
|
'captures':captures,'comparisons':comparisons,'transitions':transitions,'returns':returns,
|
|
'grabs':grabs,'physical_popup_boxes':boxes,'client_events':re.findall(r'^(?:POINTER_|POPUP_)[^\n]*',client,re.M),
|
|
'calibration':[e for e in events if e['kind']=='pointer_calibration'],
|
|
'scenario_events':[e for e in events if e['kind'] in ('chain','exterior_click','camera_pair_complete','standard_return_hit','boundary_hover')],
|
|
'clip_trace':[line for line in log.splitlines() if 'PROBE_POPUP_' in line or any(t in line for t in ('PI_SCENE ', 'PI_OWNER ', 'PI_POPUP '))],
|
|
'damage_events':len(damages)}
|
|
|
|
p=argparse.ArgumentParser();p.add_argument('lab',type=Path);args=p.parse_args();lab=args.lab.resolve()
|
|
out=lab/'fractional-results';out.mkdir(exist_ok=True)
|
|
results=[analyze(r) for r in sorted((lab/'runs').iterdir()) if (r/'exit-status.json').exists()]
|
|
(out/'analysis.json').write_text(json.dumps(results,indent=2)+'\n')
|
|
proofs=[f for r in (lab/'runs').iterdir() for f in r.rglob('*') if f.is_file()]
|
|
proofs += [f for f in lab.glob('fractional-*') if f.is_file() and f.suffix!='.lock']
|
|
proofs += [f for f in out.iterdir() if f.is_file() and f.name != 'evidence-sha256.txt']
|
|
proofs += [lab/'fx-checker.png',out/'analysis.json',lab/'effects-source-sha256.txt',lab/'effects-build-log.txt']
|
|
(out/'evidence-sha256.txt').write_text(''.join(f'{sha(f)} {f}\n' for f in sorted(set(proofs))))
|
|
print(json.dumps({'runs':len(results),'manifest_entries':len(set(proofs)),'manifest_sha256':sha(out/'evidence-sha256.txt')}))
|