"""Session 60: full-image small-three-dot screening, not full rune OCR.
Python + NumPy/SciPy/Pillow. Keep beside retained JPEGs and source manifest.
"""
from pathlib import Path
from collections import Counter
import json,hashlib
import numpy as np
from scipy import ndimage
from PIL import Image,ImageDraw,ImageFont
ROOT=Path(__file__).resolve().parent

def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def groups(mask):
 lab,_=ndimage.label(mask);dots=[]
 for k,b in enumerate(ndimage.find_objects(lab),1):
  y,x=b;w=x.stop-x.start;h=y.stop-y.start
  if not(6<=w<=13 and 6<=h<=13):continue
  area=int(np.count_nonzero(lab[b]==k))
  if 30<=area<=150:dots.append([x.start,y.start,x.stop,y.stop,area])
 gs=[]
 for d in sorted(dots,key=lambda x:(x[0],x[1])):
  near=[g for g in gs if any(abs(d[0]-e[0])<30 and abs(d[1]-e[1])<30 for e in g)]
  if not near:gs.append([d])
  else:
   g=near[0];g.append(d)
   for other in near[1:]:g.extend(other);gs.remove(other)
 return sorted([{'dot_count':len(g),'bbox':[min(d[0]for d in g),min(d[1]for d in g),max(d[2]for d in g),max(d[3]for d in g)],'dots':sorted(g)}for g in gs],key=lambda g:(g['bbox'][1],g['bbox'][0]))

def main():
 plan=json.loads((ROOT/'three_dot_scan_plan.json').read_text());assert sha(ROOT/'three_dot_scan_plan.json')=='e3f6b7a759e80df3018ba3296ea21d1ba5bd099b9fda567b8e1394845855a5b7'
 manifest=json.loads((ROOT/'jpeg_source_manifest.json').read_text());hashes={x['file']:x['sha256']for x in manifest['files']};pages=[];candidates=[]
 for p in range(58):
  f=ROOT/f'{p+17:02d}.jpg';assert sha(f)==hashes[f.name];im=Image.open(f).convert('RGB');a=np.asarray(im).astype(np.int16);mx=a.max(axis=2);rd=np.minimum(a[:,:,0]-a[:,:,1],a[:,:,0]-a[:,:,2]);records=[]
  for black,red in zip(plan['black_thresholds'],plan['red_dominance_thresholds']):
   for color,threshold,mask in [('black',black,mx<black),('red',red,(a[:,:,0]>100)&(rd>red))]:
    gs=groups(mask)if mask.any()else[];records.append({'color':color,'threshold':threshold,'groups':gs,'counts':dict(Counter(str(g['dot_count'])for g in gs))})
    for g in gs:
     if g['dot_count']!=3:continue
     near=[c for c in candidates if c['page']==p and c['color']==color and max(abs(x-y)for x,y in zip(c['bbox'],g['bbox']))<=6]
     if near:c=near[0]
     else:c={'page':p,'source':f.name,'color':color,'bbox':g['bbox'],'detections':[]};candidates.append(c)
     c['detections'].append({'threshold':threshold,'bbox':g['bbox'],'dots':g['dots']})
  pages.append({'page':p,'source':f.name,'sha256':sha(f),'size':list(im.size),'runs':records})
  if p%8==7 or p==57:print(f'Screened {p+1}/58 pages; {len(candidates)} three-component candidate locations',flush=True)
 candidates.sort(key=lambda c:(c['page'],c['bbox'][1],c['bbox'][0],c['color']))
 for i,c in enumerate(candidates):
  c['id']=f'three-dot-{i+1:02d}';im=Image.open(ROOT/c['source']);b=c['bbox'];cx=(b[0]+b[2])//2;cy=(b[1]+b[3])//2;box=[max(0,cx-110),max(0,cy-85),min(im.width,cx+110),min(im.height,cy+85)];c['context_bbox']=box;c['crop']=f"three_dot_candidate_{i+1:02d}.png";im.crop(box).resize((440,340),Image.Resampling.NEAREST).save(ROOT/c['crop'])
 out={'plan_sha256':sha(ROOT/'three_dot_scan_plan.json'),'source_manifest_sha256':sha(ROOT/'jpeg_source_manifest.json'),'scope':'All 58 original full LP2 images screened with fixed small-square component rules; candidate semantics and transcript alignment reviewed separately.','candidate_count':len(candidates),'candidates':candidates,'pages':pages}
 (ROOT/'three_dot_scan_results.json').write_text(json.dumps(out,indent=2)+'\n');sheet(candidates)
 print(json.dumps({'candidate_count':len(candidates),'candidates':[{k:v for k,v in c.items()if k!='detections'}for c in candidates]},indent=2))

def sheet(cs):
 cols=3;cellw=470;cellh=390;out=Image.new('RGB',(cols*cellw,((len(cs)+cols-1)//cols)*cellh),'#e9eef3');d=ImageDraw.Draw(out);font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',18)
 for i,c in enumerate(cs):
  x=(i%cols)*cellw;y=(i//cols)*cellh;d.text((x+12,y+10),f"{c['id']} | LP2 {c['page']} | {c['color']}",font=font,fill='#14202b');out.paste(Image.open(ROOT/c['crop']),(x+12,y+40))
 out.save(ROOT/'three_dot_candidate_contact.png')
if __name__=='__main__':main()
