audit-roof-imagery-statistics.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
  1. import json, math, hashlib
  2. from pathlib import Path
  3. import numpy as np
  4. from PIL import Image, ImageFilter
  5. BASE=Path('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35')
  6. PILOT=BASE/'negative-estate-pilot/input-v2'
  7. OUT=BASE/'roof-imagery-quality'
  8. OUT.mkdir(parents=True,exist_ok=True)
  9. configs=json.loads((PILOT/'roof-textures.json').read_text())
  10. regions=json.loads((BASE/'roof-quality-pilot/report.json').read_text())
  11. def pixels(path):
  12. return np.asarray(Image.open(path).convert('RGB'),dtype=np.float32)
  13. def stats(arr):
  14. rgb=arr/255.0
  15. lum=.2126*arr[:,:,0]+.7152*arr[:,:,1]+.0722*arr[:,:,2]
  16. sat=arr.max(2)-arr.min(2)
  17. dx=np.abs(np.diff(lum,axis=1));dy=np.abs(np.diff(lum,axis=0))
  18. return {'sizePx':[int(arr.shape[1]),int(arr.shape[0]),'source channels RGB'],
  19. 'luminancePercentiles':{str(p):round(float(np.percentile(lum,p)),2) for p in [1,5,25,50,75,95,99]},
  20. 'darkPixelFractionLumaUnder55':round(float(np.mean(lum<55)),5),
  21. 'brightPixelFractionLumaOver210':round(float(np.mean(lum>210)),5),
  22. 'saturationPercentiles':{str(p):round(float(np.percentile(sat,p)),2) for p in [50,90,99]},
  23. 'edgeGradientP95':round(float(np.percentile(np.concatenate([dx.ravel(),dy.ravel()]),95)),2),
  24. 'rgbStddev':np.round(np.std(arr.reshape(-1,3),axis=0),2).tolist()}
  25. textures=[]
  26. tile_map={}
  27. for bid,meta in configs.items():
  28. arr=pixels(meta['path']);rec={'id':bid,'roofCrop':stats(arr),'sha256':hashlib.sha256(Path(meta['path']).read_bytes()).hexdigest(),
  29. 'sourceTileCount':len(meta['sourceTiles']),'sourceTiles':meta['sourceTiles']}
  30. textures.append(rec)
  31. for t in meta['sourceTiles']:tile_map[t['path']]=t['sha256']
  32. for target in regions['results']:
  33. for src in target['processed']['sourceTiles']:tile_map[src['path']]=src['sha256']
  34. # Tile-edge continuity proxy. Compare RGB values immediately beside adjacent source tile boundaries.
  35. tiles={p:pixels(p) for p in tile_map}
  36. seams=[]
  37. items=sorted(tiles)
  38. for p in items:
  39. parts=Path(p).parts
  40. try:z=int(parts[-3]);x=int(parts[-2]);tms=int(Path(p).stem)
  41. except (ValueError,IndexError):continue
  42. y=2**z-1-tms
  43. for axis,nx,ny in [('east',x+1,y),('south',x,y+1)]:
  44. q=f'D:/GIS/GIS/yuanlingPic3/{z}/{nx}/{2**z-1-ny}.png'
  45. if q not in tiles or p>=q:continue
  46. a,b=tiles[p],tiles[q]
  47. edgeA=a[:,-1,:] if axis=='east' else a[-1,:,:]
  48. edgeB=b[:,0,:] if axis=='east' else b[0,:,:]
  49. d=np.sqrt(np.mean((edgeA-edgeB)**2,axis=1))
  50. la=.2126*edgeA[:,0]+.7152*edgeA[:,1]+.0722*edgeA[:,2]
  51. lb=.2126*edgeB[:,0]+.7152*edgeB[:,1]+.0722*edgeB[:,2]
  52. seams.append({'tileA':p,'tileB':q,'orientation':axis,'meanRgbRmsDelta':round(float(np.mean(d)),2),
  53. 'p95RgbRmsDelta':round(float(np.percentile(d,95)),2),'meanLumaDelta':round(float(np.mean(np.abs(la-lb))),2)})
  54. report={'generatedAt':'2026-10-15','method':'Pillow/Numpy raster statistics only; not roof segmentation, radiometric correction or facade evidence',
  55. 'roofCrops':textures,'sourceTileCount':len(tiles),'adjacentTileSeams':seams,
  56. 'interpretationLimits':['Dark-pixel share and luminance spread combine cast shadows, roof material, capture mosaics and building occlusion.','Adjacent tile edge mismatch is a proxy and may cross different land cover; it is not a seam detector.','The 512/205px GLB atlases are roof bounding rectangles; only the roof mesh UV subset is rendered.','No source image is altered; no geometric claim follows from these statistics.'],
  57. 'runtimeConfigChanged':False,'runtimeRendered':False}
  58. (OUT/'roof-imagery-statistics.json').write_text(json.dumps(report,indent=2))
  59. print(json.dumps({'passed':True,'roofCrops':[(x['id'],x['roofCrop']['darkPixelFractionLumaUnder55'],x['roofCrop']['luminancePercentiles']['50'],x['sourceTileCount']) for x in textures], 'sourceTileCount':len(tiles),'seams':[(x['orientation'],x['meanRgbRmsDelta']) for x in seams]}))