@bitmagic/cli 0.1.65 → 0.1.66-dev.10

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 (139) hide show
  1. package/README.md +37 -1
  2. package/assets/blender-level/find_blender.py +3 -1
  3. package/assets/character-rigging/SKILL.md +80 -0
  4. package/assets/character-rigging/references/articulation.md +55 -0
  5. package/assets/character-rigging/references/new-rig.md +51 -0
  6. package/assets/character-rigging/references/repairs.md +44 -0
  7. package/assets/character-rigging/references/verification.md +49 -0
  8. package/assets/character-rigging/scripts/rig_tool.py +197 -0
  9. package/assets/character-skin-authoring/SKILL.md +43 -0
  10. package/assets/character-skin-authoring/references/exporters.md +134 -0
  11. package/assets/character-skin-authoring/references/eyes.md +215 -0
  12. package/assets/character-skin-authoring/references/materials.md +115 -0
  13. package/assets/character-skin-authoring/references/playground.md +78 -0
  14. package/assets/character-skin-authoring/references/profiles.md +97 -0
  15. package/assets/character-skin-authoring/references/projection.md +190 -0
  16. package/assets/character-skin-authoring/references/regional-detail.md +72 -0
  17. package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
  18. package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
  19. package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
  20. package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
  21. package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
  22. package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
  23. package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
  24. package/assets/character-skin-authoring/scripts/character_build.py +54 -0
  25. package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
  26. package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
  27. package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
  28. package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
  29. package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
  30. package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
  31. package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
  32. package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
  33. package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
  34. package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
  35. package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
  36. package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
  37. package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
  38. package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
  39. package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
  40. package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
  41. package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
  42. package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
  43. package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
  44. package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
  45. package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
  46. package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
  47. package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
  48. package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
  49. package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
  50. package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
  51. package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
  52. package/assets/metahuman/tools/lipsync/make_line.py +181 -0
  53. package/assets/metahuman/tools/metahuman/README.md +21 -0
  54. package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
  55. package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
  56. package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
  57. package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
  58. package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
  59. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
  60. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
  61. package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
  62. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
  63. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
  64. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
  65. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
  66. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
  67. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
  68. package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
  69. package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
  70. package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
  71. package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
  72. package/assets/metahuman/tools/metahuman/package.json +13 -0
  73. package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
  74. package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
  75. package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
  76. package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
  77. package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
  78. package/dist/assets/voxelize-glb.d.ts +5 -1
  79. package/dist/assets/voxelize-glb.js +1 -0
  80. package/dist/assets/voxelize-glb.js.map +1 -1
  81. package/dist/assets/vxl-summary.js +2 -2
  82. package/dist/commands/forge.js +3 -1
  83. package/dist/commands/forge.js.map +1 -1
  84. package/dist/commands/generate.d.ts +62 -0
  85. package/dist/commands/generate.js +241 -2
  86. package/dist/commands/generate.js.map +1 -1
  87. package/dist/commands/init.js +2 -1
  88. package/dist/commands/init.js.map +1 -1
  89. package/dist/commands/judge.js +5 -0
  90. package/dist/commands/judge.js.map +1 -1
  91. package/dist/commands/verify.js +7 -0
  92. package/dist/commands/verify.js.map +1 -1
  93. package/dist/editor/asset-viewer.js +10 -5
  94. package/dist/editor/asset-viewer.js.map +1 -1
  95. package/dist/generate/audio-file.d.ts +6 -0
  96. package/dist/generate/audio-file.js +42 -0
  97. package/dist/generate/audio-file.js.map +1 -0
  98. package/dist/generate/model.js +3 -3
  99. package/dist/generate/model.js.map +1 -1
  100. package/dist/generate/music.d.ts +4 -3
  101. package/dist/generate/music.js +6 -23
  102. package/dist/generate/music.js.map +1 -1
  103. package/dist/generate/prop.d.ts +19 -1
  104. package/dist/generate/prop.js +34 -6
  105. package/dist/generate/prop.js.map +1 -1
  106. package/dist/generate/stream.d.ts +20 -0
  107. package/dist/generate/stream.js +15 -1
  108. package/dist/generate/stream.js.map +1 -1
  109. package/dist/scaffold/asset-pipeline-skill-files.js +7 -2
  110. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
  111. package/dist/scaffold/character-rigging-skill-files.d.ts +2 -0
  112. package/dist/scaffold/character-rigging-skill-files.js +17 -0
  113. package/dist/scaffold/character-rigging-skill-files.js.map +1 -0
  114. package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
  115. package/dist/scaffold/character-skin-skill-files.js +49 -0
  116. package/dist/scaffold/character-skin-skill-files.js.map +1 -0
  117. package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
  118. package/dist/scaffold/metahuman-skill-files.js +306 -0
  119. package/dist/scaffold/metahuman-skill-files.js.map +1 -0
  120. package/dist/scaffold/project-files.js +28 -19
  121. package/dist/scaffold/project-files.js.map +1 -1
  122. package/dist/scaffold/project.js +6 -0
  123. package/dist/scaffold/project.js.map +1 -1
  124. package/dist/scaffold/skill-files.js +5 -2
  125. package/dist/scaffold/skill-files.js.map +1 -1
  126. package/dist/telemetry/command-context.d.ts +23 -0
  127. package/dist/telemetry/command-context.js +11 -0
  128. package/dist/telemetry/command-context.js.map +1 -1
  129. package/dist/telemetry/report.d.ts +1 -0
  130. package/dist/telemetry/report.js +4 -1
  131. package/dist/telemetry/report.js.map +1 -1
  132. package/dist/tools/bundles.js +8 -0
  133. package/dist/tools/bundles.js.map +1 -1
  134. package/dist/verify/browser.js +25 -0
  135. package/dist/verify/browser.js.map +1 -1
  136. package/dist/verify/categories.d.ts +16 -0
  137. package/dist/verify/categories.js +51 -0
  138. package/dist/verify/categories.js.map +1 -0
  139. package/package.json +10 -7
