@bitmagic/cli 0.1.66-dev.6 → 0.1.66-dev.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +37 -1
  2. package/assets/blender-level/find_blender.py +3 -1
  3. package/assets/character-skin-authoring/SKILL.md +43 -0
  4. package/assets/character-skin-authoring/references/exporters.md +134 -0
  5. package/assets/character-skin-authoring/references/eyes.md +215 -0
  6. package/assets/character-skin-authoring/references/materials.md +115 -0
  7. package/assets/character-skin-authoring/references/playground.md +78 -0
  8. package/assets/character-skin-authoring/references/profiles.md +97 -0
  9. package/assets/character-skin-authoring/references/projection.md +190 -0
  10. package/assets/character-skin-authoring/references/regional-detail.md +72 -0
  11. package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
  12. package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
  13. package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
  14. package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
  15. package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
  16. package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
  17. package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
  18. package/assets/character-skin-authoring/scripts/character_build.py +54 -0
  19. package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
  20. package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
  21. package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
  22. package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
  23. package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
  24. package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
  25. package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
  26. package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
  27. package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
  28. package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
  29. package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
  30. package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
  31. package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
  32. package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
  33. package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
  34. package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
  35. package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
  36. package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
  37. package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
  38. package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
  39. package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
  40. package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
  41. package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
  42. package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
  43. package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
  44. package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
  45. package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
  46. package/assets/metahuman/tools/lipsync/make_line.py +181 -0
  47. package/assets/metahuman/tools/metahuman/README.md +21 -0
  48. package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
  49. package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
  50. package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
  51. package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
  52. package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
  53. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
  54. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
  55. package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
  56. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
  57. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
  58. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
  59. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
  60. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
  61. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
  62. package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
  63. package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
  64. package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
  65. package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
  66. package/assets/metahuman/tools/metahuman/package.json +13 -0
  67. package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
  68. package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
  69. package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
  70. package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
  71. package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
  72. package/dist/assets/voxelize-glb.d.ts +5 -1
  73. package/dist/assets/voxelize-glb.js +1 -0
  74. package/dist/assets/voxelize-glb.js.map +1 -1
  75. package/dist/assets/vxl-summary.js +2 -2
  76. package/dist/commands/generate.d.ts +62 -0
  77. package/dist/commands/generate.js +241 -2
  78. package/dist/commands/generate.js.map +1 -1
  79. package/dist/commands/init.js +2 -1
  80. package/dist/commands/init.js.map +1 -1
  81. package/dist/commands/judge.js +5 -0
  82. package/dist/commands/judge.js.map +1 -1
  83. package/dist/commands/verify.js +7 -0
  84. package/dist/commands/verify.js.map +1 -1
  85. package/dist/generate/audio-file.d.ts +6 -0
  86. package/dist/generate/audio-file.js +42 -0
  87. package/dist/generate/audio-file.js.map +1 -0
  88. package/dist/generate/model.js +3 -3
  89. package/dist/generate/model.js.map +1 -1
  90. package/dist/generate/music.d.ts +4 -3
  91. package/dist/generate/music.js +6 -23
  92. package/dist/generate/music.js.map +1 -1
  93. package/dist/generate/prop.d.ts +19 -1
  94. package/dist/generate/prop.js +34 -6
  95. package/dist/generate/prop.js.map +1 -1
  96. package/dist/generate/stream.d.ts +20 -0
  97. package/dist/generate/stream.js +15 -1
  98. package/dist/generate/stream.js.map +1 -1
  99. package/dist/scaffold/asset-pipeline-skill-files.js +6 -1
  100. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
  101. package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
  102. package/dist/scaffold/character-skin-skill-files.js +49 -0
  103. package/dist/scaffold/character-skin-skill-files.js.map +1 -0
  104. package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
  105. package/dist/scaffold/metahuman-skill-files.js +306 -0
  106. package/dist/scaffold/metahuman-skill-files.js.map +1 -0
  107. package/dist/scaffold/project-files.js +28 -19
  108. package/dist/scaffold/project-files.js.map +1 -1
  109. package/dist/scaffold/project.js +4 -0
  110. package/dist/scaffold/project.js.map +1 -1
  111. package/dist/scaffold/skill-files.js +4 -2
  112. package/dist/scaffold/skill-files.js.map +1 -1
  113. package/dist/telemetry/command-context.d.ts +23 -0
  114. package/dist/telemetry/command-context.js +11 -0
  115. package/dist/telemetry/command-context.js.map +1 -1
  116. package/dist/telemetry/report.d.ts +1 -0
  117. package/dist/telemetry/report.js +4 -1
  118. package/dist/telemetry/report.js.map +1 -1
  119. package/dist/tools/bundles.js +8 -0
  120. package/dist/tools/bundles.js.map +1 -1
  121. package/dist/verify/browser.js +25 -0
  122. package/dist/verify/browser.js.map +1 -1
  123. package/dist/verify/categories.d.ts +16 -0
  124. package/dist/verify/categories.js +51 -0
  125. package/dist/verify/categories.js.map +1 -0
  126. package/package.json +10 -7
