@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,171 @@
|
|
|
1
|
+
"""Rest-space eyelid closure from authored ocular contours; eyeballs never scale."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.spatial.transform import Rotation
|
|
4
|
+
from glb_arrays import accessor
|
|
5
|
+
from glb_output import append_accessor
|
|
6
|
+
from vision_materials import source_world_points
|
|
7
|
+
|
|
8
|
+
def surface_normal_groups(rest):
|
|
9
|
+
"""Weld geometric duplicates for deformation normals, retaining every UV vertex."""
|
|
10
|
+
tolerance=max(float(np.ptp(rest,axis=0).max())*1e-8,1e-12)
|
|
11
|
+
return np.unique(np.round(rest/tolerance).astype(np.int64),axis=0,return_inverse=True)[1]
|
|
12
|
+
|
|
13
|
+
def surface_normals(vertices,faces,groups):
|
|
14
|
+
face=np.cross(vertices[faces[:,1]]-vertices[faces[:,0]],vertices[faces[:,2]]-vertices[faces[:,0]])
|
|
15
|
+
summed=np.zeros((int(groups.max())+1,3),dtype=np.float64)
|
|
16
|
+
for k in range(3):np.add.at(summed,groups[faces[:,k]],face)
|
|
17
|
+
summed/=np.maximum(np.linalg.norm(summed,axis=1,keepdims=True),1e-12)
|
|
18
|
+
return summed[groups]
|
|
19
|
+
|
|
20
|
+
def transport_normals(original,rest,deformed):
|
|
21
|
+
"""Rotate authored normals with the geometric frame instead of adding vectors."""
|
|
22
|
+
result=np.asarray(original,dtype=float).copy()
|
|
23
|
+
valid=(np.linalg.norm(rest,axis=1)>1e-8)&(np.linalg.norm(deformed,axis=1)>1e-8)
|
|
24
|
+
a=rest[valid]/np.linalg.norm(rest[valid],axis=1,keepdims=True)
|
|
25
|
+
b=deformed[valid]/np.linalg.norm(deformed[valid],axis=1,keepdims=True)
|
|
26
|
+
n=result[valid];cosine=np.clip((a*b).sum(1),-1,1);axis=np.cross(a,b)
|
|
27
|
+
regular=cosine>-1+1e-8
|
|
28
|
+
v=axis[regular];x=n[regular]
|
|
29
|
+
n[regular]=x+np.cross(v,x)+np.cross(v,np.cross(v,x))/(1+cosine[regular,None])
|
|
30
|
+
if (~regular).any():
|
|
31
|
+
# The shortest rotation is ambiguous at 180 degrees; select a stable
|
|
32
|
+
# perpendicular axis, retaining unit length and the geometric endpoint.
|
|
33
|
+
old=a[~regular];basis=np.eye(3)[np.argmin(abs(old),axis=1)]
|
|
34
|
+
v=np.cross(old,basis);v/=np.linalg.norm(v,axis=1,keepdims=True)
|
|
35
|
+
x=n[~regular];n[~regular]=2*v*(v*x).sum(1)[:,None]-x
|
|
36
|
+
n/=np.maximum(np.linalg.norm(n,axis=1,keepdims=True),1e-12)
|
|
37
|
+
result[valid]=n
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def closed_lid(points,o,band_pixels=36):
|
|
41
|
+
target=o['target'];right=o['right'];up=o['up'];forward=-o['direction'];span=o['span'];size=o['size'];contour=np.asarray(o['contour'])
|
|
42
|
+
xy=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1);x=xy[:,0];y=xy[:,1]
|
|
43
|
+
upper=np.full(len(points),np.inf);lower=np.full(len(points),-np.inf)
|
|
44
|
+
for a,b in zip(contour,np.roll(contour,-1,axis=0)):
|
|
45
|
+
if abs(b[0]-a[0])<1e-9:continue
|
|
46
|
+
valid=(x>=min(a[0],b[0]))&(x<=max(a[0],b[0]));yy=a[1]+(x-a[0])/(b[0]-a[0])*(b[1]-a[1]);upper[valid]=np.minimum(upper[valid],yy[valid]);lower[valid]=np.maximum(lower[valid],yy[valid])
|
|
47
|
+
valid=np.isfinite(upper)&np.isfinite(lower)&(np.linalg.norm(points-o['center'],axis=1)<o['radius']*2)&((points-o['center'])@forward>0)
|
|
48
|
+
# Invalid columns are neutral; avoid infinities entering the deformation arithmetic.
|
|
49
|
+
upper[~valid]=y[~valid];lower[~valid]=y[~valid]
|
|
50
|
+
middle=upper*.3+lower*.7;top=y<=middle;edge=np.where(top,upper,lower);distance=abs(y-edge)
|
|
51
|
+
weight=np.clip(1-distance/band_pixels,0,1);weight=weight*weight*(3-2*weight);weight*=valid
|
|
52
|
+
movement=(middle-edge)*weight;out=points-up[None,:]*(movement*span/size)[:,None]
|
|
53
|
+
lateral=np.stack([(out-o['center'])@right,(out-o['center'])@up],1);rad=np.linalg.norm(lateral,axis=1)
|
|
54
|
+
depth=np.sqrt(np.maximum(0,o['radius']**2-rad**2));limbus=np.sqrt(o['radius']**2-o['iris']**2)
|
|
55
|
+
depth=np.where(rad<o['iris'],limbus+o['bulge']*np.maximum(0,1-(rad/o['iris'])**2),depth)+o['unit']*.00025
|
|
56
|
+
current=(out-o['center'])@forward
|
|
57
|
+
correction=depth-current
|
|
58
|
+
# Follow the globe where closure requires it, without dragging the whole
|
|
59
|
+
# orbital support band inward. Fade inward fitting at the canthi, where
|
|
60
|
+
# the authored upper and lower contours already meet and do not travel.
|
|
61
|
+
canthus=np.clip((lower-upper)/8,0,1)
|
|
62
|
+
inward=weight**8*canthus
|
|
63
|
+
correction=np.where(correction>0,correction*weight,correction*inward)
|
|
64
|
+
out+=forward[None,:]*correction[:,None]
|
|
65
|
+
return out,weight
|
|
66
|
+
|
|
67
|
+
def conform_open_lid(points,o,band_pixels=36):
|
|
68
|
+
"""Push intersecting lid tissue outside the authored globe, never pull skin in.
|
|
69
|
+
|
|
70
|
+
Selection is the explicitly fitted opening and its local support band. This
|
|
71
|
+
is a geometric fit operation, not an anatomical segmentation algorithm.
|
|
72
|
+
"""
|
|
73
|
+
forward=-o['direction'];rel=points-o['center']
|
|
74
|
+
xy=np.stack([((points-o['target'])@o['right']/o['span']+.5)*o['size'],(.5-(points-o['target'])@o['up']/o['span'])*o['size']],1)
|
|
75
|
+
distance=np.full(len(points),np.inf)
|
|
76
|
+
contour=np.asarray(o['contour'],float)
|
|
77
|
+
for a,b in zip(contour,np.roll(contour,-1,axis=0)):
|
|
78
|
+
edge=b-a;t=np.clip(((xy-a)*edge).sum(1)/np.dot(edge,edge),0,1)
|
|
79
|
+
distance=np.minimum(distance,np.linalg.norm(xy-a-t[:,None]*edge,axis=1))
|
|
80
|
+
lateral=np.stack([rel@o['right'],rel@o['up']],1);rad=np.linalg.norm(lateral,axis=1);current=rel@forward
|
|
81
|
+
support=(distance<band_pixels)&(current>0)&(rad<o['radius'])
|
|
82
|
+
depth=np.sqrt(np.maximum(0,o['radius']**2-rad**2))+o['unit']*.00025
|
|
83
|
+
limbus=np.sqrt(o['radius']**2-o['iris']**2)
|
|
84
|
+
depth=np.where(rad<o['iris'],limbus+o['bulge']*np.maximum(0,1-(rad/o['iris'])**2)+o['unit']*.00025,depth)
|
|
85
|
+
delta=np.where(support,np.maximum(0,depth-current),0)
|
|
86
|
+
return points+delta[:,None]*forward,delta>0
|
|
87
|
+
|
|
88
|
+
def follow_lid(points,o,direction):
|
|
89
|
+
_,weight=closed_lid(points,o)
|
|
90
|
+
xy=np.stack([((points-o['target'])@o['right']/o['span']+.5)*o['size'],(.5-(points-o['target'])@o['up']/o['span'])*o['size']],1)
|
|
91
|
+
contour=np.asarray(o['contour']);lo,hi=contour[:,0].min(),contour[:,0].max()
|
|
92
|
+
across=np.clip((xy[:,0]-lo)/(hi-lo),0,1)
|
|
93
|
+
canthus=np.sin(np.pi*across)**2
|
|
94
|
+
gains=o.get('lidFollowGains',[.55,.2])
|
|
95
|
+
gain=np.where(xy[:,1]<contour[:,1].mean(),gains[0],gains[1])
|
|
96
|
+
offset=direction*.35*o['radius']*gain*weight*canthus
|
|
97
|
+
moved=points+offset[:,None]*o['up']
|
|
98
|
+
# Refit only vertices affected by this target; a neutral eye's unrelated
|
|
99
|
+
# support vertices must not move when the gaze controller changes.
|
|
100
|
+
fitted,_=conform_open_lid(moved,o);active=abs(offset)>1e-12
|
|
101
|
+
moved[active]=fitted[active]
|
|
102
|
+
return moved,active
|
|
103
|
+
|
|
104
|
+
def add_blinks(doc,data,source,openings,eye_nodes):
|
|
105
|
+
mesh=doc['meshes'][0]
|
|
106
|
+
if any(p.get('targets') for p in mesh['primitives']):raise ValueError('Compose existing facial morphs explicitly before adding eyelid targets')
|
|
107
|
+
prim=mesh['primitives'][0];local=accessor(doc,data,prim['attributes']['POSITION']);world=source_world_points(source,local)
|
|
108
|
+
origin=source_world_points(source,np.zeros((1,3)))[0];basis=(source_world_points(source,np.eye(3))-origin).T;inverse=np.linalg.inv(basis)
|
|
109
|
+
names=['eyeBlink'+o['side'].title() for o in openings];targets=[];counts=[]
|
|
110
|
+
faces=np.concatenate([accessor(doc,data,p['indices']).reshape(-1,3) for p in mesh['primitives']]);original_normals=accessor(doc,data,prim['attributes']['NORMAL'])
|
|
111
|
+
groups=surface_normal_groups(local)
|
|
112
|
+
def geometric_normals(vertices):return surface_normals(vertices,faces,groups)
|
|
113
|
+
base_normals=geometric_normals(local)
|
|
114
|
+
for o in openings:
|
|
115
|
+
closed,weight=closed_lid(world,o);delta=(closed-world)@inverse.T
|
|
116
|
+
adjusted=transport_normals(original_normals,base_normals,geometric_normals(local+delta));normal_delta=adjusted-original_normals;normal_delta[weight==0]=0
|
|
117
|
+
targets.append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126),'NORMAL':append_accessor(doc,data,normal_delta.astype('<f4'),'VEC3',5126)});counts.append(int((weight>0).sum()))
|
|
118
|
+
for o in openings:
|
|
119
|
+
for direction,title in [(1,'Up'),(-1,'Down')]:
|
|
120
|
+
followed,active=follow_lid(world,o,direction);delta=(followed-world)@inverse.T
|
|
121
|
+
adjusted=transport_normals(original_normals,base_normals,geometric_normals(local+delta))
|
|
122
|
+
normal_delta=adjusted-original_normals;normal_delta[~active]=0
|
|
123
|
+
targets.append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126),'NORMAL':append_accessor(doc,data,normal_delta.astype('<f4'),'VEC3',5126)})
|
|
124
|
+
names.append('eyeLidLook'+title+o['side'].title())
|
|
125
|
+
for p in mesh['primitives']:p['targets']=targets.copy()
|
|
126
|
+
mesh['weights']=[0]*len(names);mesh.setdefault('extras',{})['targetNames']=names
|
|
127
|
+
times=np.array([0,.8,.9,.98,1.12,2],dtype='<f4');values=np.array([0,0,1,1,0,0],dtype='<f4');time_index=append_accessor(doc,data,times,'SCALAR',5126)
|
|
128
|
+
animation={'name':'Eyes Blink','samplers':[],'channels':[]}
|
|
129
|
+
def channel(node,weights):
|
|
130
|
+
i=len(animation['samplers']);animation['samplers'].append({'input':time_index,'output':append_accessor(doc,data,weights.astype('<f4').reshape(-1),'SCALAR',5126),'interpolation':'LINEAR'});animation['channels'].append({'sampler':i,'target':{'node':node,'path':'weights'}})
|
|
131
|
+
parent=next(i for i,n in enumerate(doc['nodes']) if n.get('mesh')==0);channel(parent,np.column_stack([values,values,np.zeros((len(values),len(names)-2))]))
|
|
132
|
+
gaze_bindings=[(parent,names)]
|
|
133
|
+
# Orbital backing stays head-fixed behind the optical envelope. Moving it
|
|
134
|
+
# toward the cornea during closure crosses the recessed iris at partial blinks.
|
|
135
|
+
for node_index,node in enumerate(doc['nodes']):
|
|
136
|
+
if 'mesh' not in node or node['mesh']==0:continue
|
|
137
|
+
m=doc['meshes'][node['mesh']];p=m['primitives'][0];material=doc['materials'][p['material']]
|
|
138
|
+
if material.get('extras',{}).get('eyeLayer') not in ['lid-contact','tear-meniscus','eyelashes','eyelid-skin','canthus-wall']:continue
|
|
139
|
+
side=next(i for i,o in enumerate(openings) if o['side']==material['extras']['eyeSide'])
|
|
140
|
+
p0=accessor(doc,data,p['attributes']['POSITION']);motion_points=accessor(doc,data,p['attributes']['_LID_WALL_ROOT']) if '_LID_WALL_ROOT' in p['attributes'] else accessor(doc,data,p['attributes']['_LASH_ROOT']) if '_LASH_ROOT' in p['attributes'] else p0;world0=source_world_points(source,motion_points);closed,weight=closed_lid(world0,openings[side])
|
|
141
|
+
transport=accessor(doc,data,p['attributes']['_LID_WALL_WEIGHT']).reshape(-1,1) if '_LID_WALL_WEIGHT' in p['attributes'] else 1
|
|
142
|
+
delta=((closed-world0)@inverse.T)*transport
|
|
143
|
+
p['targets']=[{'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126)}]
|
|
144
|
+
def add_patch_normals(target,offset):
|
|
145
|
+
if material['extras']['eyeLayer'] not in ['eyelid-skin','canthus-wall']:return
|
|
146
|
+
faces=accessor(doc,data,p['indices']).reshape(-1,3);groups=surface_normal_groups(p0)
|
|
147
|
+
original=accessor(doc,data,p['attributes']['NORMAL'])
|
|
148
|
+
adjusted=transport_normals(original,surface_normals(p0,faces,groups),surface_normals(p0+offset,faces,groups))
|
|
149
|
+
target['NORMAL']=append_accessor(doc,data,(adjusted-original).astype('<f4'),'VEC3',5126)
|
|
150
|
+
add_patch_normals(p['targets'][-1],delta)
|
|
151
|
+
local_names=[names[side]]
|
|
152
|
+
for direction,title in [(1,'Up'),(-1,'Down')]:
|
|
153
|
+
followed,_=follow_lid(world0,openings[side],direction);delta=((followed-world0)@inverse.T)*transport
|
|
154
|
+
p['targets'].append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126)})
|
|
155
|
+
add_patch_normals(p['targets'][-1],delta)
|
|
156
|
+
local_names.append('eyeLidLook'+title+openings[side]['side'].title())
|
|
157
|
+
m['weights']=[0]*len(local_names);m['extras']={'targetNames':local_names}
|
|
158
|
+
channel(node_index,np.column_stack([values,np.zeros((len(values),2))]));gaze_bindings.append((node_index,local_names))
|
|
159
|
+
doc.setdefault('animations',[]).append(animation)
|
|
160
|
+
gaze={'name':'Eyes Gaze','samplers':[],'channels':[]};gaze_times=append_accessor(doc,data,np.array([0,1,2,3,4],dtype='<f4'),'SCALAR',5126)
|
|
161
|
+
for i,node in enumerate(eye_nodes):
|
|
162
|
+
rest=Rotation.from_quat(doc['nodes'][node]['rotation']);rotations=np.array([(rest*Rotation.from_euler('yx',[yaw,pitch])).as_quat() for yaw,pitch in [(0,0),(.2,0),(0,.1),(-.2,0),(0,0)]],dtype='<f4')
|
|
163
|
+
gaze['samplers'].append({'input':gaze_times,'output':append_accessor(doc,data,rotations,'VEC4',5126),'interpolation':'LINEAR'});gaze['channels'].append({'sampler':i,'target':{'node':node,'path':'rotation'}})
|
|
164
|
+
for node,target_names in gaze_bindings:
|
|
165
|
+
weights=np.zeros((5,len(target_names)),dtype='<f4')
|
|
166
|
+
for j,name in enumerate(target_names):
|
|
167
|
+
if name.startswith('eyeLidLookDown'):weights[2,j]=.1/.35
|
|
168
|
+
sampler=len(gaze['samplers']);gaze['samplers'].append({'input':gaze_times,'output':append_accessor(doc,data,weights.reshape(-1),'SCALAR',5126),'interpolation':'LINEAR'})
|
|
169
|
+
gaze['channels'].append({'sampler':sampler,'target':{'node':node,'path':'weights'}})
|
|
170
|
+
doc['animations'].append(gaze)
|
|
171
|
+
return {'controls':names,'affectedVertices':counts,'method':'contour-defined eyelid closure with surface-following depth and smooth outer falloff','normals':'shortest-frame rotation transports authored normals with area-weighted deformed surface normals'}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Reconstruct a lid band between two explicitly authored ocular contours."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from eyelid_morph import conform_open_lid,surface_normals,surface_normal_groups
|
|
4
|
+
|
|
5
|
+
def authored_corner_support(hit,plane,opening):
|
|
6
|
+
"""Apply an explicitly reviewed corner depth, fading to source support."""
|
|
7
|
+
result=np.asarray(hit,float).copy();forward=-opening['direction']
|
|
8
|
+
pixel=np.array([((plane-opening['target'])@opening['right']/opening['span']+.5)*opening['size'],(.5-(plane-opening['target'])@opening['up']/opening['span'])*opening['size']])
|
|
9
|
+
for patch in opening.get('cornerDepths',[]):
|
|
10
|
+
radius=patch['radiusM']*opening['unit']/opening['span']*opening['size']
|
|
11
|
+
if radius<=0 or not np.isfinite([radius,patch['depthM']]).all():raise ValueError('Invalid authored corner depth')
|
|
12
|
+
t=np.clip(1-np.linalg.norm(pixel-np.asarray(patch['pixel']))/radius,0,1);weight=t*t*(3-2*t)
|
|
13
|
+
result+=forward*((patch['depthM']*opening['unit']-(result-opening['center'])@forward)*weight)
|
|
14
|
+
return result
|
|
15
|
+
|
|
16
|
+
def lid_contact_depth(radial_distance,radius,globe_depth,source_depth):
|
|
17
|
+
"""Beyond the sphere, retain authored face support instead of collapsing to zero."""
|
|
18
|
+
t=np.clip((radial_distance/radius-.85)/.15,0,1)
|
|
19
|
+
t=t*t*(3-2*t)
|
|
20
|
+
return globe_depth+t*np.maximum(0,source_depth-globe_depth)
|
|
21
|
+
|
|
22
|
+
def corner_contact_depth(radial,globe,source,plane,o):
|
|
23
|
+
depth=lid_contact_depth(radial,o['radius'],globe,source)
|
|
24
|
+
for patch in o.get('cornerDepths',[]):
|
|
25
|
+
pixel=np.asarray(patch['pixel']);corner=o['target']+o['right']*(pixel[0]/o['size']-.5)*o['span']+o['up']*(.5-pixel[1]/o['size'])*o['span']
|
|
26
|
+
rel=corner-o['center'];end=np.linalg.norm(rel-(rel@o['direction'])*o['direction']);start=.85*o['radius']
|
|
27
|
+
if end<=start:raise ValueError('Authored corner must lie outside the inner globe support')
|
|
28
|
+
axis=rel-(rel@o['direction'])*o['direction'];axis/=end
|
|
29
|
+
lateral=plane-o['center'];lateral-=((lateral@o['direction'])*o['direction'])
|
|
30
|
+
distance=np.linalg.norm(lateral-(lateral@axis)*axis)
|
|
31
|
+
width=patch['radiusM']*o['unit']
|
|
32
|
+
w=np.clip((width-distance)/(width*.5),0,1);w=w*w*(3-2*w)
|
|
33
|
+
if lateral@axis<=0:w=0
|
|
34
|
+
if radial>start and w>0:
|
|
35
|
+
t=np.clip((radial-start)/(end-start),0,1);z=np.sqrt(o['radius']**2-start**2);slope=-start/z
|
|
36
|
+
curve=(2*t**3-3*t*t+1)*z+(t**3-2*t*t+t)*slope*(end-start)+(-2*t**3+3*t*t)*patch['depthM']*o['unit']
|
|
37
|
+
# Preserve the globe clearance and blend away from the authored corner.
|
|
38
|
+
depth=max(globe,depth*(1-w)+curve*w)
|
|
39
|
+
return depth
|
|
40
|
+
|
|
41
|
+
def sample_paired_contours(inner,outer,projected_triangles=None):
|
|
42
|
+
"""Preserve source-edge breakpoints on the reconstructed band's outer rim."""
|
|
43
|
+
inner=np.asarray(inner,float);outer=np.asarray(outer,float);inside=[];outside=[]
|
|
44
|
+
if projected_triangles is not None:
|
|
45
|
+
a=projected_triangles.reshape(-1,2)
|
|
46
|
+
b=np.roll(projected_triangles,-1,axis=1).reshape(-1,2);edge=b-a
|
|
47
|
+
for i,p in enumerate(outer):
|
|
48
|
+
q=outer[(i+1)%len(outer)];d=q-p;ts=list(np.arange(8)/8)
|
|
49
|
+
if projected_triangles is not None:
|
|
50
|
+
offset=a-p;den=d[0]*edge[:,1]-d[1]*edge[:,0];valid=abs(den)>1e-12
|
|
51
|
+
safe=np.where(valid,den,1)
|
|
52
|
+
t=(offset[:,0]*edge[:,1]-offset[:,1]*edge[:,0])/safe
|
|
53
|
+
u=(offset[:,0]*d[1]-offset[:,1]*d[0])/safe
|
|
54
|
+
valid&=(t>0)&(t<1)&(u>=-1e-10)&(u<=1+1e-10)
|
|
55
|
+
ts.extend(t[valid])
|
|
56
|
+
collinear=(~(abs(den)>1e-12))&(abs(offset[:,0]*d[1]-offset[:,1]*d[0])<1e-10)
|
|
57
|
+
for endpoints in [a[collinear],b[collinear]]:
|
|
58
|
+
along=(endpoints-p)@d/np.dot(d,d);ts.extend(along[(along>0)&(along<1)])
|
|
59
|
+
t=np.unique(np.round(ts,12));t=t[t<1]
|
|
60
|
+
outside.extend(p[None,:]*(1-t[:,None])+q[None,:]*t[:,None])
|
|
61
|
+
inside.extend(inner[i][None,:]*(1-t[:,None])+inner[(i+1)%len(inner)][None,:]*t[:,None])
|
|
62
|
+
return np.asarray(inside),np.asarray(outside)
|
|
63
|
+
|
|
64
|
+
def sample_hit(origin,direction,points,triangles,uv,rgb,normals):
|
|
65
|
+
a=points[triangles[:,0]];e1=points[triangles[:,1]]-a;e2=points[triangles[:,2]]-a
|
|
66
|
+
h=np.cross(direction,e2);det=(e1*h).sum(1);safe=np.where(abs(det)>1e-12,det,1)
|
|
67
|
+
s=origin-a;u=(s*h).sum(1)/safe;q=np.cross(s,e1);v=(q*direction).sum(1)/safe;t=(e2*q).sum(1)/safe
|
|
68
|
+
valid=(abs(det)>1e-12)&(u>=0)&(v>=0)&(u+v<=1)&(t>0)
|
|
69
|
+
if not valid.any():raise ValueError('Authored outer lid contour misses the source surface')
|
|
70
|
+
index=np.argmin(np.where(valid,t,np.inf));tex=np.array([1-u[index]-v[index],u[index],v[index]])@uv[triangles[index]]
|
|
71
|
+
x=np.clip(tex[0]*rgb.shape[1]-.5,0,rgb.shape[1]-1);y=np.clip(tex[1]*rgb.shape[0]-.5,0,rgb.shape[0]-1)
|
|
72
|
+
ix,iy=int(x),int(y);jx,jy=min(ix+1,rgb.shape[1]-1),min(iy+1,rgb.shape[0]-1)
|
|
73
|
+
c=(rgb[iy,ix]*(1-(x-ix))+rgb[iy,jx]*(x-ix))*(1-(y-iy))+(rgb[jy,ix]*(1-(x-ix))+rgb[jy,jx]*(x-ix))*(y-iy)
|
|
74
|
+
c=c/255;c=np.where(c<=.04045,c/12.92,((c+.055)/1.055)**2.4)
|
|
75
|
+
normal=np.array([1-u[index]-v[index],u[index],v[index]])@normals[triangles[index]]
|
|
76
|
+
normal/=max(np.linalg.norm(normal),1e-12)
|
|
77
|
+
return origin+direction*t[index],c,normal
|
|
78
|
+
|
|
79
|
+
def fair_lid_depth(vertices,faces,forward,boundary):
|
|
80
|
+
"""Harmonic depth fairing on the projected mesh, with both rims fixed."""
|
|
81
|
+
from scipy.sparse import coo_matrix,diags
|
|
82
|
+
from scipy.sparse.linalg import spsolve
|
|
83
|
+
vertices=np.asarray(vertices);depth=vertices@forward
|
|
84
|
+
xy=vertices-depth[:,None]*forward
|
|
85
|
+
edges=np.unique(np.sort(np.concatenate([faces[:,[0,1]],faces[:,[1,2]],faces[:,[2,0]]]),axis=1),axis=0)
|
|
86
|
+
length=np.linalg.norm(xy[edges[:,0]]-xy[edges[:,1]],axis=1)
|
|
87
|
+
weight=1/np.maximum(length,1e-10)
|
|
88
|
+
row=np.r_[edges[:,0],edges[:,1]];col=np.r_[edges[:,1],edges[:,0]]
|
|
89
|
+
adjacency=coo_matrix((np.r_[weight,weight],(row,col)),shape=(len(vertices),len(vertices))).tocsr()
|
|
90
|
+
lap=diags(np.asarray(adjacency.sum(1)).ravel())-adjacency
|
|
91
|
+
free=np.flatnonzero(~boundary);fixed=np.flatnonzero(boundary)
|
|
92
|
+
fitted=depth.copy();fitted[free]=spsolve(lap[free][:,free],-lap[free][:,fixed]@depth[fixed])
|
|
93
|
+
result=vertices+(fitted-depth)[:,None]*forward;result[boundary]=vertices[boundary]
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def build_lid_band(o,outer,points,triangles,uv,rgb,inverse,origin_basis,source_normals=None,skin_samples=None,outer_edge_sampling=None,depth_fairing=False):
|
|
97
|
+
inner=np.asarray(o['contour'],float);outer=np.asarray(outer,float)
|
|
98
|
+
if outer.shape!=inner.shape:raise ValueError('Lid contours need paired authored vertices')
|
|
99
|
+
area=np.sum(outer[:,0]*np.roll(outer[:,1],-1)-outer[:,1]*np.roll(outer[:,0],-1))
|
|
100
|
+
sign=1 if area>0 else -1
|
|
101
|
+
for a,b in zip(outer,np.roll(outer,-1,axis=0)):
|
|
102
|
+
edge=b-a
|
|
103
|
+
if np.any(sign*(edge[0]*(inner[:,1]-a[1])-edge[1]*(inner[:,0]-a[0]))<0):raise ValueError('Outer lid contour must contain the optical aperture')
|
|
104
|
+
direction=o['direction'];forward=-direction;right=o['right'];up=o['up'];target=o['target'];span=o['span'];size=o['size']
|
|
105
|
+
projection=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1)
|
|
106
|
+
lo=outer.min(0)-1;hi=outer.max(0)+1;projected=projection[triangles]
|
|
107
|
+
candidates=(projected.min(1)<=hi).all(1)&(projected.max(1)>=lo).all(1)
|
|
108
|
+
candidates&=np.linalg.norm(points[triangles].mean(1)-o['center'],axis=1)<o['radius']*3
|
|
109
|
+
local_triangles=triangles[candidates]
|
|
110
|
+
if not len(local_triangles):raise ValueError('No source triangles behind authored lid band')
|
|
111
|
+
if outer_edge_sampling not in [None,'source-intersections-v1']:raise ValueError('Unsupported lid boundary sampling')
|
|
112
|
+
if source_normals is None:source_normals=surface_normals(points,triangles,surface_normal_groups(points))
|
|
113
|
+
inner,outer=sample_paired_contours(inner,outer,projection[local_triangles] if outer_edge_sampling else None)
|
|
114
|
+
edge_points=[];edge_colors=[];edge_normals=[];inside=[]
|
|
115
|
+
ray_offset=np.ptp(points,axis=0).max()*2
|
|
116
|
+
for p,q in zip(inner,outer):
|
|
117
|
+
plane=target+right*((q[0]/size)-.5)*span+up*(.5-q[1]/size)*span
|
|
118
|
+
hit,color,normal=sample_hit(plane-direction*ray_offset,direction,points,local_triangles,uv,rgb,source_normals)
|
|
119
|
+
edge_points.append(hit);edge_colors.append(color);edge_normals.append(normal)
|
|
120
|
+
plane=target+right*(p[0]/size-.5)*span+up*(.5-p[1]/size)*span
|
|
121
|
+
xy=np.array([(plane-o['center'])@right,(plane-o['center'])@up]);r=np.linalg.norm(xy)
|
|
122
|
+
depth=np.sqrt(max(0,o['radius']**2-r*r))
|
|
123
|
+
if r<o['iris']:depth=np.sqrt(o['radius']**2-o['iris']**2)+o['bulge']*max(0,1-(r/o['iris'])**2)
|
|
124
|
+
# Canthus support belongs to the aperture point, not the outer band's
|
|
125
|
+
# cheek/upper-fold sample. Reusing the latter pulls the inner corner
|
|
126
|
+
# toward a different facial depth and creates a visible ramp.
|
|
127
|
+
support_hit=hit
|
|
128
|
+
if r>o['radius']*.85:
|
|
129
|
+
support_hit,_,_=sample_hit(plane-direction*ray_offset,direction,points,local_triangles,uv,rgb,source_normals)
|
|
130
|
+
support_hit=authored_corner_support(support_hit,plane,o)
|
|
131
|
+
depth=corner_contact_depth(r,depth,(support_hit-o['center'])@forward,plane,o)
|
|
132
|
+
inside.append(o['center']+right*xy[0]+up*xy[1]+forward*(depth+.00025*o['unit']))
|
|
133
|
+
edge_points=np.asarray(edge_points);inside=np.asarray(inside);edge_colors=np.asarray(edge_colors);edge_normals=np.asarray(edge_normals)
|
|
134
|
+
inner_colors=edge_colors.copy()
|
|
135
|
+
if skin_samples is not None:
|
|
136
|
+
samples=np.asarray(skin_samples,float)
|
|
137
|
+
if samples.ndim!=2 or samples.shape[1]!=2 or len(samples)<3 or not np.isfinite(samples).all():
|
|
138
|
+
raise ValueError('Reconstructed lid skin requires at least three explicit valid donor pixels')
|
|
139
|
+
donors=[];donor_colors=[]
|
|
140
|
+
for q in samples:
|
|
141
|
+
plane=target+right*((q[0]+.5)/size-.5)*span+up*(.5-(q[1]+.5)/size)*span
|
|
142
|
+
hit,color,_=sample_hit(plane-direction*ray_offset,direction,points,triangles,uv,rgb,source_normals)
|
|
143
|
+
donors.append(hit);donor_colors.append(color)
|
|
144
|
+
distances=np.linalg.norm(inside[:,None,:]-np.asarray(donors)[None,:,:],axis=2)
|
|
145
|
+
weights=1/np.maximum(distances,1e-8)**2;weights/=weights.sum(1,keepdims=True)
|
|
146
|
+
inner_colors=weights@np.asarray(donor_colors)
|
|
147
|
+
chord=inside-edge_points
|
|
148
|
+
inner_normals=inside-o['center'];inner_normals/=np.maximum(np.linalg.norm(inner_normals,axis=1,keepdims=True),1e-12)
|
|
149
|
+
outer_tangent=chord-edge_normals*(chord*edge_normals).sum(1)[:,None]
|
|
150
|
+
inner_tangent=chord-inner_normals*(chord*inner_normals).sum(1)[:,None]
|
|
151
|
+
vertices=[];colors=[];faces=[];count=len(inner);rings=9
|
|
152
|
+
for row,t in enumerate(np.linspace(0,1,rings)):
|
|
153
|
+
# Outer boundary stays on the sampled source. The inner boundary follows
|
|
154
|
+
# the optical envelope. Existing source eye paint is not resampled inside.
|
|
155
|
+
vertices.extend((2*t**3-3*t*t+1)*edge_points+(t**3-2*t*t+t)*outer_tangent+(-2*t**3+3*t*t)*inside+(t**3-t*t)*inner_tangent);blend=t*t*(3-2*t);colors.extend(edge_colors*(1-blend)+inner_colors*blend)
|
|
156
|
+
if row:
|
|
157
|
+
for j in range(count):
|
|
158
|
+
a=(row-1)*count+j;b=(row-1)*count+(j+1)%count;faces.extend([[a,b,a+count],[b,b+count,a+count]])
|
|
159
|
+
world=np.asarray(vertices);faces=np.asarray(faces)
|
|
160
|
+
if depth_fairing:
|
|
161
|
+
# Hermite tangents can also bunch the projected columns at the canthi.
|
|
162
|
+
# Use the paired contours' straight projected strips for the solve.
|
|
163
|
+
linear=np.concatenate([edge_points*(1-t)+inside*t for t in np.linspace(0,1,rings)])
|
|
164
|
+
world=linear+((world-linear)@forward)[:,None]*forward
|
|
165
|
+
boundary=np.zeros(len(world),bool);boundary[:count]=True;boundary[-count:]=True
|
|
166
|
+
world=fair_lid_depth(world,faces,forward,boundary)
|
|
167
|
+
world,_=conform_open_lid(world,o);local=(world-origin_basis)@inverse.T
|
|
168
|
+
cross=np.cross(local[faces[:,1]]-local[faces[:,0]],local[faces[:,2]]-local[faces[:,0]])
|
|
169
|
+
if np.mean(cross@(inverse@forward))<0:faces=faces[:,::-1]
|
|
170
|
+
normals=surface_normals(local,faces,surface_normal_groups(local))
|
|
171
|
+
outer_local_normals=edge_normals@np.linalg.inv(inverse);outer_local_normals/=np.maximum(np.linalg.norm(outer_local_normals,axis=1,keepdims=True),1e-12)
|
|
172
|
+
normals[:count]=outer_local_normals
|
|
173
|
+
return local,normals,np.asarray(colors),faces
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Offline front-face atlas rasterization and landmark correspondence.
|
|
2
|
+
|
|
3
|
+
Coordinates are authoring space, not guesses made by the renderer. Rasterization
|
|
4
|
+
keeps the closest triangle and its interpolated attributes, including UV seams.
|
|
5
|
+
"""
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.ndimage import map_coordinates
|
|
8
|
+
from scipy.spatial import Delaunay
|
|
9
|
+
from scipy.interpolate import RBFInterpolator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def rasterize(points, triangles, values, bounds, size=1024):
|
|
13
|
+
lo=np.asarray(bounds[:2]);hi=np.asarray(bounds[2:])
|
|
14
|
+
xy=(points[:,:2]-lo)/(hi-lo)*(size-1)
|
|
15
|
+
image=np.zeros((size,size,values.shape[1]),np.float32)
|
|
16
|
+
depth=np.full((size,size),-np.inf,np.float32)
|
|
17
|
+
for ids in triangles:
|
|
18
|
+
t=xy[ids];d=points[ids,2]
|
|
19
|
+
if not np.isfinite(t).all():continue
|
|
20
|
+
xmin,ymin=np.maximum(np.floor(t.min(0)).astype(int),0)
|
|
21
|
+
xmax,ymax=np.minimum(np.ceil(t.max(0)).astype(int),size-1)
|
|
22
|
+
if xmax<xmin or ymax<ymin:continue
|
|
23
|
+
den=(t[1,1]-t[2,1])*(t[0,0]-t[2,0])+(t[2,0]-t[1,0])*(t[0,1]-t[2,1])
|
|
24
|
+
if abs(den)<1.e-8:continue
|
|
25
|
+
yy,xx=np.mgrid[ymin:ymax+1,xmin:xmax+1]
|
|
26
|
+
a=((t[1,1]-t[2,1])*(xx-t[2,0])+(t[2,0]-t[1,0])*(yy-t[2,1]))/den
|
|
27
|
+
b=((t[2,1]-t[0,1])*(xx-t[2,0])+(t[0,0]-t[2,0])*(yy-t[2,1]))/den
|
|
28
|
+
c=1-a-b;z=a*d[0]+b*d[1]+c*d[2]
|
|
29
|
+
dst=depth[ymin:ymax+1,xmin:xmax+1]
|
|
30
|
+
mask=(a>=-1.e-5)&(b>=-1.e-5)&(c>=-1.e-5)&(z>dst)
|
|
31
|
+
dst[mask]=z[mask]
|
|
32
|
+
field=image[ymin:ymax+1,xmin:xmax+1]
|
|
33
|
+
field[mask]=(a[...,None]*values[ids[0]]+b[...,None]*values[ids[1]]+c[...,None]*values[ids[2]])[mask]
|
|
34
|
+
return image,np.isfinite(depth)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sample(image,uv):
|
|
38
|
+
coords=np.array([uv[...,1]*(image.shape[0]-1),uv[...,0]*(image.shape[1]-1)])
|
|
39
|
+
if image.ndim==2:return map_coordinates(image,coords,order=1,mode='nearest')
|
|
40
|
+
return np.stack([map_coordinates(image[:,:,i],coords,order=1,mode='nearest') for i in range(image.shape[2])],axis=-1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register(points,target_landmarks,source_landmarks):
|
|
44
|
+
"""Smooth thin-plate anatomical correspondence; rejects points outside the hull."""
|
|
45
|
+
tri=Delaunay(target_landmarks);ids=tri.find_simplex(points)
|
|
46
|
+
landmarks=np.asarray(target_landmarks)
|
|
47
|
+
center=landmarks.mean(0);scale=np.ptp(landmarks,axis=0)
|
|
48
|
+
# Smooth first derivatives avoid shading seams at affine triangle boundaries.
|
|
49
|
+
warp=RBFInterpolator((landmarks-center)/scale,np.asarray(source_landmarks),kernel='thin_plate_spline',smoothing=0.0001)
|
|
50
|
+
mapped=np.concatenate([warp((chunk-center)/scale) for chunk in np.array_split(points,64)])
|
|
51
|
+
return mapped,ids>=0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Repair an explicitly reviewed surface patch, retaining source rig correspondence.
|
|
2
|
+
|
|
3
|
+
The agent selects the patch and a clean surrounding annulus. No defect detection runs.
|
|
4
|
+
"""
|
|
5
|
+
import hashlib,json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.ndimage import distance_transform_edt
|
|
9
|
+
from glb_arrays import accessor
|
|
10
|
+
from glb_output import append_accessor,append_texture,image_array
|
|
11
|
+
from vision_materials import source_world_points
|
|
12
|
+
from eyelid_morph import surface_normals,surface_normal_groups,transport_normals
|
|
13
|
+
from texture_repair import harmonic_fill,surface_swatch_fill
|
|
14
|
+
|
|
15
|
+
def evidence_texel(uv,shape):
|
|
16
|
+
"""Evidence uses Blender UVs; decoded image arrays have a top-left origin."""
|
|
17
|
+
return np.clip((np.array([1-uv[1],uv[0]])*np.array(shape)).astype(int),0,np.array(shape)-1)
|
|
18
|
+
|
|
19
|
+
def quadratic_patch(points,pixels,direction,center,full_radius,outer_radius,fit_ring,depth_center,depth_tolerance):
|
|
20
|
+
points=np.asarray(points,float);pixels=np.asarray(pixels,float);direction=np.asarray(direction,float)
|
|
21
|
+
if not 0<full_radius<outer_radius<fit_ring[0]<fit_ring[1]:raise ValueError('Repair and clean support annulus must be separate')
|
|
22
|
+
q=(pixels-center)/fit_ring[1];r=np.linalg.norm(pixels-center,axis=1);depth=points@direction
|
|
23
|
+
visible=abs(depth-depth_center)<depth_tolerance
|
|
24
|
+
fit=visible&(r>=fit_ring[0])&(r<=fit_ring[1])
|
|
25
|
+
if fit.sum()<30:raise ValueError('Insufficient clean surface support for authored geometry repair')
|
|
26
|
+
def basis(v):return np.stack([np.ones(len(v)),v[:,0],v[:,1],v[:,0]**2,v[:,0]*v[:,1],v[:,1]**2],1)
|
|
27
|
+
coefficients,_,rank,_=np.linalg.lstsq(basis(q[fit]),depth[fit],rcond=None)
|
|
28
|
+
if rank!=6:raise ValueError('Degenerate support annulus')
|
|
29
|
+
weight=np.clip((outer_radius-r)/(outer_radius-full_radius),0,1);weight=weight*weight*(3-2*weight);weight*=visible
|
|
30
|
+
delta=(basis(q)@coefficients-depth)*weight
|
|
31
|
+
result=points+delta[:,None]*direction
|
|
32
|
+
result[weight==0]=points[weight==0]
|
|
33
|
+
residual=basis(q[fit])@coefficients-depth[fit]
|
|
34
|
+
return result,weight,{'supportVertices':int(fit.sum()),'movedVertices':int((weight>0).sum()),'maximumDisplacement':float(abs(delta).max()),'supportResidualP95':float(np.percentile(abs(residual),95))}
|
|
35
|
+
|
|
36
|
+
def apply_geometry_repair(doc,data,profile_path,out,source_sha):
|
|
37
|
+
profile_path=Path(profile_path);profile=json.loads(profile_path.read_text())
|
|
38
|
+
if profile['sourceSha256']!=source_sha:raise ValueError('Geometry repair belongs to a different source')
|
|
39
|
+
evidence=Path(profile['evidence']);manifest=json.loads((evidence/'manifest.json').read_text())
|
|
40
|
+
if manifest['sourceSha256']!=source_sha:raise ValueError('Geometry repair evidence source mismatch')
|
|
41
|
+
view=next(v for v in manifest['views'] if v['name']==profile['view'])
|
|
42
|
+
image=evidence/(profile['view']+'.png')
|
|
43
|
+
if hashlib.sha256(image.read_bytes()).hexdigest()!=manifest['evidenceHashes'][image.name]:raise ValueError('Geometry repair evidence image changed')
|
|
44
|
+
primitives=doc['meshes'][0]['primitives'];attributes=primitives[0]['attributes']
|
|
45
|
+
if any(p['attributes']['POSITION']!=attributes['POSITION'] for p in primitives):raise ValueError('Repair requires shared source positions')
|
|
46
|
+
local=accessor(doc,data,attributes['POSITION']);world=source_world_points(doc,local)
|
|
47
|
+
source_height=float(np.ptp(world[:,2]));unit=source_height/profile['heightM'];center=(world.min(0)+world.max(0))*.5
|
|
48
|
+
direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
|
|
49
|
+
up=np.asarray(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
|
|
50
|
+
target=center+source_height*np.asarray(view.get('targetOffset',[0,0,0]));span=source_height*view['span'];size=manifest['size']
|
|
51
|
+
def project(p):return np.stack([((p-target)@right/span+.5)*size,(.5-(p-target)@up/span)*size],-1)
|
|
52
|
+
pixel_center=np.asarray(profile['centerPx'],float)
|
|
53
|
+
on_plane=target+right*(pixel_center[0]/size-.5)*span+up*(.5-pixel_center[1]/size)*span
|
|
54
|
+
faces=np.concatenate([accessor(doc,data,p['indices']).reshape(-1,3) for p in primitives])
|
|
55
|
+
from eye_assets import ray_hit
|
|
56
|
+
hit=ray_hit(on_plane-direction*source_height*2,direction,world,faces);depth_center=hit@direction
|
|
57
|
+
changed,weight,receipt=quadratic_patch(world,project(world),direction,pixel_center,profile['fullRadiusPx'],profile['outerRadiusPx'],profile['supportRingPx'],depth_center,profile['depthToleranceM']*unit)
|
|
58
|
+
if receipt['maximumDisplacement']/unit>profile['maximumDisplacementM']:raise ValueError('Geometry correction exceeds the authored displacement bound')
|
|
59
|
+
origin=source_world_points(doc,np.zeros((1,3)))[0];basis=(source_world_points(doc,np.eye(3))-origin).T
|
|
60
|
+
corrected=local+(changed-world)@np.linalg.inv(basis).T
|
|
61
|
+
groups=surface_normal_groups(local);old_normals=accessor(doc,data,attributes['NORMAL'])
|
|
62
|
+
new_geometric=surface_normals(corrected,faces,groups)
|
|
63
|
+
normals=transport_normals(old_normals,surface_normals(local,faces,groups),new_geometric)
|
|
64
|
+
normals=normals*(1-weight[:,None])+new_geometric*weight[:,None]
|
|
65
|
+
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-12)
|
|
66
|
+
touched=np.zeros(len(local),bool);touched[np.unique(faces[np.any(weight[faces]>0,axis=1)])]=True;normals[~touched]=old_normals[~touched]
|
|
67
|
+
position_index=append_accessor(doc,data,corrected.astype('<f4'),'VEC3',5126);normal_index=append_accessor(doc,data,normals.astype('<f4'),'VEC3',5126)
|
|
68
|
+
for p in primitives:
|
|
69
|
+
p['attributes'].setdefault('_BIND_SOURCE_POSITION',p['attributes']['POSITION'])
|
|
70
|
+
p['attributes']['POSITION']=position_index;p['attributes']['NORMAL']=normal_index
|
|
71
|
+
# Exact UV-surface positions come from the reviewed dense evidence bake.
|
|
72
|
+
surfaces=Path(profile['surfaceEvidence']);mask_report=json.loads((surfaces.parent/'report.json').read_text())
|
|
73
|
+
if mask_report['sourceSha256']!=source_sha:raise ValueError('Repair UV evidence belongs to a different source')
|
|
74
|
+
atlas=np.load(surfaces);positions=atlas['position'];occupied=atlas['occupied'];pixels=project(positions)
|
|
75
|
+
r=np.linalg.norm(pixels-pixel_center,axis=-1);near=occupied&(abs(positions@direction-depth_center)<profile['depthToleranceM']*unit)
|
|
76
|
+
distance,nearest=distance_transform_edt(~occupied,return_indices=True)
|
|
77
|
+
gutter=(~occupied)&(distance<=4)
|
|
78
|
+
r[gutter]=r[tuple(nearest[:,gutter])];near[gutter]=near[tuple(nearest[:,gutter])]
|
|
79
|
+
positions=positions.copy();positions[gutter]=positions[tuple(nearest[:,gutter])]
|
|
80
|
+
del distance,nearest,pixels
|
|
81
|
+
material=doc['materials'][primitives[0]['material']];texture=material['pbrMetallicRoughness']['baseColorTexture']['index'];color=image_array(doc,data,texture).copy()
|
|
82
|
+
if color.shape[:2]!=occupied.shape:raise ValueError('Repair atlas and base color resolution differ')
|
|
83
|
+
paint=profile['paint'];cleanup=near&(r<paint['cleanupRadiusPx'])
|
|
84
|
+
if paint.get('skinSamplesPx'):
|
|
85
|
+
uv_path=evidence/(profile['view']+'.npz')
|
|
86
|
+
if hashlib.sha256(uv_path.read_bytes()).hexdigest()!=manifest['evidenceHashes'][uv_path.name]:raise ValueError('Paint donor correspondence changed')
|
|
87
|
+
correspondence=np.load(uv_path);donors=[]
|
|
88
|
+
for x,y in paint['skinSamplesPx']:
|
|
89
|
+
if correspondence['material'][y,x]!=0:raise ValueError('Paint donor is outside the reviewed source material')
|
|
90
|
+
uv=correspondence['uv'][y,x];yx=evidence_texel(uv,occupied.shape)
|
|
91
|
+
if not occupied[tuple(yx)]:
|
|
92
|
+
region=occupied[max(0,yx[0]-2):yx[0]+3,max(0,yx[1]-2):yx[1]+3];candidates=np.argwhere(region)+np.maximum(0,yx-2)
|
|
93
|
+
if not len(candidates):raise ValueError('Paint donor misses occupied UV surface')
|
|
94
|
+
yx=candidates[np.argmin(((candidates-yx)**2).sum(1))]
|
|
95
|
+
donors.append(yx)
|
|
96
|
+
color=surface_swatch_fill(color[:,:,:3],cleanup,positions,donors,allowed_mask=near,blend_width=paint.get('blendWidthM',.001)*unit)
|
|
97
|
+
else:color=harmonic_fill(color[:,:,:3],cleanup)
|
|
98
|
+
alpha=np.clip((paint['radiusPx']+.5-r),0,1)*near
|
|
99
|
+
original=color.astype(float)/255;original=np.where(original<=.04045,original/12.92,((original+.055)/1.055)**2.4)
|
|
100
|
+
pigment=np.asarray(paint['colorSRGB'],float)/255;pigment=np.where(pigment<=.04045,pigment/12.92,((pigment+.055)/1.055)**2.4)
|
|
101
|
+
mixed=original*(1-alpha[:,:,None])+pigment*alpha[:,:,None];encoded=np.where(mixed<=.0031308,mixed*12.92,1.055*mixed**(1/2.4)-.055)
|
|
102
|
+
painted=color.copy();active=alpha>0;painted_count=int(active.sum());painted[active]=np.round(np.clip(encoded[active],0,1)*255).astype(np.uint8)
|
|
103
|
+
material['pbrMetallicRoughness']['baseColorTexture']={'index':append_texture(doc,data,painted,'geometry-repair-base-color',out)}
|
|
104
|
+
if material.get('normalTexture'):
|
|
105
|
+
normal=image_array(doc,data,material['normalTexture']['index']).copy()
|
|
106
|
+
if normal.shape[:2]!=occupied.shape:raise ValueError('Repair normal atlas resolution differs')
|
|
107
|
+
w=np.clip((profile['outerRadiusPx']-r)/(profile['outerRadiusPx']-profile['fullRadiusPx']),0,1);w=w*w*(3-2*w)*near
|
|
108
|
+
active=w>0;normal[active,:3]=np.round(normal[active,:3]*(1-w[active,None])+np.array([128,128,255])*w[active,None]).astype(np.uint8)
|
|
109
|
+
material['normalTexture']={**material['normalTexture'],'index':append_texture(doc,data,normal,'geometry-repair-normal',out)}
|
|
110
|
+
receipt.update(method='agent-authored quadratic patch from clean surrounding surface; feathered boundary',sourceSha256=source_sha,profileSha256=hashlib.sha256(profile_path.read_bytes()).hexdigest(),maximumDisplacementM=receipt.pop('maximumDisplacement')/unit,supportResidualP95M=receipt.pop('supportResidualP95')/unit,paintedTexels=painted_count,topologyAndUVsPreserved=True,sourceBindingPreserved=True)
|
|
111
|
+
(Path(out)/'geometry-repair.json').write_text(json.dumps(receipt,indent=2)+'\n')
|
|
112
|
+
return receipt
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""GLB binary array readers usable from Python and Blender without Pillow."""
|
|
2
|
+
import json,struct
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
def read(path):
|
|
6
|
+
raw=path.read_bytes();n=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+n]);off=20+n;size=struct.unpack_from('<I',raw,off)[0]
|
|
7
|
+
return doc,bytearray(raw[off+8:off+8+size])
|
|
8
|
+
|
|
9
|
+
def accessor(doc,data,index):
|
|
10
|
+
a=doc['accessors'][index];v=doc['bufferViews'][a['bufferView']]
|
|
11
|
+
dt=np.dtype({5126:'<f4',5125:'<u4',5123:'<u2',5121:'u1'}[a['componentType']]);w={'SCALAR':1,'VEC2':2,'VEC3':3,'VEC4':4,'MAT4':16}[a['type']]
|
|
12
|
+
return np.ndarray((a['count'],w),dtype=dt,buffer=data,offset=v.get('byteOffset',0)+a.get('byteOffset',0),strides=(v.get('byteStride',dt.itemsize*w),dt.itemsize)).copy()
|
|
13
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared embedded-GLB output helpers; no semantic classification."""
|
|
2
|
+
import io
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
def image_array(doc,data,texture,mode='RGB'):
|
|
7
|
+
im=doc['images'][doc['textures'][texture]['source']];v=doc['bufferViews'][im['bufferView']];start=v.get('byteOffset',0)
|
|
8
|
+
return np.array(Image.open(io.BytesIO(data[start:start+v['byteLength']])).convert(mode))
|
|
9
|
+
|
|
10
|
+
def append_view(doc,data,raw):
|
|
11
|
+
data.extend(b'\0'*((-len(data))%4));offset=len(data);data.extend(raw)
|
|
12
|
+
doc['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(raw)});return len(doc['bufferViews'])-1
|
|
13
|
+
|
|
14
|
+
def append_accessor(doc,data,values,kind,component):
|
|
15
|
+
view=append_view(doc,data,values.tobytes());doc['accessors'].append({'bufferView':view,'componentType':component,'count':len(values),'type':kind});return len(doc['accessors'])-1
|
|
16
|
+
|
|
17
|
+
def append_texture(doc,data,pixels,name,folder):
|
|
18
|
+
im=Image.fromarray(pixels.astype(np.uint8));raw=io.BytesIO();im.save(raw,format='PNG');(folder/(name+'.png')).write_bytes(raw.getvalue())
|
|
19
|
+
if not doc.get('samplers'):doc['samplers']=[{}]
|
|
20
|
+
view=append_view(doc,data,raw.getvalue());doc['images'].append({'bufferView':view,'mimeType':'image/png','name':name});doc['textures'].append({'source':len(doc['images'])-1,'sampler':0});return len(doc['textures'])-1
|
|
21
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Trace a reviewed image pixel to its UV texel and contributing authoring views.
|
|
2
|
+
|
|
3
|
+
Read-only geometry/provenance diagnostic. Never chooses or changes an anatomical label.
|
|
4
|
+
Requires a dense bake made with --surface-data.
|
|
5
|
+
"""
|
|
6
|
+
import argparse,hashlib,json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from skin_views import LABELS
|
|
11
|
+
|
|
12
|
+
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
13
|
+
|
|
14
|
+
def texel_footprint(positions,occupied,row,col,source_height,physical_height):
|
|
15
|
+
if not np.isfinite(physical_height) or physical_height<=0:raise ValueError('Physical height must be positive and finite')
|
|
16
|
+
if not np.isfinite(source_height) or source_height<=0:raise ValueError('Source height must be positive and finite')
|
|
17
|
+
result=[];h,w=occupied.shape
|
|
18
|
+
for dx,dy in [(-1,0),(1,0),(0,-1),(0,1)]:
|
|
19
|
+
x=col+dx;y=row+dy
|
|
20
|
+
if 0<=x<w and 0<=y<h and occupied[y,x]:
|
|
21
|
+
result.append({'atlasOffset':[dx,dy],'distanceM':float(np.linalg.norm(positions[y,x]-positions[row,col])*physical_height/source_height)})
|
|
22
|
+
return {'physicalHeightM':physical_height,'neighbours':result,'interpretation':'Local occupied-texel spacing, not guaranteed detail resolution. UV discontinuities can make a neighbour distant; inspect individual values.'}
|
|
23
|
+
|
|
24
|
+
def inspect(evidence,masks,view_name,x,y,physical_height=None):
|
|
25
|
+
evidence=Path(evidence);masks=Path(masks)
|
|
26
|
+
manifest=json.loads((evidence/'manifest.json').read_text())
|
|
27
|
+
report=json.loads((masks/'report.json').read_text())
|
|
28
|
+
archived=json.loads((masks/'dense-request.json').read_text())
|
|
29
|
+
for key in ['sourceSha256','size','views','materials','worldBounds']:
|
|
30
|
+
if archived.get(key)!=manifest.get(key):raise ValueError('Bake/evidence mismatch: '+key)
|
|
31
|
+
if report['sourceSha256']!=manifest['sourceSha256'] or digest(Path(manifest['source']))!=manifest['sourceSha256']:
|
|
32
|
+
raise ValueError('Source identity changed')
|
|
33
|
+
if digest(masks/'annotations.json')!=report['annotationSha256']:raise ValueError('Baked annotations changed')
|
|
34
|
+
if view_name not in [v['name'] for v in manifest['views']]:raise ValueError('Unknown evidence view')
|
|
35
|
+
if not 0<=x<manifest['size'] or not 0<=y<manifest['size']:raise ValueError('Pixel outside evidence image')
|
|
36
|
+
for ext in ['.png','.npz']:
|
|
37
|
+
name=view_name+ext
|
|
38
|
+
if digest(evidence/name)!=manifest['evidenceHashes'][name]:raise ValueError('Evidence changed: '+name)
|
|
39
|
+
with np.load(evidence/(view_name+'.npz')) as data:
|
|
40
|
+
material=int(data['material'][y,x]);uv=data['uv'][y,x]
|
|
41
|
+
if material<0 or not np.isfinite(uv).all() or (uv<0).any() or (uv>1).any():raise ValueError('Pixel has no supported source UV surface')
|
|
42
|
+
labels=np.asarray(Image.open(masks/f'material-{material}-labels.png'));h,w=labels.shape
|
|
43
|
+
col=min(int(uv[0]*w),w-1);row=min(int((1-uv[1])*h),h-1)
|
|
44
|
+
footprint=None
|
|
45
|
+
with np.load(masks/f'material-{material}-surface.npz') as surface:
|
|
46
|
+
occupied=surface['occupied'];positions=surface['position']
|
|
47
|
+
if not occupied[row,col]:raise ValueError('Selected UV texel is unoccupied')
|
|
48
|
+
point=positions[row,col];normal=surface['normal'][row,col]
|
|
49
|
+
visible=int(surface['visibilityBits'][row,col]);voted=int(surface['viewBits'][row,col])
|
|
50
|
+
if physical_height is not None:footprint=texel_footprint(positions,occupied,row,col,np.ptp(np.asarray(manifest['worldBounds'])[:,2]),physical_height)
|
|
51
|
+
conflicts=np.asarray(Image.open(masks/f'material-{material}-conflicts.png'))
|
|
52
|
+
bounds=np.asarray(manifest['worldBounds']);height=bounds[1,2]-bounds[0,2];center=bounds.mean(0);names={v:k for k,v in LABELS.items()};contributors=[]
|
|
53
|
+
for index,view in enumerate(manifest['views']):
|
|
54
|
+
if not visible&(1<<index):continue
|
|
55
|
+
direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
|
|
56
|
+
up=np.asarray(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
|
|
57
|
+
target=center+height*np.asarray(view.get('targetOffset',[0,0,0]));span=height*view.get('span',1.1)
|
|
58
|
+
px=int(np.floor(((point-target)@right/span+.5)*manifest['size']));py=int(np.floor((.5-(point-target)@up/span)*manifest['size']))
|
|
59
|
+
if not 0<=px<manifest['size'] or not 0<=py<manifest['size']:raise ValueError('Visibility bit projects outside its archived camera')
|
|
60
|
+
label_file=masks/(view['name']+'-annotation.npy');kind=int(np.load(label_file)[py,px]) if label_file.exists() else 0
|
|
61
|
+
contributors.append({'view':view['name'],'pixel':[px,py],'label':names[kind],'facing':float(abs(normal@direction)),'contributed':bool(voted&(1<<index))})
|
|
62
|
+
return {'sourceSha256':manifest['sourceSha256'],'annotationSha256':report['annotationSha256'],'view':view_name,'pixel':[x,y],'material':material,'atlasPixel':[col,row],'label':names[int(labels[row,col])],'conflict':bool(conflicts[row,col]),'contributors':contributors,'texelFootprint':footprint,'decision':'No semantic decision made; inspect the listed source pixels before editing annotations.'}
|
|
63
|
+
|
|
64
|
+
if __name__=='__main__':
|
|
65
|
+
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--evidence',required=True,type=Path);parser.add_argument('--masks',required=True,type=Path);parser.add_argument('--view',required=True);parser.add_argument('--x',required=True,type=int);parser.add_argument('--y',required=True,type=int);parser.add_argument('--height-m',type=float,help='Known authored physical height, for local texel-spacing diagnostics');a=parser.parse_args()
|
|
66
|
+
print(json.dumps(inspect(a.evidence,a.masks,a.view,a.x,a.y,a.height_m),indent=2))
|