@@ -0,0 +1,190 @@
1
+ # Projection contract
2
+
3
+ The portable helper produces evidence and UV labels. It does not fit a detail exemplar,
4
+ change a GLB, or claim a sparse projected label map is a production-ready skin mask.
5
+ Use its coverage report to drive further agent inspection.
6
+
7
+ Run with a local Python containing NumPy and Pillow. Blender supplies its own NumPy.
8
+ The lab uses `lowpoly/.venv/bin/python`; elsewhere choose the project's environment.
9
+
10
+ ```sh
11
+ python <skill>/scripts/skin_views.py prepare --source character.glb --views views.json --output evidence-v1 --size 1024 --blender /path/to/blender
12
+ python <skill>/scripts/skin_views.py bake --evidence evidence-v1 --annotations annotations.json --output masks-v1 --resolution 4096 --dense --blender /path/to/blender
13
+ ```
14
+
15
+ Omit `--blender` when available on PATH. `prepare` uses Blender's imported mesh and
16
+ world transform, BVH visibility, interpolated UVs and unlit base-color image. It currently
17
+ supports directly linked Principled base-color textures and the active UV set. Complex
18
+ material graphs must first receive a faithful albedo bake; the helper fails instead of
19
+ guessing. Animated models are inspected in the imported pose. Validate orientation and
20
+ texture appearance against the original before annotating.
21
+
22
+ Evidence PNGs preserve sRGB byte textures as encoded values. Blender's non-float
23
+ `Image.pixels` exposes those byte values normalized to 0–1; applying an additional
24
+ linear-to-sRGB transfer would brighten the evidence incorrectly. Constant Principled
25
+ base colors are linear and receive exactly one transfer. Float images are rejected
26
+ until a faithful sRGB albedo bake is supplied. New manifests explicitly record
27
+ `evidenceColorSpace` and `byteImageTransfer`. The playground's known-color Blender
28
+ regression verifies both byte-texture and constant-color paths within one byte value.
29
+
30
+ Older evidence made before this correction may be too bright. Preserve those immutable
31
+ revisions and annotations, prepare a new color-correct revision, and visually review it.
32
+ Identical camera/UV correspondence can justify transferring polygon coordinates, but
33
+ never claim a new visual review merely because the geometry stayed unchanged. This
34
+ helper correction does not alter the runtime GLB texture color pipeline.
35
+
36
+ `views.json` is a list. `direction` points **from camera into the scene** in Blender world
37
+ coordinates, `up` defaults to +Z. `span` is square orthographic width divided by model
38
+ height. `targetOffset` is relative to bounding-box center, also in model-height units.
39
+ Directions are geometric, not assumptions about front/back or left/right anatomy.
40
+
41
+ ```json
42
+ [
43
+ {"name":"minus-y","direction":[0,1,0],"span":1.45},
44
+ {"name":"plus-y","direction":[0,-1,0],"span":1.45}
45
+ ]
46
+ ```
47
+
48
+ After the survey, choose offsets and tighter spans by inspecting the actual character.
49
+ For a top view, supply an up vector perpendicular to its direction. Each view yields a
50
+ PNG and NPZ containing material index, UV and viewing-angle confidence for every visible
51
+ pixel. Manifest records source hash, cameras, dimensions, material names and world bounds.
52
+ Rasterized sample coordinates refer to pixel centers; polygon coordinates are image
53
+ pixels from top-left, x right and y down. Use precisely the named image dimensions.
54
+
55
+ ```json
56
+ {
57
+ "sourceSha256":"copy the manifest hash",
58
+ "views":{
59
+ "minus-y":[
60
+ {"label":"skin","polygon":[[100,100],[140,100],[140,160],[100,160]]},
61
+ {"label":"eyes","polygon":[[110,110],[120,110],[120,120],[110,120]]}
62
+ ]
63
+ }
64
+ }
65
+ ```
66
+
67
+ These coordinates are format examples, not annotations for any character. Later polygons
68
+ overwrite earlier ones within a view. Across views, contradictory labels become unknown
69
+ and appear in the conflict map. Grazing samples are excluded deterministically. UVs outside
70
+ 0–1 remain unclassified rather than being wrapped into a different region.
71
+
72
+ Outputs: per-material label, observed and conflict PNGs; per-view annotation overlays,
73
+ reprojected atlas overlays and raw reprojected labels; archived annotations; and a JSON report. Label IDs: unknown 0, skin 1, palm 2, sole 3, lips 4, eyes 5, hair 6, nails 7,
74
+ non_skin 8, dorsal_hand 9. Material ordering belongs to this evidence manifest, not a promised GLB index.
75
+ UV labels use top-left image rows (Blender UV V is inverted during baking).
76
+
77
+ The bake does not interpolate unknown texels or decide by majority vote: conflicting
78
+ observations require review. Low-resolution survey rays are insufficient to cover a 4K UV
79
+ atlas; use region close-ups and appropriate resolution for final maps. Do not count unknown
80
+ background texels as missed skin without examining the UV occupancy of the actual model.
81
+
82
+
83
+ ## Dense bake and review (tested on three additional characters)
84
+
85
+ Use `--dense` for reviewed deliverables. The default sparse mode remains useful as a small
86
+ projection diagnostic. Dense mode rasterizes the source UV triangles, finds the 3D point
87
+ for each occupied texel, projects it into annotated cameras and ray-tests visibility against
88
+ the exact mesh. It does not interpolate semantic labels across unseen geometry. Distinct
89
+ surfaces sharing UV texels are reported as overlaps and remain unknown. Increasing atlas
90
+ resolution cannot add semantic accuracy beyond the annotation images.
91
+
92
+ Prepare hashes the PNG/NPZ evidence. Bake checks these hashes and the source mesh hash;
93
+ editing an image after annotation invalidates that evidence. Legacy manifests without
94
+ image hashes remain readable; prepare fresh evidence for new work. Preserve the original
95
+ manifest with its camera definitions. Evidence must not be resized or relit for annotation.
96
+
97
+ Review BOTH `<view>-overlay.png` (your polygons) and `<view>-reprojected.png` (the actual
98
+ baked atlas). All prepared views are reprojected, including views without annotations.
99
+ Palette: green skin, mint dorsal hand, cyan palm, blue eyes, purple hair, pink lips, pale
100
+ yellow nails, gray non-skin. Red means unknown; orange means conflict. Unknown clothing
101
+ is not missing skin. The raw `*-reprojected-labels.png` images retain categorical IDs.
102
+
103
+ The report distinguishes total visible pixels from annotated visible pixels. Use
104
+ `unresolvedAnnotatedPixels` and `disagreeingAnnotatedPixels` to detect projection losses;
105
+ these are NOT whole-character skin coverage or semantic accuracy scores. Low-resolution
106
+ atlases lose samples near small islands and seams even in dense mode. Match the source
107
+ atlas resolution, inspect the result, and keep unresolved seams visible. Do not hide them
108
+ with unrestricted dilation. A synthetic Blender regression exercises dense coverage,
109
+ occluded back surfaces and overlapping UVs.
110
+
111
+ Trials found source albedo spill on soldier b3843's palms, pale hair/ear ambiguity on b5588,
112
+ and missing side scalp coverage on b3658. These require different responses: record a
113
+ source-texture defect separately; correct hair/skin boundaries from multiple views; add
114
+ views/annotations for exposed surfaces. Indistinct nails require closer views. Do not
115
+ claim a material or skin-detail improvement from a semantic mask alone.
116
+
117
+ ## Runtime packaging evidence
118
+
119
+ For the full-UV material adapter, add `--surface-data --triangle-labels` to the dense bake.
120
+
121
+ To investigate a seam or conflict, use `scripts/inspect_skin_point.py --evidence <folder>
122
+ --masks <dense-bake> --view <name> --x <pixel-x> --y <pixel-y>`. Coordinates belong to the
123
+ original evidence image. The read-only helper reports the UV texel and every visible
124
+ contributing view, its source pixel, authored label and geometric facing. It checks the
125
+ source, annotations and archived camera contract; it does not resolve the labels.
126
+
127
+ Add `--height-m <known-character-height>` to report surface distances to adjacent
128
+ occupied atlas texels in metres. Inspect individual neighbours: crossing a UV discontinuity
129
+ can produce a large distance. This diagnoses texture density, not anatomy or recovered
130
+ detail. If a face uses half-millimetre texels, increasing bump strength cannot recover
131
+ sub-texel pores. Preserve the registered detail in its own atlas instead of increasing
132
+ coverage or exaggerating relief to hide lost resolution.
133
+
134
+ Inspect the reported image pixels. A one-pixel garment-boundary mistake in a grazing
135
+ view can project into a much wider strip on the model. If that source boundary is
136
+ ambiguous and another view clearly shows the anatomy, explicitly mark the narrow
137
+ uncertain region in the ambiguous view as unknown and retain the reviewed clear view.
138
+ Do not automatically select a label by facing score or shrink all boundaries globally.
139
+ The first writes occupied texel positions, normals and geometric visibility bits in
140
+ Blender world space. The second classifies actual triangle centers with the same camera
141
+ annotations and exact visibility tests. Packaging matches those centers bijectively to the
142
+ GLB scene transform and fails if they differ; never assume Blender and GLB vertex ordering
143
+ match. This avoids assigning a whole triangle by a sparsely sampled UV center.
144
+
145
+ `triangles.npz` contains centers, labels and conflicts. `material-N-surface.npz` contains
146
+ position, normal, occupied, viewBits and visibilityBits. `viewBits` records views supporting
147
+ an annotation; `visibilityBits` independently records geometric visibility, including
148
+ unannotated views and unlabelled visible texels. Use the latter for projecting registered
149
+ detail, never an annotation's polygon boundary. Neither field assigns an anatomical label.
150
+ Older bakes without `visibilityBits` must be regenerated, not silently substituted.
151
+ Before consuming visibility bits, compare the archived `dense-request.json` source,
152
+ ordered cameras, image size, material ordering and world bounds against the supplied
153
+ manifest. A source hash alone cannot detect reordered camera bits. A color-only evidence
154
+ revision may preserve that geometric contract; changed cameras require a new bake.
155
+ These are build intermediates, not game textures.
156
+ The current visibility bit field supports up to 32 views in one revision. Blender workers
157
+ return a nonzero exit code on Python errors. Keep output revisions separate when changing
158
+ annotations; do not overwrite evidence to bypass a hash check.
159
+
160
+ Generic `skin` agrees with an observed `palm`, `sole` or `dorsal_hand` label. Two distinct
161
+ specific labels still conflict. This hierarchy combines semantic observations; it does not
162
+ recognize anatomy. A hard material boundary magnifies small polygon gaps: overlap enclosing
163
+ skin and its explicit hair/lip exclusions within the same view instead of leaving a narrow
164
+ unlabelled strip between them. Never fill an unseen nail or a contradictory observation.
165
+
166
+ ### Thin unresolved folds
167
+
168
+ A closer view in the same direction does not necessarily resolve visibility gaps. The
169
+ b3658 rear-neck test retained a thin unobserved strip in a shallow fold until an oblique
170
+ view showed the recessed surface. Inspect source, coverage and shaded images separately.
171
+ Add that new view and annotate actual anatomy; never increase ray tolerance or fill unknown
172
+ regions merely to remove a line. Sample-level coverage improvements do not prove that all
173
+ residual albedo or filtering seams have disappeared. Keep those review results distinct.
174
+
175
+
176
+ ### Source-color repair across UV islands
177
+
178
+ A harmonic image fill assumes the selected patch has clean boundaries. UV seams can split one anatomical patch into many islands whose boundaries remain contaminated. If a reviewed color repair remains unchanged, inspect the repaired albedo reprojected onto the exact mesh before extending semantic labels. For such defects, author clean color swatches on the same visible anatomical surface and interpolate in surface space, keeping the repair selection separate from anatomy. Check every donor against source hashes and independently reviewed skin. Record the swatch coordinates and inspect the final render. This is texture reconstruction, not segmentation or recovered source detail.
179
+
180
+ Designed features such as a nail plate absent from the source must be explicitly recorded as authored additions. They must not silently override contradictory anatomical observations; reconcile the views or keep the design unaccepted.
181
+
182
+ ## Occluding silhouettes need precise annotations
183
+
184
+ A bottom-foot view may show the shin or clothing immediately beyond the heel and
185
+ between toes. A polygon around the apparent foot outline must exclude those visible
186
+ surfaces too; they are not necessarily empty background. In the b3662 trial, a few pixels
187
+ of excess sole annotation produced a curved sole-detail strip on the shin. The ray test
188
+ correctly saw that shin surface—the semantic polygon was wrong. Trace a bad rendered
189
+ point with `scripts/inspect_skin_point.py`, inspect the contributing source pixel, correct
190
+ that specific polygon, and reproject. Do not move a shader cutoff to conceal the artifact.
@@ -0,0 +1,72 @@
1
+ # Regional detail helpers
2
+
3
+ The shipped `scripts/authored_detail.py` and `scripts/authored_surface_patch.py` are
4
+ Blender-independent NumPy/Pillow/SciPy libraries. They evaluate already-authored curves
5
+ and roughness patches. They do not recognize anatomy, load a rig, or export materials.
6
+ Keep profiles and licensed images in the character workspace, outside the refreshed skill.
7
+
8
+ Pass the exact evidence manifest and physical character height in metres. Surface points
9
+ must use the evidence's Blender world axes, centered on `worldBounds` and scaled by
10
+ physical height divided by the source bounds' Z extent. Normals use those same axes.
11
+ `visibility` is the dense bake's **geometric `visibilityBits`**, not annotation `viewBits`.
12
+ The label array is the reviewed semantic map. Both helpers leave other regions unchanged.
13
+
14
+ `AuthoredDetail(profile_path, manifest, height_m).evaluate(points, normals, labels,
15
+ visibility)` returns signed relief in metres. The profile contains `sourceSha256`,
16
+ `evidenceHashes`, `provenance`, and `curves`. Each curve specifies `view`, `region`,
17
+ `pointsPx`, `widthM`, and `depthM`. Regions supported here are `skin`, `palm`, `sole`,
18
+ and `dorsal_hand`. Coordinates belong to the exact source-view image. Record the curves
19
+ as designed enhancements, not measured anatomy.
20
+
21
+ `AuthoredSurfacePatch(profile_path, manifest, height_m, workspace_root).evaluate(points,
22
+ normals, labels, visibility, base_roughness)` returns blended roughness. The profile has
23
+ `sourceSha256`, `evidenceHashes`, and `patches`. Each patch contains:
24
+
25
+ - `view`, `region`, ordered `targetPixels` around the selected polygon.
26
+ - `roughnessTexture`, `textureSha256`, `sourceAnnotationImageSize`, and corresponding
27
+ `sourcePixels` from the independently inspected donor image.
28
+ - `featherM` and `roughnessRange` as an explicit authored response range.
29
+ - `provenance`: `title`, `url`, `licenseFile`, and `licenseSha256`.
30
+
31
+ Texture and license paths are relative to the supplied workspace root. The helper checks
32
+ the current evidence image, texture and license hashes. Retain its `report` and append
33
+ all `attributions` to the exported asset's notices. Exemplar roughness is not necessarily
34
+ measured: record its origin accurately. This helper transfers neither color nor normals.
35
+ Normal-map RGB is not height data; integrating normals requires a declared tangent
36
+ convention and surface metric, followed by independent relief review.
37
+
38
+ An optional `height` record supplies an already prepared signed-height NumPy array:
39
+ `path`, `sha256`, `boundsPx` in the donor annotation coordinates, explicit `scale`,
40
+ and `provenance`. Values are metres, not RGB channels. The separate
41
+ `relief(points, normals, labels, visibility)` method returns the feathered height
42
+ contribution, leaving roughness evaluation unchanged. Preserve the method, source
43
+ normal convention, assumed metric and any artistic amplitude change in provenance.
44
+ Use known or explicitly designed height; this helper does not integrate normal maps.
45
+
46
+ For a square, locally planar exemplar with a declared isotropic metric,
47
+ `scripts/normal_patch_height.py` exposes `normal_patch_height(rgb, width_m, normal_y)`.
48
+ RGB is the raw linear normal-map data in [0,1]; `normal_y` must explicitly be `up` or
49
+ `down`. It uses [Frankot/Chellappa discrete slope integration](https://doi.org/10.1109/34.3909),
50
+ a reflected periodic extension, and physically specified band limiting. Save its output
51
+ as a signed-metre NumPy array and inspect the reconstructed folds before transfer. It
52
+ does not recover an unknown donor metric, recognize a skin region, or make artist-authored
53
+ normal maps into measured scans. The playground's Hafnia palm test explicitly assumes an
54
+ 80mm planar crop and records that assumption; it is not a universal scale for hand assets.
55
+
56
+ `sampleResolution` can increase field sampling independently of the annotation image.
57
+ Physical feather width stays unchanged. When the project's exporter supports regional
58
+ atlas packing, `independentDetail: true` exposes the patch through `independent_planes()`.
59
+ The exporter must then exclude it from the coarse body bake (`include_independent=False`)
60
+ and register its texture coordinates separately. Do not silently omit it in an exporter
61
+ that lacks this path. Overlapping independent bindings require a deliberately combined
62
+ patch; there is no automatic regional winner.
63
+
64
+ Inspect the transferred patch at region-sized framing under side and specular lighting.
65
+ A small central-palm experiment is not acceptance of the whole hand, fingers or nails.
66
+ If the full-body atlas cannot resolve the patch, address texel density before amplifying it.
67
+
68
+ For independent atlas slots, keep a transparent margin around the authored patch inside
69
+ the evidence view. The playground packer rejects edge-touching patches rather than
70
+ clamping visible relief into a seam. Widen/reframe the evidence and review the annotation
71
+ again. Packing a patch at higher resolution is not a visual acceptance: inspect its relief
72
+ at an ordinary region-sized view and with side light before claiming improved skin.
@@ -0,0 +1,205 @@
1
+ """Attach the shared body rig without re-exporting authored materials or eye layers.
2
+
3
+ Original surface vertices match the dense bound mesh. Newly clipped eye vertices
4
+ use a unanimous rigid-head field or exact bound-triangle interpolation. Optical
5
+ rest fitting retains its pre-fit correspondence; off-surface evidence is rejected.
6
+ """
7
+ import argparse,copy,hashlib,json,shutil
8
+ from pathlib import Path
9
+ import numpy as np
10
+ from scipy.spatial import cKDTree
11
+ from scipy.spatial.transform import Rotation
12
+ from glb_arrays import read,accessor
13
+ from glb_output import append_accessor
14
+ from vision_materials import source_world_points
15
+ from eye_assets import write_glb
16
+
17
+ from workspace_paths import workspace_root
18
+ ROOT=workspace_root(__file__)
19
+
20
+ def node_matrix(node):
21
+ if 'matrix' in node:return np.asarray(node['matrix']).reshape(4,4).T
22
+ matrix=np.eye(4);matrix[:3,:3]=Rotation.from_quat(node.get('rotation',[0,0,0,1])).as_matrix()@np.diag(node.get('scale',[1,1,1]));matrix[:3,3]=node.get('translation',[0,0,0]);return matrix
23
+
24
+ def world_matrices(doc):
25
+ parents={child:i for i,n in enumerate(doc['nodes']) for child in n.get('children',[])};cache={}
26
+ def world(i):
27
+ if i not in cache:cache[i]=(world(parents[i]) if i in parents else np.eye(4))@node_matrix(doc['nodes'][i])
28
+ return cache[i]
29
+ return [world(i) for i in range(len(doc['nodes']))]
30
+
31
+ def set_transform(node,matrix):
32
+ scale=np.linalg.norm(matrix[:3,:3],axis=0);rotation=matrix[:3,:3]/scale
33
+ if not np.allclose(rotation.T@rotation,np.eye(3),atol=1e-5) or np.linalg.det(rotation)<0:raise ValueError('Eye attachment requires a non-sheared positive transform')
34
+ node.pop('matrix',None);node.update(translation=matrix[:3,3].tolist(),rotation=Rotation.from_matrix(rotation).as_quat().tolist(),scale=scale.tolist())
35
+
36
+ def skin_points(points,joints,weights,matrices):
37
+ result=np.zeros_like(points,dtype=float);p=np.c_[points,np.ones(len(points))]
38
+ for slot in range(4):result+=np.einsum('nij,nj->ni',matrices[joints[:,slot]],p)[:,:3]*weights[:,slot,None]
39
+ return result
40
+
41
+ def surface_weight_transfer(query,points,faces,joints,weights,tolerance):
42
+ """Exact closest-triangle interpolation; reject points off the bound surface."""
43
+ triangles=points[faces];centers=triangles.mean(1)
44
+ radius=np.linalg.norm(triangles-centers[:,None,:],axis=2).max()
45
+ tree=cKDTree(centers);vertices=cKDTree(points[np.unique(faces)])
46
+ result_j=[];result_w=[]
47
+ for point in query:
48
+ upper=vertices.query(point)[0]
49
+ ids=tree.query_ball_point(point,float(upper+radius+tolerance))
50
+ tri=triangles[ids];a=tri[:,0];u=tri[:,1]-a;v=tri[:,2]-a;p=point-a
51
+ uu=(u*u).sum(1);uv=(u*v).sum(1);vv=(v*v).sum(1);pu=(p*u).sum(1);pv=(p*v).sum(1)
52
+ determinant=uu*vv-uv*uv;safe=np.where(determinant>1e-24,determinant,1)
53
+ b=(vv*pu-uv*pv)/safe;c=(uu*pv-uv*pu)/safe
54
+ bary=np.stack([1-b-c,b,c],1);inside=(bary>=0).all(1)&(determinant>1e-24)
55
+ distance=np.linalg.norm(np.einsum('ni,nij->nj',bary,tri)-point,axis=1)
56
+ distance[~inside]=np.inf
57
+ for first,last in [(0,1),(1,2),(2,0)]:
58
+ edge=tri[:,last]-tri[:,first];length=(edge*edge).sum(1)
59
+ t=np.clip(((point-tri[:,first])*edge).sum(1)/np.maximum(length,1e-24),0,1)
60
+ candidate=np.linalg.norm(tri[:,first]+edge*t[:,None]-point,axis=1);better=candidate<distance
61
+ distance[better]=candidate[better];bary[better]=0;bary[better,first]=1-t[better];bary[better,last]=t[better]
62
+ closest=int(np.argmin(distance))
63
+ if distance[closest]>tolerance:raise ValueError('Binding provenance is not on the validated source surface')
64
+ corners=faces[ids[closest]];combined={}
65
+ for vertex,factor in zip(corners,bary[closest]):
66
+ for joint,weight in zip(joints[vertex],weights[vertex]):combined[int(joint)]=combined.get(int(joint),0)+float(factor*weight)
67
+ strongest=sorted(combined.items(),key=lambda item:item[1],reverse=True)[:4]
68
+ strongest += [(0,0)]*(4-len(strongest));total=sum(w for _,w in strongest)
69
+ if total<=0:raise ValueError('Bound triangle has no skin weights')
70
+ result_j.append([j for j,_ in strongest]);result_w.append([w/total for _,w in strongest])
71
+ return np.asarray(result_j),np.asarray(result_w)
72
+
73
+ def assemble(sid,relative_input,variant='eyes'):
74
+ base=ROOT/'characters'/sid/'high/v1';input_path=(base/relative_input).resolve()
75
+ if base.resolve() not in input_path.parents:raise ValueError('Input must be a revision inside this character')
76
+ doc,data=read(input_path);rig_path=base/'output/character.glb';rig,rig_data=read(rig_path);rig_report=json.loads((base/'rig-report.json').read_text())
77
+ if hashlib.sha256(rig_path.read_bytes()).hexdigest()!=rig_report['output_sha256']:raise ValueError('Body rig changed after validation')
78
+ if rig_report['geometry_displacement_max_m']>1e-6:raise ValueError('Reshaped sources require an explicit surface deformation transfer')
79
+ if len(rig.get('skins',[]))!=1 or doc.get('skins'):raise ValueError('This assembly adapter adds exactly one new body skin')
80
+ body_node=next(i for i,n in enumerate(doc['nodes']) if n.get('mesh')==0)
81
+ rig_body=next(i for i,n in enumerate(rig['nodes']) if n.get('skin')==0)
82
+ if any('mesh' in n for i,n in enumerate(rig['nodes']) if i!=rig_body):raise ValueError('Unexpected extra body geometry')
83
+ worlds=world_matrices(doc);rig_world=world_matrices(rig)
84
+ if not np.allclose(rig_world[rig_body],np.eye(4),atol=1e-6):raise ValueError('Bound mesh needs an explicit nonidentity bind-space adapter')
85
+ primitives=doc['meshes'][0]['primitives'];positions=accessor(doc,data,primitives[0]['attributes']['POSITION'])
86
+ if any(p['attributes']['POSITION']!=primitives[0]['attributes']['POSITION'] for p in primitives):raise ValueError('Expected a shared surface vertex pool')
87
+ rp=rig['meshes'][rig['nodes'][rig_body]['mesh']]['primitives'][0]
88
+ bound_positions=accessor(rig,rig_data,rp['attributes']['POSITION']);bound_joints=accessor(rig,rig_data,rp['attributes']['JOINTS_0']).astype(np.int32);bound_weights=accessor(rig,rig_data,rp['attributes']['WEIGHTS_0'])
89
+ if not np.issubdtype(bound_weights.dtype,np.floating):raise ValueError('Decode normalized integer weight attributes explicitly')
90
+ scale=rig_report['normalization']['scale'];center=np.asarray(rig_report['normalization']['center']);normalization=np.eye(4);normalization[:3,:3]*=scale;normalization[:3,3]=-center[[0,2,1]]*np.array([1,1,-1])*scale
91
+ transform=normalization@worlds[body_node];expected=positions@transform[:3,:3].T+transform[:3,3]
92
+ binding_index=primitives[0]['attributes'].get('_BIND_SOURCE_POSITION')
93
+ if any(p['attributes'].get('_BIND_SOURCE_POSITION')!=binding_index for p in primitives):raise ValueError('Expected shared source binding positions across material partitions')
94
+ binding_positions=positions if binding_index is None else accessor(doc,data,binding_index)
95
+ if binding_positions.shape!=positions.shape or not np.isfinite(binding_positions).all():raise ValueError('Invalid source binding positions')
96
+ binding_expected=binding_positions@transform[:3,:3].T+transform[:3,3]
97
+ distance,index=cKDTree(bound_positions).query(binding_expected);tolerance=max(np.ptp(bound_positions,axis=0))*2e-6
98
+ joints=bound_joints[index].copy();weights=bound_weights[index].copy();unmatched=distance>tolerance
99
+ source_skin=rig['skins'][0];head_indices=[k for k,i in enumerate(source_skin['joints']) if rig['nodes'][i].get('name') in ['Head','mixamorigHead','mixamorig:Head']]
100
+ if len(head_indices)!=1:raise ValueError('The shared rig must identify one Head joint')
101
+ head=head_indices[0]
102
+ barycentric_count=0
103
+ if unmatched.any():
104
+ # A clipped ocular vertex is safe only within the explicit rigid-head field.
105
+ d,near=cKDTree(bound_positions).query(binding_expected[unmatched],k=4)
106
+ head_weight=np.sum(bound_weights[near]*(bound_joints[near]==head),axis=2)
107
+ rigid=(d<=.003).all(1)&(head_weight>=.9999).all(1)
108
+ missing_ids=np.flatnonzero(unmatched);joints[missing_ids[rigid]]=head;weights[missing_ids[rigid]]=[1,0,0,0]
109
+ if not rigid.all():
110
+ selected=missing_ids[~rigid]
111
+ faces=np.concatenate([accessor(rig,rig_data,p['indices']).reshape(-1,3) for p in rig['meshes'][rig['nodes'][rig_body]['mesh']]['primitives']])
112
+ joints[selected],weights[selected]=surface_weight_transfer(binding_expected[selected],bound_positions,faces,bound_joints,bound_weights,tolerance)
113
+ barycentric_count=len(selected)
114
+ if not np.isfinite(weights).all() or np.max(abs(weights.sum(1)-1))>1e-5:raise ValueError('Invalid transferred weights')
115
+ original_length=len(data);mapping={}
116
+ for i,n in enumerate(rig['nodes']):
117
+ if i!=rig_body:mapping[i]=len(doc['nodes']);doc['nodes'].append(copy.deepcopy(n))
118
+ for old,new in mapping.items():
119
+ if 'children' in doc['nodes'][new]:doc['nodes'][new]['children']=[mapping[c] for c in rig['nodes'][old]['children'] if c!=rig_body]
120
+ roots=rig['scenes'][rig.get('scene',0)]['nodes'];doc['scenes'][doc.get('scene',0)]['nodes'] += [mapping[i] for i in roots if i!=rig_body]
121
+ inverse_bind=accessor(rig,rig_data,source_skin['inverseBindMatrices']).reshape(-1,4,4).transpose(0,2,1)
122
+ # Keep the rig's canonical inverse binds: Skeleton.pose() reconstructs its
123
+ # rest world matrices from them. Convert mesh coordinates instead of hiding
124
+ # a mesh-space transform inside the inverse binds.
125
+ coordinate_scale=np.linalg.norm(transform[:3,:3],axis=0)
126
+ if not np.allclose(coordinate_scale,coordinate_scale[0],rtol=1e-5):raise ValueError('Nonuniform bind-space conversion needs explicit normal handling')
127
+ rotation=transform[:3,:3]/coordinate_scale[0]
128
+ if not np.allclose(rotation.T@rotation,np.eye(3),atol=1e-5):raise ValueError('Sheared bind-space conversion is unsupported')
129
+ converted={}
130
+ def convert_attribute(index,kind,delta=False):
131
+ key=(index,kind,delta)
132
+ if key not in converted:
133
+ values=accessor(doc,data,index)
134
+ if kind=='POSITION':values=values@transform[:3,:3].T+(0 if delta else transform[:3,3])
135
+ elif kind=='TANGENT':values[:,:3]=values[:,:3]@rotation.T
136
+ else:values=values@rotation.T
137
+ converted[key]=append_accessor(doc,data,values.astype('<f4'),'VEC4' if kind=='TANGENT' else 'VEC3',5126)
138
+ if kind=='POSITION':doc['accessors'][converted[key]].update(min=values.min(0).tolist(),max=values.max(0).tolist())
139
+ return converted[key]
140
+ for p in primitives:
141
+ # Authoring correspondence is consumed here; it is not a renderer input.
142
+ p['attributes'].pop('_BIND_SOURCE_POSITION',None)
143
+ for kind in ['POSITION','NORMAL','TANGENT']:
144
+ if kind in p['attributes']:p['attributes'][kind]=convert_attribute(p['attributes'][kind],kind)
145
+ for target in p.get('targets',[]):
146
+ for kind in ['POSITION','NORMAL']:
147
+ if kind in target:target[kind]=convert_attribute(target[kind],kind,True)
148
+ adjusted=inverse_bind
149
+ pose_error=float(np.max(abs(np.linalg.inv(adjusted)-np.asarray([rig_world[i] for i in source_skin['joints']]))))
150
+ if pose_error>2e-5:raise ValueError('Inverse binds are incompatible with the engine skeleton pose reset')
151
+ ibm_index=append_accessor(doc,data,adjusted.transpose(0,2,1).reshape(-1,16).astype('<f4'),'MAT4',5126)
152
+ doc['skins']=[{'name':source_skin.get('name','SharedBodyRig'),'joints':[mapping[i] for i in source_skin['joints']],'inverseBindMatrices':ibm_index}];doc['nodes'][body_node]['skin']=0
153
+ ji=append_accessor(doc,data,joints.astype('<u2'),'VEC4',5123);wi=append_accessor(doc,data,weights.astype('<f4'),'VEC4',5126)
154
+ for p in primitives:p['attributes'].update(JOINTS_0=ji,WEIGHTS_0=wi)
155
+ # Optical roots and head-fixed tissue now follow the animated Head joint.
156
+ head_node=mapping[source_skin['joints'][head]];head_world=rig_world[source_skin['joints'][head]];rotation_changes={};attachments=list(doc['nodes'][body_node].get('children',[]))
157
+ for child in attachments:
158
+ node=doc['nodes'][child]
159
+ optical=node.get('extras',{}).get('characterEye') or ('mesh' in node and all(doc['materials'][p['material']].get('extras',{}).get('eyeLayer') for p in doc['meshes'][node['mesh']]['primitives']))
160
+ if not optical:raise ValueError('Nonocular body attachments require explicit parenting')
161
+ old_rotation=Rotation.from_quat(node.get('rotation',[0,0,0,1]));set_transform(node,np.linalg.inv(head_world)@normalization@worlds[child]);rotation_changes[child]=Rotation.from_quat(node['rotation'])*old_rotation.inv()
162
+ doc['nodes'][head_node].setdefault('children',[]).append(child)
163
+ doc['nodes'][body_node]['children']=[]
164
+ for i,node in enumerate(doc['nodes']):
165
+ if i!=body_node and body_node in node.get('children',[]):node['children'].remove(body_node)
166
+ set_transform(doc['nodes'][body_node],np.eye(4))
167
+ scene_nodes=doc['scenes'][doc.get('scene',0)]['nodes']
168
+ if body_node not in scene_nodes:scene_nodes.append(body_node)
169
+ for animation in doc.get('animations',[]):
170
+ for channel in animation['channels']:
171
+ target=channel['target']
172
+ if target.get('path')=='rotation' and target['node'] in rotation_changes:
173
+ sampler=animation['samplers'][channel['sampler']]
174
+ if sampler.get('interpolation','LINEAR')!='LINEAR':raise ValueError('Eye rotation retarget requires linear quaternion tracks')
175
+ q=accessor(doc,data,sampler['output']);q=(rotation_changes[target['node']]*Rotation.from_quat(q)).as_quat();sampler['output']=append_accessor(doc,data,q.astype('<f4'),'VEC4',5126)
176
+ copied={}
177
+ def copy_accessor(index):
178
+ if index not in copied:
179
+ original=rig['accessors'][index];copied[index]=append_accessor(doc,data,accessor(rig,rig_data,index),original['type'],original['componentType'])
180
+ for key in ['min','max','normalized']:
181
+ if key in original:doc['accessors'][copied[index]][key]=original[key]
182
+ return copied[index]
183
+ for animation in rig.get('animations',[]):
184
+ a=copy.deepcopy(animation)
185
+ for sampler in a['samplers']:sampler['input']=copy_accessor(sampler['input']);sampler['output']=copy_accessor(sampler['output'])
186
+ for channel in a['channels']:channel['target']['node']=mapping[channel['target']['node']]
187
+ doc.setdefault('animations',[]).append(a)
188
+ matrices=np.asarray([rig_world[i] for i in source_skin['joints']])@adjusted
189
+ maximum=0.
190
+ for batch in np.array_split(np.arange(len(positions)),max(1,len(positions)//100000)):
191
+ error=np.linalg.norm(skin_points(expected[batch],joints[batch],weights[batch],matrices)-expected[batch],axis=1);maximum=max(maximum,float(error.max()))
192
+ if maximum>2e-5:raise ValueError(f'Assembled bind pose does not preserve the source: {maximum}m')
193
+ out=base/'runtime/v1';out.mkdir(parents=True,exist_ok=True)
194
+ report={'id':sid,'status':'motion_review_required','viewerReady':False,'surfaceInput':relative_input,'surfaceSha256':hashlib.sha256(input_path.read_bytes()).hexdigest(),'rigSha256':rig_report['output_sha256'],'restPositionErrorM':maximum,'rigidHeadInsertedVertices':int(unmatched.sum()),'bodyBones':len(source_skin['joints']),'headAttachments':len(attachments),'clips':[a['name'] for a in doc.get('animations',[])],'sourceBufferBytesPreserved':bytes(data[:original_length])==bytes(read(input_path)[1]),'geometryChange':'Rigid uniform bind-space coordinate conversion of positions, normals and morph deltas; no surface reshaping','limitations':['Shared body rig currently has no independent finger bones','Motion and eye contact require visual acceptance']}
195
+ report['variant']=variant
196
+ report['barycentricWeightVertices']=barycentric_count
197
+ report['rigidHeadInsertedVertices']=int(unmatched.sum())-barycentric_count
198
+ report['poseResetMatrixError']=pose_error
199
+ report['outputSha256']=write_glb(doc,data,out/f'{variant}.glb');(out/f'{variant}-report.json').write_text(json.dumps(report,indent=2))
200
+ for name in ['ATTRIBUTION.txt','MakeHuman-LICENSE.md']:
201
+ if (input_path.parent/name).exists():shutil.copyfile(input_path.parent/name,out/name)
202
+ print(json.dumps(report))
203
+
204
+ if __name__=='__main__':
205
+ p=argparse.ArgumentParser();p.add_argument('--id',required=True);p.add_argument('--input',required=True);p.add_argument('--variant',choices=['eyes','surfaces'],default='eyes');a=p.parse_args();assemble(a.id,a.input,a.variant)
@@ -0,0 +1,47 @@
1
+ """Bake agent-authored regional crease curves; never infer anatomy or landmarks."""
2
+ import hashlib,json
3
+ import numpy as np
4
+
5
+ LABELS={'skin':1,'palm':2,'sole':3,'dorsal_hand':9}
6
+
7
+ def curve_distance(points,curve):
8
+ distance=np.full(len(points),np.inf)
9
+ for a,b in zip(curve[:-1],curve[1:]):
10
+ edge=b-a;length=np.dot(edge,edge)
11
+ if length<=0:raise ValueError('Crease curve has a repeated point')
12
+ t=np.clip((points-a)@edge/length,0,1)
13
+ distance=np.minimum(distance,np.linalg.norm(points-a-t[:,None]*edge,axis=1))
14
+ return distance
15
+
16
+ class AuthoredDetail:
17
+ def __init__(self,path,manifest,physical_height):
18
+ self.curves=[];self.report=None
19
+ if not path.exists():return
20
+ raw=path.read_bytes();profile=json.loads(raw)
21
+ if profile['sourceSha256']!=manifest['sourceSha256']:raise ValueError('Body detail profile belongs to another source')
22
+ for record in profile['curves']:
23
+ index=next(i for i,v in enumerate(manifest['views']) if v['name']==record['view'])
24
+ image=record['view']+'.png'
25
+ if profile['evidenceHashes'].get(image)!=manifest['evidenceHashes'].get(image):raise ValueError('Body detail coordinates belong to different view evidence')
26
+ view=manifest['views'][index];direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
27
+ right=np.cross(direction,view.get('up',[0,0,1]));right/=np.linalg.norm(right);up=np.cross(right,direction)
28
+ curve=np.asarray(record['pointsPx'],float)
29
+ if curve.ndim!=2 or curve.shape[1]!=2 or len(curve)<2 or not np.isfinite(curve).all():raise ValueError('Invalid authored crease polyline')
30
+ width=record['widthM'];depth=record['depthM']
31
+ if not 0<width<=.003 or not 0<depth<=.00025:raise ValueError('Crease dimensions exceed the supported skin-detail range')
32
+ self.curves.append((index,np.asarray(view.get('targetOffset',[0,0,0]))*physical_height,right,up,direction,view['span']*physical_height,manifest['size'],curve,width,depth,LABELS[record['region']]))
33
+ self.report={'profileSha256':hashlib.sha256(raw).hexdigest(),'curves':len(self.curves),'provenance':profile['provenance'],'method':'agent-authored regional curves projected only onto visible independently labelled skin; physical width/depth'}
34
+
35
+ def evaluate(self,points,normals,labels,visibility):
36
+ result=np.zeros(len(points),np.float32)
37
+ for index,target,right,up,direction,span,size,curve,width,depth,region in self.curves:
38
+ selected=(labels==region)&((visibility&(1<<index))!=0)
39
+ if not selected.any():continue
40
+ local=points[selected]-target
41
+ xy=np.stack([(local@right/span+.5)*size,(.5-local@up/span)*size],1)
42
+ distance=curve_distance(xy,curve)*span/size
43
+ facing=np.clip((abs(normals[selected]@direction)-.2)/.3,0,1)
44
+ # A compact smooth groove avoids hard tube ends and adds no pigment.
45
+ relief=-depth*np.exp(-.5*(distance/width)**2)*facing*facing*(3-2*facing)
46
+ result[selected]=np.minimum(result[selected],relief)
47
+ return result
@@ -0,0 +1,83 @@
1
+ """Vision-registered roughness/height exemplars, limited by labels and visibility.
2
+
3
+ No inferred anatomy and no conversion of normal RGB into displacement.
4
+ """
5
+ import hashlib,json
6
+ from pathlib import Path
7
+ import numpy as np
8
+ from PIL import Image,ImageDraw
9
+ from scipy.interpolate import RBFInterpolator
10
+ from scipy.ndimage import distance_transform_edt,map_coordinates,gaussian_filter
11
+ from authored_detail import LABELS
12
+
13
+ class AuthoredSurfacePatch:
14
+ def __init__(self,path,manifest,physical_height,root):
15
+ self.patches=[];self.independent=[];self.report=None;self.attributions={}
16
+ if not path.exists():return
17
+ raw=path.read_bytes();profile=json.loads(raw)
18
+ if profile['sourceSha256']!=manifest['sourceSha256']:raise ValueError('Surface patch belongs to another source')
19
+ receipts=[]
20
+ for record in profile['patches']:
21
+ index=next(i for i,v in enumerate(manifest['views'])if v['name']==record['view']);view=manifest['views'][index];image=record['view']+'.png'
22
+ image_path=Path(manifest['output'])/image
23
+ if profile['evidenceHashes'].get(image)!=manifest['evidenceHashes'].get(image) or hashlib.sha256(image_path.read_bytes()).hexdigest()!=profile['evidenceHashes'][image]:raise ValueError('Surface patch evidence changed')
24
+ texture_path=root/record['roughnessTexture'];data=texture_path.read_bytes()
25
+ if hashlib.sha256(data).hexdigest()!=record['textureSha256']:raise ValueError('Surface patch exemplar changed')
26
+ provenance=record['provenance'];license_bytes=(root/provenance['licenseFile']).read_bytes()
27
+ if hashlib.sha256(license_bytes).hexdigest()!=provenance['licenseSha256']:raise ValueError('Surface patch license receipt changed')
28
+ self.attributions[provenance['licenseSha256']]=provenance['title']+'\n'+provenance['url']+'\nAdapted by vision registration and roughness remapping.\n'+license_bytes.decode()
29
+ donor=np.asarray(Image.open(texture_path).convert('L'),float)/255
30
+ source=np.asarray(record['sourcePixels'],float);target=np.asarray(record['targetPixels'],float)
31
+ if source.shape!=target.shape or source.ndim!=2 or source.shape[1]!=2 or len(source)<3 or not np.isfinite(source).all() or not np.isfinite(target).all():raise ValueError('Invalid surface patch correspondences')
32
+ size=manifest['size'];donor_size=record['sourceAnnotationImageSize'];resolution=record.get('sampleResolution',size)
33
+ if not isinstance(resolution,int) or not 16<=resolution<=4096:raise ValueError('Invalid patch sampling resolution')
34
+ if (target<0).any() or (target>=size).any() or (source<0).any() or (source>=donor_size).any():raise ValueError('Surface patch pixel outside evidence')
35
+ if not 0<record['featherM']<=.02:raise ValueError('Invalid surface patch feather')
36
+ low,high=record['roughnessRange']
37
+ if not 0<=low<high<=1:raise ValueError('Invalid surface patch roughness range')
38
+ target_mask=Image.new('L',(resolution,resolution));ImageDraw.Draw(target_mask).polygon([tuple(p*resolution/size)for p in target],fill=255);inside=np.asarray(target_mask)>0
39
+ yy,xx=np.nonzero(inside);mapped=RBFInterpolator(target/size,source/donor_size,kernel='thin_plate_spline',smoothing=0)(np.column_stack([xx,yy])/resolution)
40
+ sampled=map_coordinates(gaussian_filter(donor,1),[mapped[:,1]*(donor.shape[0]-1),mapped[:,0]*(donor.shape[1]-1)],order=1,mode='nearest')
41
+ # Preserve exemplar variation inside a declared art-directed physical response range.
42
+ rough=np.full((resolution,resolution),.5*(low+high));rough[yy,xx]=low+(high-low)*sampled
43
+ height=None
44
+ if record.get('height'):
45
+ h=record['height'];height_path=root/h['path']
46
+ if hashlib.sha256(height_path.read_bytes()).hexdigest()!=h['sha256']:raise ValueError('Surface height exemplar changed')
47
+ field=np.load(height_path,allow_pickle=False);bounds=np.asarray(h['boundsPx'],float)
48
+ if field.ndim!=2 or not np.isfinite(field).all() or np.max(abs(field))>.001 or bounds.shape!=(4,) or not np.isfinite(bounds).all() or np.any(bounds[2:]<=bounds[:2]) or not 0<h['scale']<=4 or not h.get('provenance'):
49
+ raise ValueError('Invalid physical surface height field or provenance')
50
+ coordinates=(mapped*donor_size-bounds[:2])/(bounds[2:]-bounds[:2])
51
+ height=np.zeros((resolution,resolution));height[yy,xx]=map_coordinates(field,[coordinates[:,1]*(field.shape[0]-1),coordinates[:,0]*(field.shape[1]-1)],order=1,mode='nearest')*h['scale']
52
+ self.attributions[provenance['licenseSha256']]+='\nHeight adaptation: '+json.dumps(h['provenance'])+'\n'
53
+ feather=np.clip(distance_transform_edt(inside)*view['span']*physical_height/resolution/record['featherM'],0,1);feather=feather*feather*(3-2*feather)
54
+ direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction);right=np.cross(direction,view.get('up',[0,0,1]));right/=np.linalg.norm(right);up=np.cross(right,direction)
55
+ self.patches.append((index,np.asarray(view.get('targetOffset',[0,0,0]))*physical_height,right,up,direction,view['span']*physical_height,resolution,LABELS[record['region']],rough,feather,height))
56
+ self.independent.append(record.get('independentDetail',False))
57
+ receipts.append({'view':record['view'],'region':record['region'],'roughnessTexture':record['roughnessTexture'],'textureSha256':record['textureSha256'],'roughnessRange':record['roughnessRange'],'height':record.get('height'),'provenance':record['provenance'],'independentDetail':self.independent[-1],'sampleResolution':resolution})
58
+ self.report={'profileSha256':hashlib.sha256(raw).hexdigest(),'patches':receipts,'method':'independent vision correspondences; optional physical height; semantic labels and geometric visibility constrain application'}
59
+
60
+ def evaluate(self,points,normals,labels,visibility,base,include_independent=True):
61
+ return self._evaluate(points,normals,labels,visibility,base,False,include_independent)
62
+
63
+ def relief(self,points,normals,labels,visibility,include_independent=True):
64
+ return self._evaluate(points,normals,labels,visibility,np.zeros(len(points)),True,include_independent)
65
+
66
+ def _evaluate(self,points,normals,labels,visibility,base,use_height,include_independent):
67
+ total=np.zeros(len(points));value=np.zeros(len(points))
68
+ for independent,patch in zip(self.independent,self.patches):
69
+ if independent and not include_independent:continue
70
+ index,target,right,up,direction,span,size,region,rough,feather,height=patch
71
+ field=height if use_height else rough
72
+ if field is None:continue
73
+ selected=(labels==region)&((visibility&(1<<index))!=0)
74
+ if not selected.any():continue
75
+ local=points[selected]-target;xy=np.stack([(local@right/span+.5)*size,(.5-local@up/span)*size],1)
76
+ weight=map_coordinates(feather,[xy[:,1],xy[:,0]],order=1,mode='constant',cval=0)
77
+ facing=np.clip((abs(normals[selected]@direction)-.2)/.3,0,1);weight*=facing*facing*(3-2*facing)
78
+ value[selected]+=map_coordinates(field,[xy[:,1],xy[:,0]],order=1,mode='nearest')*weight;total[selected]+=weight
79
+ return base*(1-np.minimum(total,1))+value/np.maximum(total,1e-12)*np.minimum(total,1)
80
+
81
+ def independent_planes(self):
82
+ names=['index','target','right','up','direction','span','size','region','roughness','feather','height']
83
+ return [dict(zip(names,patch)) for independent,patch in zip(self.independent,self.patches) if independent]