@bitmagic/cli 0.1.66-dev.5 → 0.1.66-dev.7
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.
- package/README.md +36 -1
- package/assets/character-skin-authoring/SKILL.md +43 -0
- package/assets/character-skin-authoring/references/exporters.md +134 -0
- package/assets/character-skin-authoring/references/eyes.md +215 -0
- package/assets/character-skin-authoring/references/materials.md +115 -0
- package/assets/character-skin-authoring/references/playground.md +78 -0
- package/assets/character-skin-authoring/references/profiles.md +97 -0
- package/assets/character-skin-authoring/references/projection.md +190 -0
- package/assets/character-skin-authoring/references/regional-detail.md +72 -0
- package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
- package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
- package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
- package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
- package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
- package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
- package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
- package/assets/character-skin-authoring/scripts/character_build.py +54 -0
- package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
- package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
- package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
- package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
- package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
- package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
- package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
- package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
- package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
- package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
- package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
- package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
- package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
- package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
- package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
- package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
- package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
- package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
- package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
- package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
- package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
- package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
- package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
- package/assets/metahuman/tools/lipsync/make_line.py +181 -0
- package/assets/metahuman/tools/metahuman/README.md +21 -0
- package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
- package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
- package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
- package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
- package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
- package/assets/metahuman/tools/metahuman/package.json +13 -0
- package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
- package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
- package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
- package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
- package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
- package/dist/commands/generate.d.ts +49 -0
- package/dist/commands/generate.js +168 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/generate/audio-file.d.ts +6 -0
- package/dist/generate/audio-file.js +42 -0
- package/dist/generate/audio-file.js.map +1 -0
- package/dist/generate/music.d.ts +4 -3
- package/dist/generate/music.js +6 -23
- package/dist/generate/music.js.map +1 -1
- package/dist/generate/stream.d.ts +13 -0
- package/dist/generate/stream.js +14 -1
- package/dist/generate/stream.js.map +1 -1
- package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
- package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
- package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
- package/dist/scaffold/character-skin-skill-files.js +49 -0
- package/dist/scaffold/character-skin-skill-files.js.map +1 -0
- package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
- package/dist/scaffold/metahuman-skill-files.js +306 -0
- package/dist/scaffold/metahuman-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +4 -4
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +4 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +3 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/dist/tools/bundles.js +8 -0
- package/dist/tools/bundles.js.map +1 -1
- package/package.json +4 -4
|
@@ -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]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Short strand groom from explicit image-space brow polygons and comb guides.
|
|
2
|
+
|
|
3
|
+
Sampling places designed fibers inside authored regions; it does not recognize hair.
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def inside_polygon(points, polygon):
|
|
9
|
+
polygon=np.asarray(polygon,float);inside=np.zeros(len(points),bool)
|
|
10
|
+
for a,b in zip(polygon,np.roll(polygon,-1,axis=0)):
|
|
11
|
+
if abs(b[1]-a[1])<1e-12:continue
|
|
12
|
+
inside^=((a[1]>points[:,1])!=(b[1]>points[:,1]))&(points[:,0]<(b[0]-a[0])*(points[:,1]-a[1])/(b[1]-a[1])+a[0])
|
|
13
|
+
return inside
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_brow(profile,points,triangles,normals,target,right,up,direction,span,size,unit,inverse,origin):
|
|
17
|
+
polygon=np.asarray(profile['polygonPx'],float);guides=np.asarray(profile['combGuidesPx'],float)
|
|
18
|
+
if polygon.ndim!=2 or polygon.shape[1]!=2 or len(polygon)<3 or not np.isfinite(polygon).all():raise ValueError('Brow needs an explicit finite polygon')
|
|
19
|
+
if guides.ndim!=3 or guides.shape[1:]!=(2,2) or len(guides)<2 or not np.isfinite(guides).all():raise ValueError('Brow needs at least two explicit comb guides')
|
|
20
|
+
vectors=guides[:,1]-guides[:,0];lengths=np.linalg.norm(vectors,axis=1)
|
|
21
|
+
if np.any(lengths<1e-6):raise ValueError('Brow comb guides must have a direction')
|
|
22
|
+
vectors/=lengths[:,None]
|
|
23
|
+
pigment=np.asarray(profile['colorLinear'],float)
|
|
24
|
+
if pigment.shape!=(3,) or not np.isfinite(pigment).all() or np.any(pigment<0) or np.any(pigment>1):raise ValueError('Brow pigment must be finite linear RGB')
|
|
25
|
+
count=int(profile['strandCount']);radii=float(profile['radiusM'])*unit
|
|
26
|
+
low,high=np.asarray(profile['lengthRangeM'],float)*unit
|
|
27
|
+
if not 1<=count<=1000 or not 0<radii<.0001*unit or not 0<low<=high<.015*unit:raise ValueError('Invalid authored brow fiber dimensions')
|
|
28
|
+
rng=np.random.default_rng(profile['seed']);roots=[]
|
|
29
|
+
for batch in range(100):
|
|
30
|
+
candidates=rng.uniform(polygon.min(0),polygon.max(0),size=(count*4,2))
|
|
31
|
+
roots.extend(candidates[inside_polygon(candidates,polygon)])
|
|
32
|
+
if len(roots)>=count:break
|
|
33
|
+
if len(roots)<count:raise ValueError('Brow polygon has insufficient area')
|
|
34
|
+
roots=np.asarray(roots[:count])
|
|
35
|
+
projected=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1)
|
|
36
|
+
bounds=projected[triangles];selected=np.all(bounds.max(1)>=polygon.min(0)-1,axis=1)&np.all(bounds.min(1)<=polygon.max(0)+1,axis=1)
|
|
37
|
+
triangles=triangles[selected];a=points[triangles[:,0]];e1=points[triangles[:,1]]-a;e2=points[triangles[:,2]]-a
|
|
38
|
+
h=np.cross(direction,e2);det=(e1*h).sum(1);safe=np.where(abs(det)>1e-12,det,1)
|
|
39
|
+
vertices=[];vertex_normals=[];faces=[];color=[]
|
|
40
|
+
for pixel in roots:
|
|
41
|
+
ray=target+right*((pixel[0]+.5)/size-.5)*span+up*(.5-(pixel[1]+.5)/size)*span-direction*span*4
|
|
42
|
+
s=ray-a;u=(s*h).sum(1)/safe;q=np.cross(s,e1);v=(q*direction).sum(1)/safe;t=(e2*q).sum(1)/safe
|
|
43
|
+
valid=(abs(det)>1e-12)&(u>=0)&(v>=0)&(u+v<=1)&(t>0)
|
|
44
|
+
if not valid.any():raise ValueError('Authored brow root misses visible source geometry')
|
|
45
|
+
face=int(np.argmin(np.where(valid,t,np.inf)));root=ray+direction*t[face]
|
|
46
|
+
normal=normals[triangles[face]].T@np.array([1-u[face]-v[face],u[face],v[face]]);normal/=np.linalg.norm(normal)
|
|
47
|
+
weights=1/np.maximum(np.sum((guides[:,0]-pixel)**2,axis=1),9);comb=weights@vectors/weights.sum()
|
|
48
|
+
tangent=right*comb[0]-up*comb[1];tangent-=normal*np.dot(normal,tangent);tangent/=np.linalg.norm(tangent)
|
|
49
|
+
length=rng.uniform(low,high);curve=[]
|
|
50
|
+
for fraction in np.linspace(0,1,8):
|
|
51
|
+
curve.append(root+tangent*length*fraction+normal*unit*(.00004+.00018*np.sin(np.pi*fraction)+.00008*fraction))
|
|
52
|
+
curve=np.asarray(curve);along=np.gradient(curve,axis=0);along/=np.linalg.norm(along,axis=1,keepdims=True)
|
|
53
|
+
side=np.cross(along,normal);side/=np.linalg.norm(side,axis=1,keepdims=True);other=np.cross(along,side)
|
|
54
|
+
angle=np.arange(5)*2*np.pi/5;tube_normals=side[:,None,:]*np.cos(angle)[None,:,None]+other[:,None,:]*np.sin(angle)[None,:,None]
|
|
55
|
+
tube=curve[:,None,:]+tube_normals*radii*np.linspace(1,.05,8)[:,None,None];start=len(vertices)
|
|
56
|
+
vertices.extend((tube.reshape(-1,3)-origin)@inverse.T)
|
|
57
|
+
transformed=tube_normals.reshape(-1,3)@np.linalg.inv(inverse);transformed/=np.linalg.norm(transformed,axis=1,keepdims=True);vertex_normals.extend(transformed)
|
|
58
|
+
shade=np.clip(pigment*rng.uniform(.8,1.2),0,1);color.extend(np.broadcast_to(shade,(40,3)))
|
|
59
|
+
for row in range(7):
|
|
60
|
+
for k in range(5):
|
|
61
|
+
i=start+row*5+k;j=start+row*5+(k+1)%5;faces.extend([[i,j,i+5],[j,j+5,i+5]])
|
|
62
|
+
return np.asarray(vertices),np.asarray(vertex_normals),np.asarray(color),np.asarray(faces)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Register an attributed scan detail atlas to a generated face, preserving identity.
|
|
2
|
+
|
|
3
|
+
This transfers band-limited displacement, not the scan's face shape. Height scale
|
|
4
|
+
is an authoring calibration (not recovered physical capture metadata). No MERL or
|
|
5
|
+
MetaHuman textures are used. All derivatives carry source/license/hash provenance.
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import hashlib,json
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image,ImageDraw
|
|
11
|
+
from scipy.ndimage import gaussian_filter, distance_transform_edt
|
|
12
|
+
from face_atlas import rasterize,sample,register
|
|
13
|
+
from glb_arrays import read,accessor
|
|
14
|
+
from workspace_paths import workspace_root
|
|
15
|
+
ROOT=workspace_root(__file__)
|
|
16
|
+
REFERENCE=ROOT/'highquality/references/lee-perry-smith'
|
|
17
|
+
|
|
18
|
+
def pixels_to_coords(points,bounds,n=900):
|
|
19
|
+
p=np.asarray(points,dtype=float)/n;p[:,1]=1-p[:,1]
|
|
20
|
+
return np.asarray(bounds[:2])+p*(np.asarray(bounds[2:])-bounds[:2])
|
|
21
|
+
|
|
22
|
+
def region_image(profile,size=2048):
|
|
23
|
+
im=Image.new('L',(size,size),0);draw=ImageDraw.Draw(im)
|
|
24
|
+
for value,kind in enumerate(['eyes','brows','lips'],1):
|
|
25
|
+
for polygon in profile['regions'][kind]:
|
|
26
|
+
draw.polygon([(x/profile['annotationImageSize']*(size-1),(1-y/profile['annotationImageSize'])*(size-1)) for x,y in polygon],fill=value)
|
|
27
|
+
return np.array(im)
|
|
28
|
+
|
|
29
|
+
def verify_landmark_evidence(profile,root,source):
|
|
30
|
+
if profile.get('version',1)<2:return 'legacy_unbound_landmarks'
|
|
31
|
+
if hashlib.sha256(source.read_bytes()).hexdigest()!=profile['sourceSha256']:
|
|
32
|
+
raise ValueError('Face landmarks belong to a different source mesh')
|
|
33
|
+
image=root/profile['evidenceImage']
|
|
34
|
+
if hashlib.sha256(image.read_bytes()).hexdigest()!=profile['evidenceImageSha256']:
|
|
35
|
+
raise ValueError('Face landmark evidence image changed')
|
|
36
|
+
with Image.open(image) as pixels:
|
|
37
|
+
if pixels.size!=(profile['annotationImageSize'],profile['annotationImageSize']):
|
|
38
|
+
raise ValueError('Face landmark evidence dimensions differ')
|
|
39
|
+
return 'source_and_image_hash_verified'
|
|
40
|
+
|
|
41
|
+
def build(sid='b3662'):
|
|
42
|
+
profile_path=ROOT/'highquality/materials/faces'/f'{sid}.json';profile=json.loads(profile_path.read_text())
|
|
43
|
+
evidence_status=verify_landmark_evidence(profile,ROOT,ROOT/'characters'/sid/'high/v1/source/hy31pro.glb')
|
|
44
|
+
folder=ROOT/'characters'/sid/'high/v1/materials/v2';folder.mkdir(parents=True,exist_ok=True)
|
|
45
|
+
source=REFERENCE/'LeePerrySmith.glb';j,b=read(source);pr=j['meshes'][0]['primitives'][0]
|
|
46
|
+
p=accessor(j,b,pr['attributes']['POSITION']);uv=accessor(j,b,pr['attributes']['TEXCOORD_0']);uv[:,1]=1-uv[:,1]
|
|
47
|
+
ids=accessor(j,b,pr['indices']).reshape(-1,3)
|
|
48
|
+
size=profile.get('atlasSize',2048)
|
|
49
|
+
if size not in (2048,4096):raise ValueError('Facial atlasSize must be 2048 or 4096')
|
|
50
|
+
pixel_scale=size/2048
|
|
51
|
+
scan_uv,scan_valid=rasterize(p,ids,uv,profile['sourceBounds'],size)
|
|
52
|
+
displacement=np.asarray(Image.open(REFERENCE/'Infinite-Level_02_Disp_NoSmoothUV-4096.jpg').convert('L'),dtype=float)/255
|
|
53
|
+
scan_height=sample(displacement,scan_uv)
|
|
54
|
+
# Inpaint uncovered pixels before filtering to avoid silhouette impulses.
|
|
55
|
+
nearest=distance_transform_edt(~scan_valid,return_distances=False,return_indices=True)
|
|
56
|
+
scan_height=scan_height[tuple(nearest)]
|
|
57
|
+
# Remove identity-scale form; retain scan-correlated intermediate and fine detail.
|
|
58
|
+
band=gaussian_filter(scan_height,.65*pixel_scale)-gaussian_filter(scan_height,24*pixel_scale)
|
|
59
|
+
central=band[size//3:3*size//4,size//3:2*size//3]
|
|
60
|
+
amplitude=np.percentile(np.abs(central),95)
|
|
61
|
+
band*=profile['detailAmplitudeM']/max(amplitude,1.e-6)
|
|
62
|
+
np.save(folder/'scan-band-height-m.npy',band.astype('float32'))
|
|
63
|
+
yy,xx=np.mgrid[0:size,0:size];coords=np.stack([xx,yy],-1)/(size-1)
|
|
64
|
+
bounds=np.array(profile['bounds']);target=coords*(bounds[2:]-bounds[:2])+bounds[:2]
|
|
65
|
+
mapped,valid=register(target.reshape(-1,2),pixels_to_coords(profile['targetPixels'],profile['bounds'],profile['annotationImageSize']),pixels_to_coords(profile['sourcePixels'],profile['sourceBounds'],profile.get('sourceAnnotationImageSize',900)))
|
|
66
|
+
sb=np.array(profile['sourceBounds']);lookup=((mapped-sb[:2])/(sb[2:]-sb[:2])).reshape(size,size,2)
|
|
67
|
+
height=sample(band,lookup)
|
|
68
|
+
regions=region_image(profile,size)
|
|
69
|
+
coverage=valid.reshape(size,size)&(regions==0)
|
|
70
|
+
if not coverage.any():raise ValueError('Registration has no uncovered skin; review landmark hull and exclusion polygons')
|
|
71
|
+
feather=np.clip(distance_transform_edt(coverage)/(48*pixel_scale),0,1)
|
|
72
|
+
# Roughness follows the transferred relief's local variation, with coarse oil regions.
|
|
73
|
+
dy,dx=np.gradient(height);variance=gaussian_filter(dx*dx+dy*dy,3*pixel_scale)
|
|
74
|
+
relief=np.clip(np.sqrt(variance)/max(np.percentile(np.sqrt(variance[coverage]),95),1e-9),0,1)
|
|
75
|
+
# Transfer only gentle chromatic variation from clean upper-face skin;
|
|
76
|
+
# reject beard/jaw pigment so the donor's stubble does not enter the queen.
|
|
77
|
+
scan_color=sample(np.asarray(Image.open(REFERENCE/'Map-COL.jpg'),dtype=float)/255,scan_uv)
|
|
78
|
+
chroma=np.log(np.maximum(scan_color[:,:,0],.02)/np.maximum(scan_color[:,:,1],.02))
|
|
79
|
+
chroma=gaussian_filter(chroma,4*pixel_scale)-gaussian_filter(chroma,50*pixel_scale)
|
|
80
|
+
pigment=sample(chroma,lookup)*np.clip((lookup[:,:,1]-.53)/.09,0,1)
|
|
81
|
+
pigment=np.clip(pigment*4,-.3,.3)
|
|
82
|
+
packed=np.stack([np.clip(.5+height/profile['detailHeightRangeM'],0,1),.45+.20*relief,.5+pigment,feather],axis=-1)
|
|
83
|
+
Image.fromarray(np.round(packed*255).astype('uint8'),'RGBA').save(folder/'face-detail.png')
|
|
84
|
+
# Authored appearance deltas, NOT captured expression scans. Modulate the
|
|
85
|
+
# registered relief within facial regions; the source HY geometry stays intact.
|
|
86
|
+
x=coords[:,:,0];z=coords[:,:,1]
|
|
87
|
+
forehead=np.exp(-((x-.5)/.22)**2-((z-.69)/.11)**2)
|
|
88
|
+
smile=np.exp(-((abs(x-.5)-.15)/.10)**2-((z-.23)/.13)**2)
|
|
89
|
+
squint=np.exp(-((abs(x-.5)-.19)/.10)**2-((z-.46)/.09)**2)
|
|
90
|
+
delta=np.stack([height*forehead*.8,height*smile*.6,height*squint*.7],-1)
|
|
91
|
+
dynamic=np.concatenate([np.clip(.5+delta/profile['detailHeightRangeM'],0,1),np.clip((smile+forehead+squint)*feather,0,1)[...,None]],axis=-1)
|
|
92
|
+
Image.fromarray(np.round(dynamic*255).astype('uint8'),'RGBA').save(folder/'face-expression.png')
|
|
93
|
+
np.save(folder/'face-height-m.npy' ,height.astype('float32'))
|
|
94
|
+
Image.fromarray(regions.astype('uint8')).save(folder/'face-regions.png')
|
|
95
|
+
# Useful independent normal preview in the registered face plane.
|
|
96
|
+
physical_height=json.loads((ROOT/'characters'/sid/'high/v1/output/viewer-profile.json').read_text())['height']
|
|
97
|
+
spacing=(bounds[2:]-bounds[:2])*physical_height/(size-1)
|
|
98
|
+
dy,dx=np.gradient(height,spacing[1],spacing[0]);n=np.stack([-dx,-dy,np.ones_like(height)],-1);n/=np.linalg.norm(n,axis=-1,keepdims=True)
|
|
99
|
+
Image.fromarray(np.round((n*.5+.5)*255).astype('uint8')).save(folder/'face-normal-preview.png')
|
|
100
|
+
report={'id':sid,'landmarkEvidence':evidence_status,'method':'landmark-registered, band-limited scan displacement','author':'Lee Perry-Smith / Infinite Realities','license':'CC BY 3.0','licenseUrl':'https://creativecommons.org/licenses/by/3.0/','sourceUrl':'https://github.com/mrdoob/three.js/tree/r186/examples/models/gltf/LeePerrySmith','changes':'Projected into a facial atlas, band-pass filtered, amplitude calibrated, landmark-warped and masked. Only upper-face chromatic residual is transferred; beard pigment and face shape are not copied.','expressionMaps':'Authored modulation of scan relief; not captured expressions or a biological color model','scale':'Authoring estimate, not measured displacement units','heightRangeM':profile['detailHeightRangeM'],'profile_sha256':hashlib.sha256(profile_path.read_bytes()).hexdigest(),'inputs':{f.name:hashlib.sha256(f.read_bytes()).hexdigest() for f in REFERENCE.iterdir() if f.is_file()},'source_sha256':hashlib.sha256((ROOT/'characters'/sid/'high/v1/source/hy31pro.glb').read_bytes()).hexdigest()}
|
|
101
|
+
report['atlasSize']=size;report['texelSpacingM']=spacing.tolist();report['filterScaleRelativeTo2048']=pixel_scale
|
|
102
|
+
(folder/'face-detail-provenance.json').write_text(json.dumps(report,indent=2)+'\n')
|
|
103
|
+
(folder/'ATTRIBUTION.txt').write_text('Facial detail adapted from Infinite, 3D Head Scan by Lee Perry-Smith / Infinite Realities.\nCC BY 3.0: https://creativecommons.org/licenses/by/3.0/\nSource: three.js r186 examples/models/gltf/LeePerrySmith.\nModified by landmark registration, filtering, masking and amplitude calibration.\n')
|
|
104
|
+
print(json.dumps({'id':sid,'atlasSize':size,'heightP95Mm':float(np.percentile(abs(height[coverage]),95)*1000)}))
|
|
105
|
+
if __name__=='__main__':
|
|
106
|
+
import argparse
|
|
107
|
+
parser=argparse.ArgumentParser();parser.add_argument('--id',default='b3662');build(parser.parse_args().id)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Build reviewed evidence into a separate surface/eye/runtime candidate, fail-fast.
|
|
2
|
+
|
|
3
|
+
Authoring and visual acceptance remain agent tasks. This command never selects anatomy,
|
|
4
|
+
promotes viewer readiness, modifies the original model, or runs paid generation.
|
|
5
|
+
"""
|
|
6
|
+
import argparse,json,subprocess,sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from workspace_paths import workspace_root
|
|
10
|
+
ROOT=workspace_root(__file__)
|
|
11
|
+
|
|
12
|
+
def build(character,evidence,masks,eyes=None,assemble=False):
|
|
13
|
+
base=ROOT/'characters'/character/'high/v1'
|
|
14
|
+
for file in [evidence/'manifest.json',masks/'report.json',masks/'material-0-surface.npz',base/'materials/v2/face-detail.png',base/'materials/v2/face-expression.png',base/'materials/v2/scan-band-height-m.npy']:
|
|
15
|
+
if not file.is_file():raise ValueError(f'Missing authored input: {file}')
|
|
16
|
+
if eyes and json.loads(eyes.read_text())['id']!=character:raise ValueError('Eye profile character mismatch')
|
|
17
|
+
def run(script,*args):
|
|
18
|
+
subprocess.run([sys.executable,str(Path(__file__).resolve().parent/script),*map(str,args)],cwd=ROOT,check=True)
|
|
19
|
+
run('vision_materials.py','--id',character,'--evidence',evidence,'--masks',masks)
|
|
20
|
+
run('vision_blend_materials.py','--id',character,'--all-surfaces')
|
|
21
|
+
if eyes:run('eye_assets.py','--profile',eyes)
|
|
22
|
+
if assemble:
|
|
23
|
+
run('assemble_character.py','--id',character,'--input','materials/v4/character.glb','--variant','surfaces')
|
|
24
|
+
if eyes:run('assemble_character.py','--id',character,'--input','eyes/v1/character.glb','--variant','eyes')
|
|
25
|
+
|
|
26
|
+
if __name__=='__main__':
|
|
27
|
+
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--id',required=True);p.add_argument('--evidence',required=True,type=Path);p.add_argument('--masks',required=True,type=Path);p.add_argument('--eyes-profile',type=Path);p.add_argument('--assemble',action='store_true');a=p.parse_args()
|
|
28
|
+
build(a.id,a.evidence.resolve(),a.masks.resolve(),a.eyes_profile.resolve() if a.eyes_profile else None,a.assemble)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Designed lining between an explicitly authored canthus and the globe."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from eyelid_morph import conform_open_lid,surface_normals,surface_normal_groups
|
|
4
|
+
from eyelid_patch import lid_contact_depth,corner_contact_depth
|
|
5
|
+
|
|
6
|
+
def lining_depth_at_radius(radial,corner_radial,source_depth,radius,iris,unit,reconstructed=True):
|
|
7
|
+
"""Depth on the authored corner lining, for tissue supported by that lining."""
|
|
8
|
+
swept=iris*np.cos(.35)+np.sqrt(radius*radius-iris*iris)*np.sin(.35)
|
|
9
|
+
inner=max(radius*.88,swept+.00035*unit)
|
|
10
|
+
if inner>=radius or not inner<=radial<=corner_radial or corner_radial<=inner:
|
|
11
|
+
raise ValueError('Authored corner tissue lies outside its lining support')
|
|
12
|
+
globe=np.sqrt(max(0,radius*radius-corner_radial*corner_radial))
|
|
13
|
+
if reconstructed:boundary=float(lid_contact_depth(corner_radial,radius,globe,source_depth))+.00025*unit
|
|
14
|
+
else:boundary=max(source_depth,globe+.00025*unit) if corner_radial<radius and source_depth>0 else source_depth
|
|
15
|
+
inner_depth=np.sqrt(radius*radius-inner*inner)-.00035*unit
|
|
16
|
+
t=(radial-inner)/(corner_radial-inner)
|
|
17
|
+
return inner_depth*(1-t)+(boundary-.0003*unit)*t
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_canthus_wall(profile,opening,sample_surface,inverse,origin):
|
|
21
|
+
contour=np.asarray(opening['contour']);indices=profile['contourIndices']
|
|
22
|
+
if len(indices)<3:raise ValueError('A corner wall needs upper, corner and lower landmarks')
|
|
23
|
+
path=contour[indices];pixels=np.concatenate([a[None,:]*(1-np.arange(12)[:,None]/12)+b[None,:]*(np.arange(12)[:,None]/12) for a,b in zip(path[:-1],path[1:])]+[path[-1:]])
|
|
24
|
+
right=opening['right'];up=opening['up'];forward=-opening['direction'];center=opening['center'];radius=opening['radius'];unit=opening['unit']
|
|
25
|
+
plane=opening['target']+right[None,:]*((pixels[:,0]/opening['size']-.5)*opening['span'])[:,None]+up[None,:]*((.5-pixels[:,1]/opening['size'])*opening['span'])[:,None]
|
|
26
|
+
xy=np.stack([(plane-center)@right,(plane-center)@up],1);rad=np.linalg.norm(xy,axis=1)
|
|
27
|
+
swept_iris=opening['iris']*np.cos(.35)+np.sqrt(radius*radius-opening['iris']**2)*np.sin(.35)
|
|
28
|
+
inner_radius=max(radius*.88,swept_iris+.00035*unit)
|
|
29
|
+
if inner_radius>=radius:raise ValueError('Authored optical dimensions leave no lining clearance for the reviewed gaze range')
|
|
30
|
+
active=np.flatnonzero(rad>inner_radius)
|
|
31
|
+
if len(active)<3 or np.any(np.diff(active)!=1):raise ValueError('Reviewed corner must form one contiguous lining outside the globe center')
|
|
32
|
+
plane=plane[active];xy=xy[active];rad=rad[active]
|
|
33
|
+
boundary=np.asarray([sample_surface(p) for p in plane])
|
|
34
|
+
if profile.get('contactMethod') == 'reconstructed-lid-v1':
|
|
35
|
+
# Use precisely the reconstructed aperture's depth, rather than the
|
|
36
|
+
# old source eye surface, which is removed by the lid-band exporter.
|
|
37
|
+
source_depth=(boundary-center)@forward
|
|
38
|
+
globe_depth=np.sqrt(np.maximum(0,radius*radius-rad*rad))
|
|
39
|
+
depth=np.array([corner_contact_depth(r,g,s,p,opening) for r,g,s,p in zip(rad,globe_depth,source_depth,plane)])+.00025*unit
|
|
40
|
+
boundary=center+xy[:,0,None]*right+xy[:,1,None]*up+depth[:,None]*forward
|
|
41
|
+
elif profile.get('contactMethod') is not None:
|
|
42
|
+
raise ValueError('Unsupported canthus contact method')
|
|
43
|
+
boundary,_=conform_open_lid(boundary,opening)
|
|
44
|
+
inner_xy=xy*(inner_radius/rad)[:,None];inner=center+inner_xy[:,0,None]*right+inner_xy[:,1,None]*up+forward*(np.sqrt(radius*radius-inner_radius*inner_radius)-.00035*unit)
|
|
45
|
+
rows=7;vertices=[];roots=[];weights=[];faces=[];count=len(boundary)
|
|
46
|
+
for row,t in enumerate(np.linspace(0,1,rows)):
|
|
47
|
+
# The near-lid edge shares its support point with the source lid, but is
|
|
48
|
+
# slightly recessed. The far edge remains behind the globe during blink.
|
|
49
|
+
vertices.extend(inner*(1-t)+(boundary-forward*.0003*unit)*t)
|
|
50
|
+
roots.extend(boundary);weights.extend(np.full(count,t))
|
|
51
|
+
if row:
|
|
52
|
+
for j in range(count-1):
|
|
53
|
+
a=(row-1)*count+j;b=a+1;faces.extend([[a,b,a+count],[b,b+count,a+count]])
|
|
54
|
+
vertices=(np.asarray(vertices)-origin)@inverse.T;faces=np.asarray(faces)
|
|
55
|
+
normals=surface_normals(vertices,faces,surface_normal_groups(vertices))
|
|
56
|
+
colors=np.broadcast_to(profile['colorLinear'],vertices.shape).copy()
|
|
57
|
+
return (vertices,normals,colors,faces),(np.asarray(roots)-origin)@inverse.T,np.asarray(weights)
|