@@ -0,0 +1,38 @@
1
+ """Evaluate relief from an explicitly selected, locally planar tangent-normal patch.
2
+
3
+ Frankot/Chellappa (1988), discrete integration (eq.22). This does not recover a
4
+ donor's missing surface metric or turn an authored normal texture into a scan.
5
+ """
6
+ import numpy as np
7
+ from scipy.ndimage import gaussian_filter
8
+
9
+ def integrate_slopes(dx,dy,spacing):
10
+ dx=np.asarray(dx,float);dy=np.asarray(dy,float)
11
+ if dx.shape!=dy.shape or dx.ndim!=2 or not np.isfinite(dx).all() or not np.isfinite(dy).all() or not np.isfinite(spacing) or spacing<=0:
12
+ raise ValueError('Finite slope fields and positive spacing required')
13
+ wx=2*np.pi*np.fft.fftfreq(dx.shape[1]);wy=2*np.pi*np.fft.fftfreq(dx.shape[0])
14
+ ax=1j*np.sin(wx)[None,:]/spacing;ay=1j*np.sin(wy)[:,None]/spacing
15
+ denominator=abs(ax)**2+abs(ay)**2
16
+ numerator=ax.conj()*np.fft.fft2(dx)+ay.conj()*np.fft.fft2(dy)
17
+ spectrum=np.zeros_like(numerator);valid=denominator>1e-12/spacing**2
18
+ spectrum[valid]=numerator[valid]/denominator[valid]
19
+ return np.fft.ifft2(spectrum).real
20
+
21
+ def normal_patch_height(rgb,width_m,normal_y,low_sigma_m=.00012,high_sigma_m=.004):
22
+ rgb=np.asarray(rgb,float)
23
+ if rgb.ndim!=3 or rgb.shape[2]!=3 or rgb.shape[0]!=rgb.shape[1] or min(rgb.shape[:2])<16:
24
+ raise ValueError('A square, explicitly selected normal patch is required')
25
+ if not np.isfinite(rgb).all() or np.any(rgb<0) or np.any(rgb>1) or not np.isfinite(width_m) or width_m<=0:
26
+ raise ValueError('Normal values must be linear [0,1]; patch scale must be positive')
27
+ if normal_y not in ['up','down'] or not 0<low_sigma_m<high_sigma_m<width_m/2:
28
+ raise ValueError('Declare normal Y convention and valid physical filter widths')
29
+ n=rgb*2-1
30
+ if np.any(n[:,:,2]<.1):raise ValueError('Selected patch contains unsupported grazing/back-facing normals')
31
+ dx=-n[:,:,0]/n[:,:,2];dy=n[:,:,1]/n[:,:,2]*(1 if normal_y=='up' else -1)
32
+ # Even-reflected height has odd derivative across the corresponding boundary.
33
+ # Integrate the resulting periodic extension, then retain the original patch.
34
+ dx=np.concatenate([dx,-dx[:,::-1]],axis=1);dx=np.concatenate([dx,dx[::-1]],axis=0)
35
+ dy=np.concatenate([dy,dy[:,::-1]],axis=1);dy=np.concatenate([dy,-dy[::-1]],axis=0)
36
+ spacing=width_m/(len(rgb)-1)
37
+ height=integrate_slopes(dx,dy,spacing)[:len(rgb),:len(rgb)]
38
+ return gaussian_filter(height,low_sigma_m/spacing)-gaussian_filter(height,high_sigma_m/spacing)
@@ -0,0 +1,23 @@
1
+ """Render the licensed Lee Perry-Smith donor in the exact registration coordinates.
2
+
3
+ This supplies an inspectable image for source landmarks, not target anatomy.
4
+ """
5
+ import argparse,hashlib,json
6
+ from pathlib import Path
7
+ import numpy as np
8
+ from PIL import Image
9
+ from face_atlas import rasterize,sample
10
+ from glb_arrays import read,accessor
11
+
12
+ def prepare(workspace,out,size=900):
13
+ if not 128<=size<=2048:raise ValueError('Reference view size must be 128..2048')
14
+ reference=workspace/'highquality/references/lee-perry-smith';source=reference/'LeePerrySmith.glb';color=reference/'Map-COL.jpg';bounds=[-3,-3,3,4]
15
+ doc,data=read(source);prim=doc['meshes'][0]['primitives'][0]
16
+ points=accessor(doc,data,prim['attributes']['POSITION']);uv=accessor(doc,data,prim['attributes']['TEXCOORD_0']).copy();uv[:,1]=1-uv[:,1]
17
+ lookup,valid=rasterize(points,accessor(doc,data,prim['indices']).reshape(-1,3),uv,bounds,size)
18
+ rgb=sample(np.asarray(Image.open(color).convert('RGB'),float)/255,lookup);rgb[~valid]=.18
19
+ out.mkdir(parents=True,exist_ok=True);image=out/'donor-front.png';Image.fromarray(np.round(np.flipud(rgb)*255).astype(np.uint8)).save(image)
20
+ receipt={'sourceBounds':bounds,'sourceAnnotationImageSize':size,'imageSha256':hashlib.sha256(image.read_bytes()).hexdigest(),'sourceSha256':hashlib.sha256(source.read_bytes()).hexdigest(),'colorSha256':hashlib.sha256(color.read_bytes()).hexdigest(),'coordinateConvention':'Donor X/Y plane, nearest positive Z; top-left image pixels; matches build_face_detail sourcePixels','license':'CC BY 3.0','attribution':'Lee Perry-Smith / Infinite Realities; projection derivative'}
21
+ (out/'registration-view.json').write_text(json.dumps(receipt,indent=2)+'\n');print(json.dumps(receipt))
22
+ if __name__=='__main__':
23
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--workspace',required=True,type=Path);p.add_argument('--out',required=True,type=Path);p.add_argument('--size',type=int,default=900);a=p.parse_args();prepare(a.workspace.resolve(),a.out.resolve(),a.size)
@@ -0,0 +1,51 @@
1
+ """Pack explicitly registered regional detail alongside the facial atlas.
2
+
3
+ One shader sampler and one existing VEC3 binding; body coverage remains authoritative.
4
+ Overlapping vertex bindings require an authored combined patch, not an automatic winner.
5
+ """
6
+ import numpy as np
7
+
8
+ def pack_regions(face,expression,binding,points,normals,labels,visibility,planes,height_range,triangles=None):
9
+ if not planes:return face,expression,binding,{}
10
+ if face.shape!=expression.shape or face.ndim!=3 or face.shape[2]!=4 or height_range<=0:
11
+ raise ValueError('Regional atlas requires matching RGBA facial maps and physical height range')
12
+ width=face.shape[1];cursor=face.shape[0]+2;slots=[]
13
+ for plane in planes:
14
+ yy,xx=np.nonzero(plane['feather']>0)
15
+ if not len(xx):raise ValueError('Independent detail patch has no authored support')
16
+ if xx.min()<2 or yy.min()<2 or xx.max()>=plane['size']-2 or yy.max()>=plane['size']-2:
17
+ raise ValueError('Independent detail requires a transparent margin inside its evidence view')
18
+ x0=max(0,int(xx.min())-2);x1=min(plane['size'],int(xx.max())+3)
19
+ y0=max(0,int(yy.min())-2);y1=min(plane['size'],int(yy.max())+3)
20
+ if x1-x0+4>width:raise ValueError('Registered patch exceeds atlas width')
21
+ slots.append((plane,(x0,y0,x1,y1),2,cursor));cursor+=y1-y0+4
22
+ if cursor>8192 or width*2>8192:raise ValueError('Regional atlas exceeds the supported 8192 texture extent')
23
+ detail=np.full((cursor,width,4),.5,np.float32);detail[:,:,3]=0
24
+ dynamic=detail.copy();detail[:len(face)]=face;dynamic[:len(face)]=expression
25
+ result=binding.copy();result[:,1]*=len(face)/cursor
26
+ assigned=np.zeros(len(points),bool);records=[]
27
+ for plane,(x0,y0,x1,y1),dest_x,dest_y in slots:
28
+ h=plane['height'];crop_height=np.zeros((y1-y0,x1-x0)) if h is None else h[y0:y1,x0:x1]
29
+ tile=np.stack([np.clip(.5+crop_height/height_range,0,1),plane['roughness'][y0:y1,x0:x1],np.full(crop_height.shape,.5),plane['feather'][y0:y1,x0:x1]],axis=-1)
30
+ detail[dest_y:dest_y+len(tile),dest_x:dest_x+tile.shape[1]]=tile
31
+ local=points-plane['target'];pixels=np.stack([(local@plane['right']/plane['span']+.5)*plane['size'],(.5-local@plane['up']/plane['span'])*plane['size']],axis=1)
32
+ seen=((visibility&(1<<plane['index']))!=0)&np.all((pixels>=0)&(pixels<plane['size']),axis=1)
33
+ region=seen&(labels==plane['region'])
34
+ support=region.copy()
35
+ if triangles is not None:
36
+ # An inactive vertex still interpolates UVs. Give adjacent triangle
37
+ # corners coherent patch coordinates, but keep their detail weight zero.
38
+ touching=triangles[np.any(region[triangles],axis=1)]
39
+ support[np.unique(touching)]=True
40
+ if np.any(support&assigned) or np.any(support&(binding[:,2]>0)):
41
+ raise ValueError('Independent detail regions overlap; author a combined binding explicitly')
42
+ # Clamp to this tile's transparent boundary, not another atlas region.
43
+ q=np.clip(pixels-np.array([x0,y0]),[.5,.5],[x1-x0-1.5,y1-y0-1.5])
44
+ result[support,0]=(q[support,0]+dest_x)/width
45
+ result[support,1]=(q[support,1]+dest_y)/cursor
46
+ result[support,2]=0
47
+ facing=np.clip((abs(normals@plane['direction'])-.2)/.3,0,1)
48
+ result[region,2]=(facing*facing*(3-2*facing))[region]
49
+ assigned|=support
50
+ records.append({'viewIndex':plane['index'],'region':plane['region'],'sourceViewResolution':plane['size'],'cropPixels':[x0,y0,x1,y1],'atlasPixels':[dest_x,dest_y,x1-x0,y1-y0],'boundVertices':int(region.sum()),'zeroWeightSupportVertices':int((support&~region).sum())})
51
+ return detail,dynamic,result,{'skinFacialAtlasRegion':[0,0,1,len(face)/cursor],'skinRegionalAtlasPatches':records,'skinRegionalAtlasHeightMode':'additive-v1'}
@@ -0,0 +1,90 @@
1
+ """Select a local preview only after exact-asset rendering and recorded visual review.
2
+
3
+ This validates receipts. The agent supplies the visual verdict; no image classification
4
+ or whole-character acceptance is performed here.
5
+ """
6
+ import argparse,hashlib,json,shutil,tempfile
7
+ from pathlib import Path
8
+
9
+ def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
10
+
11
+ def select(asset,render_report,visual_verdict,metadata,variant=None):
12
+ asset,render_report,visual_verdict,metadata=map(Path,(asset,render_report,visual_verdict,metadata))
13
+ sha=digest(asset);reports=json.loads(render_report.read_text());verdict=json.loads(visual_verdict.read_text());record=json.loads(metadata.read_text())
14
+ if not isinstance(reports,list) or {row.get('backend') for row in reports}!={'webgl','webgpu'}:
15
+ raise ValueError('Both rendering backends require current receipts')
16
+ for row in reports:
17
+ rendered=row.get('assetHashes',{}).get(variant) if variant else row.get('outputSha256')
18
+ if rendered!=sha or row.get('actual')!=row['backend'] or row.get('errors')!=[]:
19
+ raise ValueError('Rendering receipt is stale, failed, or used a fallback')
20
+ if verdict.get('outputSha256')!=sha or verdict.get('status')!='local_preview_with_known_issues' or verdict.get('wholeCharacterAccepted') is not False:
21
+ raise ValueError('Explicit current local-preview verdict required')
22
+ images=verdict.get('visuallyInspected',[])
23
+ if not images:raise ValueError('No inspected images recorded')
24
+ for image in images:
25
+ if digest(visual_verdict.parent/image['file'])!=image['sha256']:
26
+ raise ValueError('Inspected image changed after visual review')
27
+ if record.get('outputSha256',record.get('output_sha256'))!=sha:
28
+ raise ValueError('Candidate metadata belongs to another asset')
29
+ # Keep the last reviewed bytes independent of the next candidate build.
30
+ # Publish the pointer only after the complete snapshot is safely in place.
31
+ snapshots=asset.parent/'.previews';snapshots.mkdir(exist_ok=True)
32
+ review_sha=hashlib.sha256(render_report.read_bytes()+visual_verdict.read_bytes()).hexdigest()
33
+ (snapshots/sha).mkdir(exist_ok=True)
34
+ destination=snapshots/sha/review_sha
35
+ if not destination.exists():
36
+ staging=Path(tempfile.mkdtemp(prefix='pending-',dir=snapshots))
37
+ try:
38
+ shutil.copyfile(asset,staging/asset.name)
39
+ if digest(staging/asset.name)!=sha:raise ValueError('Candidate changed while snapshotting')
40
+ saved_verdict=dict(verdict);saved_verdict['visuallyInspected']=[]
41
+ for index,image in enumerate(images):
42
+ source_image=visual_verdict.parent/image['file'];name=f"view-{index}{source_image.suffix}"
43
+ shutil.copyfile(source_image,staging/name)
44
+ if digest(staging/name)!=image['sha256']:raise ValueError('Inspected image changed while snapshotting')
45
+ saved_verdict['visuallyInspected'].append(dict(image,file=name))
46
+ (staging/'visual-verdict.json').write_text(json.dumps(saved_verdict,indent=2)+'\n')
47
+ (staging/'render-report.json').write_text(json.dumps(reports,indent=2)+'\n')
48
+ saved_record=dict(record,viewerReady=True,visualReview='visual-verdict.json')
49
+ (staging/'metadata.json').write_text(json.dumps(saved_record,indent=2)+'\n')
50
+ staging.replace(destination)
51
+ finally:
52
+ if staging.exists():shutil.rmtree(staging)
53
+ elif digest(destination/asset.name)!=sha:
54
+ raise ValueError('Previously selected snapshot is corrupted')
55
+ pointer=asset.with_suffix('.selected.json');temporary_pointer=pointer.with_suffix('.tmp')
56
+ temporary_pointer.write_text(json.dumps({'asset':str((destination/asset.name).relative_to(asset.parent)),
57
+ 'outputSha256':sha,'reviewSha256':review_sha,'status':'local_preview_with_known_issues','wholeCharacterAccepted':False},indent=2)+'\n')
58
+ temporary_pointer.replace(pointer)
59
+ record.update(viewerReady=True,visualReview=str(visual_verdict.resolve()))
60
+ temporary=metadata.with_suffix('.preview.tmp')
61
+ temporary.write_text(json.dumps(record,indent=2)+'\n');temporary.replace(metadata)
62
+ return {'asset':str(asset),'outputSha256':sha,'status':'local_preview_with_known_issues'}
63
+
64
+
65
+
66
+ def selected_preview(asset):
67
+ """Resolve a reviewed immutable snapshot without following arbitrary paths."""
68
+ asset=Path(asset);pointer=asset.with_suffix('.selected.json')
69
+ if not pointer.is_file():return None
70
+ record=json.loads(pointer.read_text());sha=record.get('outputSha256','')
71
+ if len(sha)!=64 or any(c not in '0123456789abcdef' for c in sha):raise ValueError('Invalid selected snapshot identity')
72
+ review_sha=record.get('reviewSha256')
73
+ if review_sha is not None and (len(review_sha)!=64 or any(c not in '0123456789abcdef' for c in review_sha)):raise ValueError('Invalid selected review identity')
74
+ expected=Path('.previews')/sha
75
+ if review_sha:expected/=review_sha
76
+ expected/=asset.name
77
+ if record.get('asset')!=str(expected) or record.get('wholeCharacterAccepted') is not False:raise ValueError('Invalid selected snapshot path or verdict')
78
+ path=asset.parent/expected
79
+ metadata=json.loads((path.parent/'metadata.json').read_text())
80
+ if not path.is_file() or metadata.get('outputSha256',metadata.get('output_sha256'))!=sha or not metadata.get('viewerReady'):
81
+ raise ValueError('Selected snapshot is incomplete')
82
+ return path
83
+
84
+
85
+ if __name__=='__main__':
86
+ parser=argparse.ArgumentParser(description=__doc__)
87
+ for name in ['asset','render-report','visual-verdict','metadata']:parser.add_argument('--'+name,required=True,type=Path)
88
+ parser.add_argument('--variant',choices=['eyes','surfaces']);args=parser.parse_args()
89
+ print(json.dumps(select(args.asset,args.render_report,args.visual_verdict,args.metadata,args.variant)))
90
+
@@ -0,0 +1,267 @@
1
+ """Deterministic visible-surface projection. The agent supplies all semantic labels.
2
+
3
+ prepare uses Blender BVH rays (no lighting, AI, color classification or pose guesses).
4
+ bake projects reviewed polygons into UV space, retaining unknown/conflict coverage.
5
+ """
6
+ import argparse,hashlib,json,subprocess,sys,shutil
7
+ from pathlib import Path
8
+ import numpy as np
9
+
10
+ LABELS={'unknown':0,'skin':1,'palm':2,'sole':3,'lips':4,'eyes':5,'hair':6,'nails':7,'non_skin':8,'dorsal_hand':9}
11
+
12
+ def dense_worker(c,bvh,p,tri,uv,mi):
13
+ """Inverse UV bake: texel -> surface -> annotated view, with exact visibility."""
14
+ from mathutils import Vector
15
+ n=c['resolution'];out=Path(c['output']);height=np.ptp(p[:,2]);distance=np.linalg.norm(np.ptp(p,axis=0))*2
16
+ center=(p.min(0)+p.max(0))/2;all_votes=[];overlaps=[]
17
+ for material in range(len(c['materials'])):
18
+ position=np.zeros((n*n,3),np.float32);face_ids=np.full(n*n,-1,np.int32);overlap=np.zeros(n*n,bool)
19
+ selected=np.flatnonzero(mi==material)
20
+ for batch in np.array_split(selected,max(1,int(np.ceil(len(selected)/2048)))):
21
+ q=uv[batch].copy();q[:,:,1]=1-q[:,:,1];q=q*n-.5
22
+ lo=np.maximum(np.ceil(q.min(1)).astype(int),0);hi=np.minimum(np.floor(q.max(1)).astype(int),n-1)
23
+ width=np.maximum(hi[:,0]-lo[:,0]+1,0);height_px=np.maximum(hi[:,1]-lo[:,1]+1,0);count=width*height_px
24
+ if count.sum()==0:continue
25
+ local=np.repeat(np.arange(len(batch)),count);starts=np.repeat(np.cumsum(count)-count,count);offset=np.arange(count.sum())-starts
26
+ x=lo[local,0]+offset%width[local];y=lo[local,1]+offset//width[local];t=q[local]
27
+ den=(t[:,1,1]-t[:,2,1])*(t[:,0,0]-t[:,2,0])+(t[:,2,0]-t[:,1,0])*(t[:,0,1]-t[:,2,1]);safe=np.where(abs(den)>1e-12,den,1)
28
+ a=((t[:,1,1]-t[:,2,1])*(x-t[:,2,0])+(t[:,2,0]-t[:,1,0])*(y-t[:,2,1]))/safe
29
+ b=((t[:,2,1]-t[:,0,1])*(x-t[:,2,0])+(t[:,0,0]-t[:,2,0])*(y-t[:,2,1]))/safe;d=1-a-b
30
+ valid=(abs(den)>1e-12)&(a>=0)&(b>=0)&(d>=0);idx=(y*n+x)[valid];f=batch[local[valid]]
31
+ xyz=(p[tri[f,0]]*a[valid,None]+p[tri[f,1]]*b[valid,None]+p[tri[f,2]]*d[valid,None])
32
+ # Record distinct surfaces sharing a texel, including collisions within a batch.
33
+ order=np.argsort(idx,kind='stable');ii=idx[order];xx=xyz[order];dup=ii[1:]==ii[:-1]
34
+ overlap[ii[1:][dup&(np.linalg.norm(xx[1:]-xx[:-1],axis=1)>height*1e-5)]]=True
35
+ exists=face_ids[idx]>=0;overlap[idx[exists&(np.linalg.norm(position[idx]-xyz,axis=1)>height*1e-5)]]=True
36
+ position[idx]=xyz;face_ids[idx]=f
37
+ occupied=face_ids>=0
38
+ view_bits=np.zeros(n*n,np.uint32);visibility_bits=np.zeros(n*n,np.uint32)
39
+ v=np.zeros((len(LABELS),n*n),np.uint32)
40
+ normals=np.cross(p[tri[:,1]]-p[tri[:,0]],p[tri[:,2]]-p[tri[:,0]]);normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-15)
41
+ for view_index,view in enumerate(c['views']):
42
+ if view_index>=32:raise ValueError('Surface evidence supports at most 32 views per revision')
43
+ label_path=out/(view['name']+'-annotation.npy')
44
+ if not label_path.exists() and not c.get('surfaceData'):continue
45
+ labels=np.load(label_path) if label_path.exists() else np.zeros((c['size'],c['size']),np.uint8);direction=np.array(view['direction'],float);direction/=np.linalg.norm(direction)
46
+ up=np.array(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
47
+ target=center+height*np.array(view.get('targetOffset',[0,0,0]));span=height*view.get('span',1.1)
48
+ ids=np.flatnonzero(occupied&~overlap);points=position[ids];delta=points-target
49
+ x=np.floor((delta@right/span+.5)*c['size']).astype(int);y=np.floor((.5-delta@up/span)*c['size']).astype(int)
50
+ keep=(x>=0)&(x<c['size'])&(y>=0)&(y<c['size'])&(abs(normals[face_ids[ids]]@direction)>.2)
51
+ ids=ids[keep];x=x[keep];y=y[keep];label=labels[y,x];keep=np.ones(len(label),bool) if c.get('surfaceData') else label>0;ids=ids[keep];label=label[keep];ray=Vector(direction.tolist())
52
+ for index,kind in zip(ids,label):
53
+ point=position[index];hit,_,_,_=bvh.ray_cast(Vector((point-direction*distance).tolist()),ray,distance*2)
54
+ if hit is not None and np.linalg.norm(np.asarray(hit)-point)<height*1e-5:
55
+ visibility_bits[index]|=np.uint32(1<<view_index)
56
+ if kind>0:v[kind,index]+=1;view_bits[index]|=np.uint32(1<<view_index)
57
+ print('UV visibility',material,view['name'],len(ids),flush=True)
58
+ if c.get('surfaceData'):np.savez_compressed(out/f'material-{material}-surface.npz',position=position.reshape(n,n,3),normal=normals[np.maximum(face_ids,0)].astype(np.float32).reshape(n,n,3),occupied=occupied.reshape(n,n),viewBits=view_bits.reshape(n,n),visibilityBits=visibility_bits.reshape(n,n))
59
+ all_votes.append(v.reshape(len(LABELS),n,n));overlaps.append(overlap.reshape(n,n))
60
+ np.savez_compressed(out/'dense-votes.npz',**{f'm{i}':v for i,v in enumerate(all_votes)},**{f'overlap{i}':a for i,a in enumerate(overlaps)})
61
+
62
+ def triangle_worker(c,bvh,p,tri):
63
+ """Classify actual triangle centers against the same reviewed camera evidence.
64
+
65
+ This avoids raster-resolution-dependent primitive boundaries. Visibility is exact;
66
+ no UV hole filling or spatial anatomy rules are involved.
67
+ """
68
+ from mathutils import Vector
69
+ out=Path(c['output']);points=p[tri].mean(1);height=np.ptp(p[:,2]);center=(p.min(0)+p.max(0))/2;distance=np.linalg.norm(np.ptp(p,axis=0))*2
70
+ normals=np.cross(p[tri[:,1]]-p[tri[:,0]],p[tri[:,2]]-p[tri[:,0]]);normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-15)
71
+ bits=np.zeros(len(tri),np.uint16)
72
+ for view in c['views']:
73
+ label_path=out/(view['name']+'-annotation.npy')
74
+ if not label_path.exists():continue
75
+ labels=np.load(label_path);direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
76
+ up=np.asarray(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
77
+ target=center+height*np.asarray(view.get('targetOffset',[0,0,0]));span=height*view.get('span',1.1);delta=points-target
78
+ x=np.floor((delta@right/span+.5)*c['size']).astype(int);y=np.floor((.5-delta@up/span)*c['size']).astype(int)
79
+ valid=(x>=0)&(x<c['size'])&(y>=0)&(y<c['size'])&(abs(normals@direction)>.2);ids=np.flatnonzero(valid);kinds=labels[y[ids],x[ids]];valid=kinds>0;ids=ids[valid];kinds=kinds[valid];ray=Vector(direction.tolist())
80
+ for index,kind in zip(ids,kinds):
81
+ point=points[index];hit,_,_,_=bvh.ray_cast(Vector((point-direction*distance).tolist()),ray,distance*2)
82
+ if hit is not None and np.linalg.norm(np.asarray(hit)-point)<height*1e-5:bits[index]|=np.uint16(1<<int(kind))
83
+ print('Triangle visibility',view['name'],len(ids),flush=True)
84
+ subtype=(bits&((1<<2)|(1<<3)|(1<<9)))!=0;bits[subtype]&=np.uint16(65535^(1<<1));conflict=(bits&(bits-1))!=0
85
+ labels=np.zeros(len(tri),np.uint8)
86
+ for kind in LABELS.values():
87
+ if kind:labels[bits==(1<<kind)]=kind
88
+ np.savez_compressed(out/'triangles.npz',centers=points.astype(np.float32),labels=labels,conflict=conflict)
89
+
90
+ def worker(config):
91
+ import bpy
92
+ from mathutils import Vector
93
+ from mathutils.bvhtree import BVHTree
94
+ c=json.loads(Path(config).read_text());out=Path(c['output'])
95
+ bpy.ops.wm.read_factory_settings(use_empty=True)
96
+ bpy.ops.import_scene.gltf(filepath=c['source'])
97
+ depsgraph=bpy.context.evaluated_depsgraph_get()
98
+ vertices=[];triangles=[];uvs=[];mats=[];materials=[];offset=0
99
+ for obj in bpy.context.scene.objects:
100
+ if obj.type!='MESH':continue
101
+ evaluated=obj.evaluated_get(depsgraph);mesh=evaluated.to_mesh();mesh.calc_loop_triangles()
102
+ if mesh.uv_layers.active is None:raise ValueError(f'Missing UVs: {obj.name}')
103
+ vertices.extend([tuple(evaluated.matrix_world@v.co) for v in mesh.vertices])
104
+ slots=[]
105
+ for slot in obj.material_slots:
106
+ material=slot.material
107
+ if material not in materials:materials.append(material)
108
+ slots.append(materials.index(material))
109
+ for t in mesh.loop_triangles:
110
+ triangles.append([offset+i for i in t.vertices]);uvs.append([tuple(mesh.uv_layers.active.data[i].uv) for i in t.loops]);mats.append(slots[t.material_index])
111
+ offset+=len(mesh.vertices);evaluated.to_mesh_clear()
112
+ p=np.array(vertices);tri=np.array(triangles);uv=np.array(uvs);mi=np.array(mats)
113
+ bvh=BVHTree.FromPolygons(vertices,triangles,all_triangles=True)
114
+ textures=[];info=[]
115
+ for m in materials:
116
+ bsdf=next((n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED'),None)
117
+ if bsdf is None:raise ValueError('Expected Principled base-color material')
118
+ socket=bsdf.inputs['Base Color'];color=np.array(socket.default_value[:3]);im=None
119
+ if socket.is_linked:
120
+ node=socket.links[0].from_node
121
+ if node.type!='TEX_IMAGE':raise ValueError('Base color must link directly to its image; prepare a faithful albedo bake first')
122
+ im=node.image
123
+ if im:
124
+ if im.is_float:raise ValueError('Floating-point base-color images need an explicit sRGB albedo bake for evidence')
125
+ # Blender byte-image buffers expose encoded channel values. Encoding
126
+ # these again washes out the evidence (128 becomes about 188).
127
+ pixels=np.array(im.pixels[:],dtype=np.float32).reshape(im.size[1],im.size[0],4)[:,:,:3]
128
+ else:
129
+ pixels=np.where(color<=.0031308,color*12.92,1.055*np.maximum(color,0)**(1/2.4)-.055).reshape(1,1,3)
130
+ textures.append(pixels);info.append({'name':m.name,'width':pixels.shape[1],'height':pixels.shape[0]})
131
+ if c.get('mode')=='dense':
132
+ if [x['name'] for x in info]!=[x['name'] for x in c['materials']]:raise ValueError('Material order changed')
133
+ dense_worker(c,bvh,p,tri,uv,mi)
134
+ if c.get('triangleLabels'):triangle_worker(c,bvh,p,tri)
135
+ return
136
+ lo=p.min(0);hi=p.max(0);center=(lo+hi)/2;height=hi[2]-lo[2]
137
+ # Blender world axes are recorded, not assumed to mean anatomical front.
138
+ for view in c['views']:
139
+ direction=np.array(view['direction'],dtype=float)
140
+ if direction.shape!=(3,) or not np.isfinite(direction).all() or np.linalg.norm(direction)==0:raise ValueError('Invalid view direction')
141
+ direction/=np.linalg.norm(direction)
142
+ up=np.array(view.get('up',[0,0,1]),dtype=float);right=np.cross(direction,up)
143
+ if np.linalg.norm(right)<1e-8:raise ValueError('View up is parallel to direction')
144
+ right/=np.linalg.norm(right);up=np.cross(right,direction)
145
+ target=center+height*np.array(view.get('targetOffset',[0,0,0]));span=height*view.get('span',1.1)
146
+ n=c['size'];rgb=np.full((n,n,3),.2,np.float32);coord=np.zeros((n,n,2),np.float32);material_ids=np.full((n,n),-1,np.int16);facing=np.zeros((n,n),np.float32)
147
+ distance=float(np.linalg.norm(hi-lo)*2);ray=Vector(direction.tolist())
148
+ for y in range(n):
149
+ for x in range(n):
150
+ origin=target-direction*distance+right*((x+.5)/n-.5)*span+up*(.5-(y+.5)/n)*span
151
+ hit,normal,face,_=bvh.ray_cast(Vector(origin.tolist()),ray,distance*2)
152
+ if face is None:continue
153
+ a,b,d=p[tri[face]];v0=b-a;v1=d-a;v2=np.array(hit)-a
154
+ aa=v0@v0;ab=v0@v1;bb=v1@v1;ac=v0@v2;bc=v1@v2;den=aa*bb-ab*ab
155
+ if abs(den)<1e-20:continue
156
+ wb=(bb*ac-ab*bc)/den;wc=(aa*bc-ab*ac)/den
157
+ texuv=(1-wb-wc)*uv[face,0]+wb*uv[face,1]+wc*uv[face,2]
158
+ mat=int(mi[face]);pixels=textures[mat];h,w=pixels.shape[:2]
159
+ xx=int(np.clip(texuv[0]*w,0,w-1));yy=int(np.clip(texuv[1]*h,0,h-1))
160
+ rgb[y,x]=pixels[yy,xx];coord[y,x]=texuv;material_ids[y,x]=mat;facing[y,x]=abs(np.dot(normal,direction))
161
+ np.savez_compressed(out/(view['name']+'.npz'),uv=coord,material=material_ids,facing=facing)
162
+ np.save(out/(view['name']+'.rgb.npy'),np.uint8(np.clip(rgb,0,1)*255))
163
+ print('Rendered',view['name'],flush=True)
164
+ c['materials']=info;c['worldBounds']=[lo.tolist(),hi.tolist()];c['labels']=LABELS;c['evidenceColorSpace']='sRGB';c['byteImageTransfer']='encoded values preserved; no additional sRGB encoding'
165
+ (out/'manifest.json').write_text(json.dumps(c,indent=2)+'\n')
166
+
167
+ def prepare(args):
168
+ from PIL import Image
169
+ out=Path(args.output).resolve();out.mkdir(parents=True,exist_ok=True)
170
+ if (out/'manifest.json').exists():raise ValueError('Use a new output revision; existing evidence is immutable')
171
+ source=Path(args.source).resolve();views=json.loads(Path(args.views).read_text())
172
+ c={'source':str(source),'sourceSha256':hashlib.sha256(source.read_bytes()).hexdigest(),'output':str(out),'size':args.size,'views':views}
173
+ config=out/'request.json';config.write_text(json.dumps(c,indent=2))
174
+ blender=args.blender or shutil.which('blender')
175
+ if not blender and Path('/Applications/Blender.app/Contents/MacOS/Blender').exists():blender='/Applications/Blender.app/Contents/MacOS/Blender'
176
+ if not blender:raise ValueError('Pass --blender with the local Blender executable')
177
+ subprocess.run([blender,'--background','--python-exit-code','1','--python',str(Path(__file__).resolve()),'--','worker',str(config)],check=True)
178
+ for view in views:
179
+ path=out/(view['name']+'.rgb.npy');Image.fromarray(np.load(path)).save(out/(view['name']+'.png'));path.unlink()
180
+ manifest=json.loads((out/'manifest.json').read_text())
181
+ manifest['evidenceHashes']={view['name']+ext:hashlib.sha256((out/(view['name']+ext)).read_bytes()).hexdigest() for view in views for ext in ['.png','.npz']}
182
+ (out/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
183
+
184
+ def bake(args):
185
+ from PIL import Image,ImageDraw
186
+ folder=Path(args.evidence);m=json.loads((folder/'manifest.json').read_text());annotation=json.loads(Path(args.annotations).read_text());out=Path(args.output)
187
+ if out.exists():raise ValueError('Use a fresh bake output directory')
188
+ if annotation['sourceSha256']!=m['sourceSha256']:raise ValueError('Annotations belong to a different mesh')
189
+ if hashlib.sha256(Path(m['source']).read_bytes()).hexdigest()!=m['sourceSha256']:raise ValueError('Source changed after rendering')
190
+ for name in [v['name'] for v in m.get('views', [])] or annotation['views']:
191
+ for ext in ['.png','.npz']:
192
+ expected=m.get('evidenceHashes',{}).get(name+ext)
193
+ if expected is not None and hashlib.sha256((folder/(name+ext)).read_bytes()).hexdigest()!=expected:raise ValueError('Evidence changed after rendering: '+name+ext)
194
+ # Reject authoring errors before reserving the immutable output revision.
195
+ # A corrected annotation can then retry the same destination safely.
196
+ for name,regions in annotation['views'].items():
197
+ if not (folder/(name+'.npz')).is_file():raise ValueError('Unknown annotation view: '+name)
198
+ with Image.open(folder/(name+'.png')) as view:
199
+ if list(view.size)!=[m['size'],m['size']]:raise ValueError('Evidence dimensions changed')
200
+ for region in regions:
201
+ if region['label'] not in LABELS:raise ValueError('Unknown annotation label: '+region['label'])
202
+ points=region['polygon']
203
+ if len(points)<3 or not all(0<=x<m['size'] and 0<=y<m['size'] for x,y in points):raise ValueError('Invalid annotation polygon in '+name)
204
+ out.mkdir(parents=True);shutil.copyfile(args.annotations,out/'annotations.json');n=args.resolution;votes=[np.zeros((len(LABELS),n,n),np.uint32) for _ in m['materials']]
205
+ for name,regions in annotation['views'].items():
206
+ data=np.load(folder/(name+'.npz'));uv=data['uv'];ids=data['material'];view=Image.open(folder/(name+'.png'))
207
+ if list(view.size)!=[m['size'],m['size']]:raise ValueError('Evidence dimensions changed')
208
+ labels=Image.new('L',view.size,0);draw=ImageDraw.Draw(labels)
209
+ for region in regions:
210
+ label=LABELS[region['label']];points=region['polygon']
211
+ if len(points)<3 or not all(0<=x<m['size'] and 0<=y<m['size'] for x,y in points):raise ValueError('Invalid annotation polygon')
212
+ draw.polygon([tuple(p) for p in points],fill=label)
213
+ a=np.array(labels);a[ids<0]=0;palette=np.array([[0,0,0],[50,210,100],[60,170,210],[80,130,200],[240,90,140],[70,140,255],[180,60,200],[240,230,150],[140,140,140],[90,210,140]],dtype=np.uint8)
214
+ rgb=np.array(view.convert('RGB'));overlay=np.where((a>0)[...,None],(.45*rgb+.55*palette[a]).astype('uint8'),rgb)
215
+ Image.fromarray(overlay).save(out/(name+'-overlay.png'))
216
+ np.save(out/(name+'-annotation.npy'),a)
217
+ for mat in range(len(votes)):
218
+ ok=(ids==mat)&(a>0)&(data['facing']>.2)&(uv[:,:,0]>=0)&(uv[:,:,0]<=1)&(uv[:,:,1]>=0)&(uv[:,:,1]<=1)
219
+ x=np.clip((uv[:,:,0][ok]*n).astype(int),0,n-1);y=np.clip(((1-uv[:,:,1][ok])*n).astype(int),0,n-1)
220
+ np.add.at(votes[mat],(a[ok],y,x),1)
221
+ uv_overlaps=[np.zeros((n,n),bool) for _ in votes]
222
+ if getattr(args,'dense',False):
223
+ c=dict(m,mode='dense',output=str(out.resolve()),resolution=n,surfaceData=getattr(args,'surface_data',False),triangleLabels=getattr(args,'triangle_labels',False));config=out/'dense-request.json';config.write_text(json.dumps(c))
224
+ blender=getattr(args,'blender',None) or shutil.which('blender')
225
+ if not blender and Path('/Applications/Blender.app/Contents/MacOS/Blender').exists():blender='/Applications/Blender.app/Contents/MacOS/Blender'
226
+ if not blender:raise ValueError('Pass --blender for dense baking')
227
+ subprocess.run([blender,'--background','--python-exit-code','1','--python',str(Path(__file__).resolve()),'--','worker',str(config.resolve())],check=True)
228
+ d=np.load(out/'dense-votes.npz');votes=[d[f'm{i}'] for i in range(len(votes))];uv_overlaps=[d[f'overlap{i}'] for i in range(len(votes))]
229
+ report={'sourceSha256':m['sourceSha256'],'annotationSha256':hashlib.sha256(Path(args.annotations).read_bytes()).hexdigest(),'status':'review_required','method':'inverse_uv_visibility' if getattr(args,'dense',False) else 'sparse_projection','resolution':n,'labels':LABELS,'materials':[],'views':[]}
230
+ results=[];conflicts=[]
231
+ for i,v in enumerate(votes):
232
+ present=v>0
233
+ # A generic skin observation agrees with a more specific skin subtype.
234
+ # Palm versus dorsal hand (or skin versus hair) remains a real conflict.
235
+ subtype=present[[LABELS['palm'],LABELS['sole'],LABELS['dorsal_hand']]].any(0)
236
+ v[LABELS['skin'],subtype]=0;present=v>0
237
+ conflict=(present.sum(0)>1)|uv_overlaps[i];known=present.any(0);labels=v.argmax(0).astype('uint8');labels[conflict]=0
238
+ results.append(labels);conflicts.append(conflict)
239
+ # Sparse evidence stays sparse. Never silently fill an unseen palm/back.
240
+ Image.fromarray(labels).save(out/f'material-{i}-labels.png');Image.fromarray(np.uint8(known&~conflict)*255).save(out/f'material-{i}-observed.png');Image.fromarray(np.uint8(conflict)*255).save(out/f'material-{i}-conflicts.png')
241
+ report['materials'].append({'material':m['materials'][i]['name'],'observedTexels':int(known.sum()),'conflictTexels':int(conflict.sum()),'overlappingUvTexels':int(uv_overlaps[i].sum()),'labelCounts':{k:int((labels==v).sum()) for k,v in LABELS.items()}})
242
+ # Review the BAKED atlas through the original visibility buffer, not just polygons.
243
+ for name in [v['name'] for v in m.get('views', [])] or annotation['views']:
244
+ d=np.load(folder/(name+'.npz'));uv=d['uv'];ids=d['material'];visible=ids>=0
245
+ x=np.clip((uv[:,:,0]*n).astype(int),0,n-1);y=np.clip(((1-uv[:,:,1])*n).astype(int),0,n-1)
246
+ a=np.zeros(ids.shape,np.uint8);conflict=np.zeros(ids.shape,bool)
247
+ for mat in range(len(results)):
248
+ valid=(ids==mat)&(uv[:,:,0]>=0)&(uv[:,:,0]<=1)&(uv[:,:,1]>=0)&(uv[:,:,1]<=1)
249
+ a[valid]=results[mat][y[valid],x[valid]];conflict[valid]=conflicts[mat][y[valid],x[valid]]
250
+ rgb=np.array(Image.open(folder/(name+'.png')).convert('RGB'))
251
+ color=palette[a];color[conflict]=[255,80,20];unknown=visible&(a==0)&~conflict
252
+ color[unknown]=[160,45,45]
253
+ overlay=np.where(visible[...,None],(.5*rgb+.5*color).astype('uint8'),rgb)
254
+ Image.fromarray(overlay).save(out/(name+'-reprojected.png'))
255
+ Image.fromarray(a).save(out/(name+'-reprojected-labels.png'))
256
+ annotation_path=out/(name+'-annotation.npy');expected=np.load(annotation_path) if annotation_path.exists() else np.zeros_like(a)
257
+ annotated=visible&(expected>0)&(d['facing']>.2)
258
+ report['views'].append({'name':name,'visiblePixels':int(visible.sum()),'unknownPixels':int(unknown.sum()),'conflictPixels':int(conflict.sum()),'classifiedPixels':int((a>0).sum()),'annotatedVisiblePixels':int(annotated.sum()),'unresolvedAnnotatedPixels':int((annotated&(a==0)).sum()),'disagreeingAnnotatedPixels':int((annotated&(a>0)&(a!=expected)&~((expected==LABELS['skin'])&np.isin(a,[LABELS['palm'],LABELS['sole'],LABELS['dorsal_hand']]))).sum())})
259
+ (out/'report.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report))
260
+
261
+ if __name__=='__main__':
262
+ if '--' in sys.argv:
263
+ worker(sys.argv[sys.argv.index('--')+2]);sys.exit()
264
+ p=argparse.ArgumentParser();sub=p.add_subparsers(dest='command',required=True)
265
+ a=sub.add_parser('prepare');a.add_argument('--source',required=True);a.add_argument('--views',required=True);a.add_argument('--output',required=True);a.add_argument('--size',type=int,default=512);a.add_argument('--blender')
266
+ a=sub.add_parser('bake');a.add_argument('--evidence',required=True);a.add_argument('--annotations',required=True);a.add_argument('--output',required=True);a.add_argument('--resolution',type=int,default=1024);a.add_argument('--dense',action='store_true');a.add_argument('--surface-data',action='store_true');a.add_argument('--triangle-labels',action='store_true');a.add_argument('--blender')
267
+ args=p.parse_args();prepare(args) if args.command=='prepare' else bake(args)
@@ -0,0 +1,46 @@
1
+ """Bake vision-authored hair/lip/nail response into standard glTF PBR maps."""
2
+ import numpy as np
3
+ from scipy.ndimage import distance_transform_edt,gaussian_filter
4
+ from glb_output import image_array,append_texture
5
+
6
+ def surface_weights(labels,occupied):
7
+ labels=labels.copy()
8
+ distance,nearest=distance_transform_edt(~occupied,return_indices=True)
9
+ gutter=(~occupied)&(distance<=4);labels[gutter]=labels[tuple(nearest[:,gutter])]
10
+ categories={'skin':[1,2,3,9],'hair':[6],'lips':[4],'nails':[7]}
11
+ known=np.isin(labels,[1,2,3,9,6,4,7])
12
+ weights={key:gaussian_filter(np.isin(labels,ids).astype(np.float32),1.2) for key,ids in categories.items()}
13
+ total=sum(weights.values())
14
+ for value in weights.values():value/=np.maximum(total,1e-12);value[~known]=0
15
+ return weights
16
+
17
+ def bake_surface_pbr(doc,data,material,labels,occupied,out,weights=None):
18
+ pbr=material['pbrMetallicRoughness'];ext=material.setdefault('extensions',{})
19
+ spec=ext.get('KHR_materials_specular',{})
20
+ if spec.get('specularTexture') or spec.get('specularColorTexture'):raise ValueError('Compose existing specular textures explicitly before region baking')
21
+ if ext.get('KHR_materials_anisotropy',{}).get('anisotropyStrength',0) or ext.get('KHR_materials_sheen',{}).get('sheenColorFactor',[0,0,0])!=[0,0,0]:raise ValueError('Preserve existing native fiber maps with a dedicated adapter')
22
+ weights=surface_weights(labels,occupied) if weights is None else weights
23
+ hair=weights['hair'];lips=weights['lips'];nails=weights['nails'];non_skin=hair+lips+nails
24
+ # The skin shader performs the final skin/non-skin mix. These base PBR maps
25
+ # describe the conditional non-skin response, avoiding a second native mix.
26
+ denominator=np.maximum(non_skin,1e-12);h=hair/denominator;l=lips/denominator;n=nails/denominator;assigned=np.clip(h+l+n,0,1)
27
+ orm=image_array(doc,data,pbr['metallicRoughnessTexture']['index']).astype(np.float32)/255
28
+ orm[:,:,1]=orm[:,:,1]*pbr.get('roughnessFactor',1)*(1-assigned)+h*.76+l*.38+n*.32
29
+ orm[:,:,2]*=pbr.get('metallicFactor',1)*(1-assigned)
30
+ pbr['roughnessFactor']=1;pbr['metallicFactor']=1
31
+ pbr['metallicRoughnessTexture']={'index':append_texture(doc,data,np.round(orm*255),'vision-surface-orm',out)}
32
+ factor=float(spec.get('specularFactor',1));color=np.asarray(spec.get('specularColorFactor',[1,1,1]),float)
33
+ intensity=np.maximum(factor,.5);color_factor=np.maximum(color,1.)
34
+ rgba=np.full((*labels.shape,4),255,np.uint8);rgba[:,:,3]=np.round(((1-assigned)*factor+assigned*.5)/intensity*255)
35
+ intensity_index=append_texture(doc,data,rgba,'vision-surface-specular',out)
36
+ linear=((1-assigned[:,:,None])*color+assigned[:,:,None])/color_factor
37
+ encoded=np.where(linear<=.0031308,linear*12.92,1.055*np.maximum(linear,0)**(1/2.4)-.055)
38
+ color_index=append_texture(doc,data,np.round(encoded*255),'vision-surface-specular-color',out)
39
+ ext['KHR_materials_specular']={'specularFactor':intensity,'specularColorFactor':color_factor.tolist(),'specularTexture':{'index':intensity_index},'specularColorTexture':{'index':color_index}}
40
+ flow=np.zeros((*labels.shape,3),np.uint8);flow[:,:,0]=255;flow[:,:,1]=128;flow[:,:,2]=np.round(hair*255)
41
+ ext['KHR_materials_anisotropy']={'anisotropyStrength':.25,'anisotropyTexture':{'index':append_texture(doc,data,flow,'vision-surface-fiber-flow',out)}}
42
+ sheen=np.repeat(np.round(hair*255).astype(np.uint8)[:,:,None],3,axis=2)
43
+ ext['KHR_materials_sheen']={'sheenColorFactor':[.025,.025,.025],'sheenRoughnessFactor':.8,'sheenColorTexture':{'index':append_texture(doc,data,sheen,'vision-surface-sheen',out)}}
44
+ for key in ext:
45
+ if key not in doc.setdefault('extensionsUsed',[]):doc['extensionsUsed'].append(key)
46
+ return {'hairTexels':int((hair>0).sum()),'lipTexels':int((lips>0).sum()),'nailTexels':int((nails>0).sum()),'method':'explicit vision labels baked into ORM, specular, anisotropy and sheen; native response outside selected regions','limitations':['Fiber direction is a uniform provisional tangent direction; author comb-flow before final hair acceptance','8-bit PBR maps quantize material factors']}