Replace TownSquare with HomeCamp art and land humanoid bots, LittleBear, and LmGirl review assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-11-03 20:33:08 +08:00
co-authored by Cursor
parent 45e62132c8
commit 9eec364f30
838 changed files with 57978 additions and 12225 deletions
+20
View File
@@ -21,6 +21,11 @@
## FBX 轴向约定
项目标准为 Y-up、底面中心原点、**局部 +Z 为正面**,配置见 `Config/Art/ModelConvention.json`。
朝向直接使用 Unity `transform.forward` / `Quaternion.LookRotation(direction)`。
`axis_forward="-Z"` 是 Blender FBX 坐标转换参数,继续保留;它不等于角色解剖正面。
旧 -Z 资产、固定骨架和历史验收记录按自身约定兼容,不批量旋转已绑定网格或动画。
Studio 五步的 GLB 使用标准 glTF Y-up;Blender 默认导入后应在 Z-up 场景中站立。
`prepare_game_export` 为历史调用保留 Y-up 顶点缓冲;最终必须由
`export_fbx_source_yup` 在临时副本上恢复 Blender Z-up,再烘焙为 Y-up FBX。
@@ -67,3 +72,18 @@ python Tools/install_character_bundles.py -a CaoCao --kind Human -b Tools/output
| QR 引擎 | 未捆绑(无 xremesh 依赖) | 需本机 Quad Remesher 引擎 |
**结论**:AIC#Project 在「有机角色拓扑」上更优(Quad Remesher + 更深 UV 门);本仓库已并入其自适应射线、大岛门限与 XPbr 打包,并保留 GLB 输入。若本机已装 QR 引擎,可再把 `qr_bridge` / `--reducer quad` 完整迁入作为可选增强。
## 肩部黑影诊断(2026-10-27)
先用同机位拆分检查:底色无光照、关闭 Normal、关闭 AO、纯灰材质。`bl_audit_bake_quality.py --job <Studio任务目录> --output <绝对输出目录>` 在 Blender 中运行,可生成上述对比。`--quick --normal-strength 0.35` 可导出减弱法线的隔离预览;它会牺牲部分细节,不代表投射已修复。
已用 `lm_bear` 任务完成受控对比,记录在 `Tools/output/bake_quality_audit/findings.json`:黑块主要由法线贴图引入,EMIT底色和不挂AO的GLB仍可排除相应通道。仅取消烘焙后的向外法线偏置不足以消除问题;将cage从0.06014m缩到0.007156m改善肩部/腿间,但增加头发细节漏投射。因此不能仅凭包围盒固定比例或全局P99距离决定最终参数。
推荐的生产改进(本轮未修改共享后端):
- 烘焙前固定最终三角化、UV和角点法线,烘焙后保持一致;如第五步减面改变了网格,需在最终网格原UV上补烘法线。
- 分区域cage、小范围到大范围重试,结合命中距离、法线方向和部位对应关系选择结果;肩/领巾/头发等复杂区域可用匹配分件或手调cage。
- 生成独立UV覆盖/投射命中mask;不要把黑色底色、零金属度当作未命中。单位长度合格的法线也可能投射到了错误表面。
- 可为卡通预览提供Normal Strength约0.3~0.5的可调回退,并保留原贴图。
投射试验入口 `bl_trial_studio_bake.py --job <任务目录> --output <绝对输出目录> --ray-mode legacy|adaptive`:只在独立目录执行当前共享烘焙器的受控副本,保留源网格与Studio UV。Blender烘焙设置说明:[Render Baking](https://docs.blender.org/manual/en/5.0/render/cycles/baking.html)。
+171
View File
@@ -0,0 +1,171 @@
"""Read-only shading audit for a Studio/bake job. Blender -- --job DIR --output DIR."""
import argparse
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Vector
p = argparse.ArgumentParser()
p.add_argument('--job', required=True)
p.add_argument('--output', required=True)
p.add_argument('--low-model')
p.add_argument('--quick', action='store_true')
p.add_argument('--normal-strength', type=float)
args = p.parse_args(sys.argv[sys.argv.index('--') + 1:])
job, out = Path(args.job).resolve(), Path(args.output).resolve()
out.mkdir(parents=True, exist_ok=True)
def load(path):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(path))
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
assert len(meshes) == 1
obj = meshes[0]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return obj
def describe(obj):
result = []
for mat in obj.data.materials:
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
channels = {}
if bsdf:
for name in ['Base Color', 'Metallic', 'Roughness', 'Normal']:
socket = bsdf.inputs[name]
channels[name] = dict(linked=socket.is_linked,
source=socket.links[0].from_node.type if socket.is_linked else None)
result.append(dict(name=mat.name, channels=channels,
images=[dict(name=n.image.name, size=list(n.image.size), colorspace=n.image.colorspace_settings.name)
for n in mat.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image]))
return result
def setup(obj):
pts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
lo, hi = pts.min(0), pts.max(0)
center = Vector((lo + hi) / 2)
h = hi[2] - lo[2]
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 16
scene.cycles.use_denoising = True
scene.render.resolution_x = 800
scene.render.resolution_y = 800
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.world = bpy.data.worlds.new('AuditWorld')
scene.world.use_nodes = True
scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.4, .4, .4, 1)
scene.world.node_tree.nodes['Background'].inputs['Strength'].default_value = .6
for name, xyz, power, size in [('Key', (-2, 3, 3), 180, 2), ('Fill', (2, -2, 1.5), 100, 2)]:
data = bpy.data.lights.new(name, 'AREA'); data.energy = power; data.shape = 'DISK'; data.size = size*h
light = bpy.data.objects.new(name, data); scene.collection.objects.link(light)
light.location = center + Vector(xyz)*h
light.rotation_euler = (center-light.location).to_track_quat('-Z', 'Y').to_euler()
camera = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
scene.collection.objects.link(camera); scene.camera = camera
camera.data.type = 'ORTHO'; camera.data.ortho_scale = max(h*1.17, (hi[0]-lo[0])*1.17)
camera.location = center + Vector((1.7, 3, 1))*h
camera.rotation_euler = (center-camera.location).to_track_quat('-Z', 'Y').to_euler()
scene.view_settings.view_transform = 'Standard'
def capture(name):
bpy.context.scene.render.filepath = str(out / (name+'.png'))
bpy.ops.render.render(write_still=True)
def normal_quality(obj):
node = next((n for mat in obj.data.materials for n in mat.node_tree.nodes
if n.type == 'TEX_IMAGE' and n.image and n.image.name.startswith('normal')), None)
if node is None:
return {}
image = node.image
w, h = image.size[:]
pixels = np.empty(w*h*4, dtype=np.float32)
image.pixels.foreach_get(pixels)
pixels = pixels.reshape(h, w, 4)
coverage = np.zeros((h, w), dtype=bool)
uv_data = obj.data.uv_layers.active.data
obj.data.calc_loop_triangles()
corners = []
for tri in obj.data.loop_triangles:
uvs = np.array([uv_data[i].uv[:] for i in tri.loops])
for vi, uv in zip(tri.vertices, uvs):
corners.append(tuple(np.round(list(obj.data.vertices[vi].co)+list(uv), 6)))
x, y = uvs[:, 0]*w-.5, uvs[:, 1]*h-.5
x0, x1 = max(0, int(np.floor(x.min()))), min(w-1, int(np.ceil(x.max())))
y0, y1 = max(0, int(np.floor(y.min()))), min(h-1, int(np.ceil(y.max())))
den = (y[1]-y[2])*(x[0]-x[2])+(x[2]-x[1])*(y[0]-y[2])
if abs(den)<1e-8 or x1<x0 or y1<y0:
continue
xx, yy = np.meshgrid(np.arange(x0,x1+1),np.arange(y0,y1+1))
a=((y[1]-y[2])*(xx-x[2])+(x[2]-x[1])*(yy-y[2]))/den
b=((y[2]-y[0])*(xx-x[2])+(x[0]-x[2])*(yy-y[2]))/den
coverage[y0:y1+1,x0:x1+1] |= (a>=0)&(b>=0)&(a+b<=1)
length = np.linalg.norm(pixels[:,:,:3]*2-1, axis=2)
invalid = coverage & ((length < .85) | (length > 1.15))
return dict(uvCoveredPixels=int(coverage.sum()),
invalidNormalDirectionPixels=int(invalid.sum()),
invalidNormalDirectionPercent=float(invalid.sum()/max(1,coverage.sum())*100),
nearlyBlackNormalPixels=int((coverage & (pixels[:,:,:3].max(axis=2)<.03)).sum()),
oppositeHemispherePixels=int((coverage & (pixels[:,:,2]<.5)).sum()),
geometryUvFingerprint=hashlib.sha256(np.array(sorted(corners),dtype='<f4').tobytes()).hexdigest())
report = {}
items = [('low', Path(args.low_model).resolve() if args.low_model else job/'model_baked.glb')]
if not args.quick:
items.insert(0,('high',job/'model.glb'))
for tag, filename in items:
obj = load(filename)
if args.normal_strength is not None and tag=='low':
for mat in obj.data.materials:
for node in mat.node_tree.nodes:
if node.type=='NORMAL_MAP':
node.inputs['Strength'].default_value=args.normal_strength
report[tag] = dict(triangles=sum(len(poly.vertices)-2 for poly in obj.data.polygons), materials=describe(obj),
normalQuality=normal_quality(obj) if tag=='low' else None)
setup(obj)
capture(tag+'_lit_full')
if args.quick:
if args.normal_strength is not None:
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True)
bpy.context.view_layer.objects.active=obj
bpy.ops.export_scene.gltf(filepath=str(out/'normal_strength_preview.glb'),export_format='GLB',use_selection=True,export_animations=False)
continue
if tag == 'low':
for mat in obj.data.materials:
nt = mat.node_tree; bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
for link in list(bsdf.inputs['Normal'].links): nt.links.remove(link)
capture('low_without_normal')
for mat in obj.data.materials:
nt=mat.node_tree; bsdf=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
output=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL')
emit=nt.nodes.new('ShaderNodeEmission')
base=bsdf.inputs['Base Color']
if base.is_linked:nt.links.new(base.links[0].from_socket,emit.inputs['Color'])
else:emit.inputs['Color'].default_value=base.default_value
for link in list(output.inputs['Surface'].links):nt.links.remove(link)
nt.links.new(emit.outputs['Emission'],output.inputs['Surface'])
capture('low_albedo_unlit')
# Plain material isolates the exported geometric/corner normals.
mat=bpy.data.materials.new('Clay');mat.use_nodes=True
bsdf=mat.node_tree.nodes.get('Principled BSDF');bsdf.inputs['Base Color'].default_value=(.5,.5,.5,1);bsdf.inputs['Roughness'].default_value=.8
obj.data.materials.clear();obj.data.materials.append(mat)
capture('low_clay_exported_normals')
try:bpy.ops.mesh.customdata_custom_splitnormals_clear()
except Exception:pass
for poly in obj.data.polygons:poly.use_smooth=True
capture('low_clay_recomputed_normals')
(out/'audit.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('AUDIT_DONE',out)
+5 -2
View File
@@ -8,6 +8,8 @@ from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio'
OUT=FOLDER/'axis_pipeline_audit'
LEGACY=OUT/'legacy/LmGirlTpose.fbx'
if not LEGACY.is_file():LEGACY=FOLDER/'static/LmGirlTpose.fbx'
def inspect(stage,path):
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -34,10 +36,11 @@ def inspect(stage,path):
stages=[('01 Hunyuan high',FOLDER/'model.glb'),('02 Studio low',FOLDER/'model_lowpoly.glb'),
('03 Studio UV',FOLDER/'model_lowpoly_uv.glb'),('03b alignment',FOLDER/'model_lowpoly_uv_aligned.glb'),
('04 bake',FOLDER/'model_baked.glb'),('05 UV-preserving decimation',FOLDER/'model_decimated.glb'),
('05 final GLB',FOLDER/'model_ex.glb'),('After 05 static FBX',FOLDER/'static/LmGirlTpose.fbx'),
('05 final GLB',FOLDER/'model_ex.glb'),('Original static FBX',LEGACY),
('XPbr FBX',FOLDER/'xpbr/LmGirlTpose.fbx'),('Corrected upload FBX',FOLDER/'mixamo_upload/LmGirlTpose_Mixamo.fbx'),
('Fixed production static FBX',FOLDER/'axis_pipeline_audit/production/static/LmGirlTpose.fbx'),
('Fixed production XPbr FBX',FOLDER/'axis_pipeline_audit/production/xpbr/LmGirlTpose.fbx')]
('Fixed production XPbr FBX',FOLDER/'axis_pipeline_audit/production/xpbr/LmGirlTpose.fbx'),
('Current static FBX',FOLDER/'static/LmGirlTpose.fbx')]
OUT.mkdir(parents=True,exist_ok=True)
report=[inspect(stage,path) for stage,path in stages]
(OUT/'stages.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
@@ -0,0 +1,23 @@
"""Export unretargeted Mixamo FBX rest/animated joint positions for Unity comparison."""
import sys,json,hashlib
from pathlib import Path
import bpy
import numpy as np
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/campguide_studio/playback_audit';OUT.mkdir(parents=True,exist_ok=True)
paths=[ROOT/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx',ROOT/'AIClient/Assets/Bundles/Characters/Human/Common/Walking.fbx']
report=[]
for path in paths:
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(path),use_anim=True)
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
action=arm.animation_data.action if arm.animation_data else None
start,end=action.frame_range if action else (1,1)
rest={b.name:dict(parent=b.parent.name if b.parent else None,world=list(arm.matrix_world@b.head_local),matrix=[list(r) for r in arm.matrix_world@b.matrix_local]) for b in arm.data.bones}
samples=[]
for frame in np.linspace(start,end,32) if action else [1]:
whole=int(frame);bpy.context.scene.frame_set(whole,subframe=float(frame-whole));bpy.context.view_layer.update()
samples.append(dict(frame=float(frame),world={b.name:list(arm.matrix_world@b.head) for b in arm.pose.bones}))
entry=dict(path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),bones=len(rest),rest=rest,frame_start=float(start),frame_end=float(end),fps=bpy.context.scene.render.fps,samples=samples)
report.append(entry);print('FBX_REFERENCE',path.name,len(rest),start,end)
(OUT/'fbx_reference.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
+2 -1
View File
@@ -71,7 +71,8 @@ def prepare_game_export(obj, restore_axis=None):
restore_axis: True 强制 -90°X;False 跳过;None 则导入后物体仍带约 +90°X 才还原。
烘焙结束时顶点已是 Z-up、旋转已 apply,必须传 True。
导出后:Y-up、原点底面中点、局部 -Z 为正面(与混元/glTF 一致)。
导出后保证 Y-up、原点底面中点;项目目标为解剖正面 +Z,须独立验收。
解剖正面沿用输入。axis_forward='-Z' 是坐标转换设置,不代表人物脸部朝 -Z。
"""
import math
import bpy
@@ -0,0 +1,190 @@
"""Export the selected TokenRig GLB as a textured, skinned Unity FBX.
Run in Blender. Does not change the source rig, weights, or prior review assets.
"""
import hashlib
import json
import math
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / 'Tools/output/lmgirl_tpose_studio/rig_mixamo41/lmgirl_local.glb'
STATIC = ROOT / 'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
OUT = SOURCE.parent / 'fbx'
FBX = OUT / 'LmGirlTokenRig.fbx'
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def points(mesh):
return np.array([mesh.matrix_world @ v.co for v in mesh.data.vertices])
def nearest_error(a, b):
tree = KDTree(len(b))
for i, p in enumerate(b):
tree.insert(Vector(p), i)
tree.balance()
return max(tree.find(Vector(p))[2] for p in a)
def snapshot(arm, mesh):
mesh.data.calc_loop_triangles()
return {
'points': points(mesh),
'uv': np.array([list(d.uv) for d in mesh.data.uv_layers.active.data]),
'triangles': len(mesh.data.loop_triangles),
'bones': {b.name: {'parent': b.parent.name if b.parent else None,
'matrix': np.array(arm.matrix_world @ b.matrix_local)}
for b in arm.data.bones},
'weights': [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-8}
for v in mesh.data.vertices],
}
def make_material(mesh):
mat = bpy.data.materials.new('LmGirlTokenRig_Body')
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get('Principled BSDF')
images = {}
for suffix, socket in [('color', 'Base Color'), ('roughness', 'Roughness'), ('metallic', 'Metallic'), ('normal', None)]:
path = STATIC.parent / ('LmGirlTpose_' + suffix + '.png')
image = bpy.data.images.load(str(path), check_existing=True)
if suffix != 'color':
image.colorspace_settings.name = 'Non-Color'
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
node.image = image
if suffix == 'normal':
normal = mat.node_tree.nodes.new('ShaderNodeNormalMap')
mat.node_tree.links.new(node.outputs['Color'], normal.inputs['Color'])
mat.node_tree.links.new(normal.outputs['Normal'], bsdf.inputs['Normal'])
else:
mat.node_tree.links.new(node.outputs['Color'], bsdf.inputs[socket])
images[suffix] = str(path.relative_to(ROOT))
mesh.data.materials.clear()
mesh.data.materials.append(mat)
return images
def main():
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(SOURCE))
arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
arm.animation_data_clear()
assert len(arm.data.bones) == 41
original = snapshot(arm, mesh)
print('SOURCE', mesh.name, len(original['points']), original['triangles'],
original['points'].min(0), original['points'].max(0), 'ARM_MATRIX', arm.matrix_world)
# The current static FBX has independently reviewed anatomical +Z front.
# Match its complete imported geometry rather than infer direction from metadata.
existing = set(bpy.context.scene.objects)
bpy.ops.import_scene.fbx(filepath=str(STATIC))
reference = next(o for o in bpy.context.scene.objects if o not in existing and o.type == 'MESH')
ref = points(reference)
candidates = []
for degrees in [0, 180]:
rotate = Matrix.Rotation(math.radians(degrees), 4, 'Z')
candidate = np.array([rotate @ Vector(p) for p in original['points']])
candidates.append((nearest_error(candidate, ref), degrees, rotate))
error, degrees, conversion = min(candidates, key=lambda item: item[0])
if error > 1e-5:
raise RuntimeError('Cannot match reviewed static geometry: ' + str([(e, d) for e, d, _ in candidates]))
for obj in list(bpy.context.scene.objects):
if obj not in existing:
bpy.data.objects.remove(obj, do_unlink=True)
converted = np.array([conversion @ Vector(p) for p in original['points']])
lo, hi = converted.min(0), converted.max(0)
shift = Vector((-(lo[0]+hi[0])/2, -(lo[1]+hi[1])/2, -lo[2]))
conversion = Matrix.Translation(shift) @ conversion
matrices = {o: conversion @ o.matrix_world for o in [arm, mesh]}
mesh.parent = None
for obj, matrix in matrices.items():
obj.matrix_world = matrix
for obj in [arm, mesh]:
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
arm.name = 'LmGirlTokenRig'
mesh.name = 'LmGirlTokenRigMesh'
texture_paths = make_material(mesh)
bpy.context.view_layer.update()
expected = snapshot(arm, mesh)
assert max(abs(sum(row.values())-1) for row in expected['weights']) < 1e-5
assert max(len(row) for row in expected['weights']) <= 4
bpy.ops.object.select_all(action='DESELECT')
for obj in [arm, mesh]:
obj.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.fbx(
filepath=str(FBX), use_selection=True, object_types={'ARMATURE', 'MESH'},
axis_forward='-Z', axis_up='Y', global_scale=1,
apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL',
bake_space_transform=False, add_leaf_bones=False, use_armature_deform_only=False,
armature_nodetype='NULL', bake_anim=False, mesh_smooth_type='OFF',
use_mesh_modifiers=False, path_mode='COPY', embed_textures=True)
# Reimport through Blender's FBX importer, independently of the GLB importer.
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(FBX))
actual_arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
actual_mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
actual = snapshot(actual_arm, actual_mesh)
assert actual['triangles'] == original['triangles'] == 4999
assert set(actual['bones']) == set(expected['bones'])
assert all(actual['bones'][n]['parent'] == expected['bones'][n]['parent'] for n in actual['bones'])
# FBX may split vertices at corners. Match geometry+UV+weights using nearest positions.
tree = KDTree(len(expected['points']))
for i, p in enumerate(expected['points']):
tree.insert(Vector(p), i)
tree.balance()
max_geometry_error = 0.
max_weight_error = 0.
mapping = []
for p, row in zip(actual['points'], actual['weights']):
_, index, distance = tree.find(Vector(p))
mapping.append(index)
max_geometry_error = max(max_geometry_error, distance)
source_row = expected['weights'][index]
max_weight_error = max(max_weight_error, max(abs(row.get(n, 0)-source_row.get(n, 0)) for n in set(row)|set(source_row)))
max_bone_head_error = max(float(np.linalg.norm(actual['bones'][n]['matrix'][:3,3]-expected['bones'][n]['matrix'][:3,3])) for n in actual['bones'])
# UV distributions are independent of corner ordering. Quantization allows FBX float rounding.
uv_reference = sorted(map(tuple, np.round(expected['uv'], 5)))
uv_roundtrip = sorted(map(tuple, np.round(actual['uv'], 5)))
uv_preserved = uv_reference == uv_roundtrip
assert max_geometry_error < 1e-5, max_geometry_error
assert max_weight_error < 1e-5, max_weight_error
assert max_bone_head_error < 1e-5, max_bone_head_error
assert uv_preserved
lo, hi = actual['points'].min(0), actual['points'].max(0)
report = {
'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': sha(SOURCE),
'fbx': str(FBX.relative_to(ROOT)), 'fbx_sha256': sha(FBX),
'variant': 'TokenRig local constraints, 41 bones, top4; no website weights',
'sceneUp': '+Y', 'anatomicalFront': '+Z', 'unit': 'meter',
'conversionAboutBlenderZDegrees': degrees, 'originShiftBlender': list(shift),
'geometryMatchToReviewedStaticM': error, 'textures': texture_paths,
'sourceVertices': len(original['points']), 'roundtripVertices': len(actual['points']),
'triangles': actual['triangles'], 'bones': len(actual['bones']),
'maxInfluences': max(len(row) for row in actual['weights']),
'roundtripGeometryErrorM': max_geometry_error, 'roundtripWeightError': max_weight_error,
'roundtripBoneHeadErrorM': max_bone_head_error, 'uvPreserved': uv_preserved,
'roundtripBoundsBlender': [lo.tolist(), hi.tolist()],
'animationIncluded': False,
'notes': 'Blender workspace is Z-up. Standard skinned FBX axis conversion is retained on the armature node; verify full imported transforms, not raw mesh coordinates alone.'}
(OUT/'export_verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('EXPORTED', json.dumps(report))
if __name__ == '__main__':
main()
@@ -11,6 +11,8 @@ from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
SOURCE=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
LEGACY=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/legacy/LmGirlTpose.fbx'
if LEGACY.is_file():SOURCE=LEGACY
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
TARGET=OUT/'LmGirlTpose_Mixamo.fbx'
@@ -0,0 +1,39 @@
"""Read-only comparison of static and Mixamo FBX UVs/materials/textures."""
import json,hashlib
from pathlib import Path
import bpy
import numpy as np
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/material_audit'
OUT.mkdir(parents=True,exist_ok=True)
def inspect(path):
tree,_=parse_fbx.parse(str(path))
objects=next(e for e in tree.elems if e.id==b'Objects')
raw=[]
for e in objects.elems:
if e.id not in (b'Material',b'Texture',b'Video',b'Geometry'):continue
item=dict(type=e.id.decode(),name=str(e.props[1]))
if e.id==b'Geometry':item['uv_layers']=[dict(name=str(x.id),children=[str(v.id) for v in x.elems]) for x in e.elems if x.id==b'LayerElementUV']
if e.id in (b'Texture',b'Video'):item['files']=[str(x.props) for x in e.elems if x.id in (b'FileName',b'RelativeFilename',b'Filename')];item['embedded_bytes']=sum(len(x.props[0]) for x in e.elems if x.id==b'Content' and x.props and isinstance(x.props[0],bytes))
raw.append(item)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
meshes=[];uv_values=[]
for obj in bpy.context.scene.objects:
if obj.type!='MESH':continue
layers=[]
for layer in obj.data.uv_layers:
values=np.array([tuple(v.uv) for v in layer.data],dtype='<f4')
if layer==obj.data.uv_layers.active:uv_values.extend(values.tolist())
layers.append(dict(name=layer.name,corners=len(values),min=values.min(0).tolist(),max=values.max(0).tolist(),unique=len(np.unique(values,axis=0)),sha256=hashlib.sha256(values.tobytes()).hexdigest()))
meshes.append(dict(name=obj.name,vertices=len(obj.data.vertices),triangles=sum(len(p.vertices)-2 for p in obj.data.polygons),uv=layers,
material_slots=[dict(name=s.material.name if s.material else None,images=[dict(name=n.image.name,path=n.image.filepath) for n in s.material.node_tree.nodes if n.type=='TEX_IMAGE' and n.image] if s.material and s.material.use_nodes else []) for s in obj.material_slots]))
return dict(path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),raw=raw,meshes=meshes),np.array(sorted(tuple(np.round(v,6)) for v in uv_values))
source,source_uv=inspect(ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx')
mixamo,mixamo_uv=inspect(ROOT/'AIClient/Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx')
report=dict(source=source,mixamo=mixamo,uv_corner_multiset_equal=source_uv.shape==mixamo_uv.shape and bool(np.array_equal(source_uv,mixamo_uv)))
(OUT/'fbx_materials.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
+373
View File
@@ -0,0 +1,373 @@
"""LittleBear fixed Mixamo-41 fitting, TokenRig weight restoration and FBX export.
Blender: --python this_file -- inspect|prepare|restore|render
The fitted skeleton is frozen before inference. No reference weights are used.
"""
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
ROOT = Path(__file__).resolve().parents[2]
BASE = ROOT / "Tools/output/littlebear_studio"
OUT = BASE / "rig_mixamo41"
STATIC = BASE / "static/LittleBear.fbx"
REFERENCE = ROOT / "AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx"
HEIGHT = 1.2
def write(name, value):
OUT.mkdir(parents=True, exist_ok=True)
(OUT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
def points(mesh):
return np.array([mesh.matrix_world @ v.co for v in mesh.data.vertices])
def activate(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
def load_static():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(STATIC), use_anim=False)
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
activate(mesh)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
p = points(mesh)
if int(np.argmax(np.ptp(p, axis=0))) != 2:
raise ValueError("Static FBX must reimport upright in Blender Z-up")
mesh.scale = (HEIGHT / np.ptp(p[:, 2]),) * 3
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
p = points(mesh)
mesh.location -= Vector(((p[:, 0].min() + p[:, 0].max()) / 2,
(p[:, 1].min() + p[:, 1].max()) / 2, p[:, 2].min()))
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return mesh
def material(mesh):
mat = bpy.data.materials.new("LittleBear_Body")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
for suffix, socket in [("color", "Base Color"), ("roughness", "Roughness"), ("metallic", "Metallic"), ("normal", None)]:
image = bpy.data.images.load(str(BASE / "static" / ("LittleBear_" + suffix + ".png")), check_existing=True)
if suffix != "color":
image.colorspace_settings.name = "Non-Color"
node = mat.node_tree.nodes.new("ShaderNodeTexImage")
node.image = image
if socket:
mat.node_tree.links.new(node.outputs["Color"], bsdf.inputs[socket])
else:
normal = mat.node_tree.nodes.new("ShaderNodeNormalMap")
mat.node_tree.links.new(node.outputs["Color"], normal.inputs["Color"])
mat.node_tree.links.new(normal.outputs["Normal"], bsdf.inputs["Normal"])
if suffix == "color":
mat.node_tree.nodes.active = node
mesh.data.materials.clear()
mesh.data.materials.append(mat)
def render(mesh, tag, assign_material=True):
if assign_material:
material(mesh)
p = points(mesh)
center = Vector(((p[:, 0].min() + p[:, 0].max()) / 2,
(p[:, 1].min() + p[:, 1].max()) / 2,
(p[:, 2].min() + p[:, 2].max()) / 2))
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.resolution_x = 900
scene.render.resolution_y = 1000
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
shading = scene.display.shading
shading.light = "STUDIO"
shading.color_type = "TEXTURE"
shading.show_shadows = True
shading.show_cavity = True
shading.background_type = "WORLD"
scene.world = bpy.data.worlds.new("BearReviewWorld")
scene.world.color = (.78, .78, .78)
camera = bpy.data.objects.new("ReviewCamera", bpy.data.cameras.new("ReviewCamera"))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = "ORTHO"
camera.data.ortho_scale = max(HEIGHT * 1.15, np.ptp(p[:, 0]) * 1000 / 900 * 1.1)
for label, offset in [("front", (0, -3, 0)), ("back", (0, 3, 0)), ("three_quarter", (2, -3, .4))]:
camera.location = center + Vector(offset)
camera.rotation_euler = (center - camera.location).to_track_quat("-Z", "Y").to_euler()
scene.render.filepath = str(OUT / (tag + "_" + label + ".png"))
bpy.ops.render.render(write_still=True)
def inspect():
mesh = load_static()
p = points(mesh)
bands = []
for z in np.linspace(.02, .98, 49):
a = p[abs(p[:, 2] / HEIGHT - z) < .012]
if len(a):
bands.append(dict(z=float(z), count=len(a), min=a.min(0).tolist(), max=a.max(0).tolist()))
write("mesh_inspection.json", dict(bounds=[p.min(0).tolist(), p.max(0).tolist()], bands=bands))
render(mesh, "static")
def inspect_high():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT / "Tools/output/littlebear_3d/littlebear.glb"))
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
activate(mesh)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
p = points(mesh)
mesh.scale = (HEIGHT / np.ptp(p[:, 2]),) * 3
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
for slot in mesh.material_slots:
mat = slot.material
if mat and mat.use_nodes:
for node in mat.node_tree.nodes:
if node.type == "TEX_IMAGE" and any(link.to_socket.name == "Base Color" for link in node.outputs["Color"].links):
mat.node_tree.nodes.active = node
break
render(mesh, "high", assign_material=False)
def prepare():
mesh = load_static()
p = points(mesh)
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(REFERENCE), use_anim=False)
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
for o in list(bpy.data.objects):
if o not in (mesh, arm):
bpy.data.objects.remove(o, do_unlink=True)
arm.animation_data_clear()
d = arm.matrix_world @ arm.data.bones["mixamorig:Head"].head_local - arm.matrix_world @ arm.data.bones["mixamorig:Hips"].head_local
if abs(d.y) > abs(d.z):
arm.matrix_world = Matrix.Rotation(math.pi / 2 if d.y > 0 else -math.pi / 2, 4, "X") @ arm.matrix_world
activate(arm)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
toe = arm.data.bones["mixamorig:LeftToeBase"].head_local - arm.data.bones["mixamorig:LeftFoot"].head_local
if toe.y > 0:
arm.matrix_world = Matrix.Rotation(math.pi, 4, "Z") @ arm.matrix_world
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
assert len(arm.data.bones) == 41
# Fractions reflect this bear's inspected T-pose silhouette, not human landmarks.
positions = {"Hips": (0, 0, .385), "Spine": (0, 0, .440), "Spine1": (0, 0, .510),
"Spine2": (0, 0, .595), "Neck": (0, 0, .677), "Head": (0, 0, .723),
"HeadTop_End": (0, 0, .952)}
reach = max(abs(p[:, 0].min()), abs(p[:, 0].max())) / HEIGHT
arm_z = float(np.median(p[(abs(p[:, 0]) > HEIGHT * .28) & (abs(p[:, 0]) < HEIGHT * .36), 2])) / HEIGHT
if not .54 < arm_z < .76:
raise ValueError("Inspect bear shoulder height before fitting: " + str(arm_z))
for side, sign in [("Left", 1), ("Right", -1)]:
local = {"Shoulder": (.064, 0, arm_z), "Arm": (.146, 0, arm_z),
"ForeArm": (reach * .57, 0, arm_z), "Hand": (reach * .82, 0, arm_z),
"UpLeg": (.079, 0, .383), "Leg": (.095, -.006, .213),
"Foot": (.099, .004, .065), "ToeBase": (.099, -.070, .024),
"Toe_End": (.099, -.105, .024)}
for name, v in local.items():
positions[side + name] = (v[0] * sign, v[1], v[2])
old = {b.name: b.matrix_local.copy() for b in arm.data.bones}
template_height = old["mixamorig:HeadTop_End"].translation.z - old["mixamorig:LeftToe_End"].translation.z
for side in ["Left", "Right"]:
hand = old["mixamorig:" + side + "Hand"].translation
for name, m in old.items():
short = name.split(":")[-1]
if short.startswith(side + "Hand") and short != side + "Hand":
positions[short] = (Vector(positions[side + "Hand"]) + (m.translation - hand) * (.75 / template_height))[:]
bpy.ops.object.mode_set(mode="EDIT")
for b in arm.data.edit_bones:
b.use_connect = False
for b in arm.data.edit_bones:
delta, roll = b.tail - b.head, b.roll
b.head = Vector(positions[b.name.split(":")[-1]]) * HEIGHT
b.tail = b.head + delta
b.roll = roll
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
drift = max(old[b.name].to_quaternion().rotation_difference(b.matrix_local.to_quaternion()).angle for b in arm.data.bones)
assert drift < .001, drift
mesh.vertex_groups.clear()
for b in arm.data.bones:
mesh.vertex_groups.new(name=b.name)
mesh.vertex_groups["mixamorig:Hips"].add(list(range(len(p))), 1, "REPLACE")
for i, b in enumerate(arm.data.bones):
if b.name != "mixamorig:Hips":
mesh.vertex_groups["mixamorig:Hips"].remove([i])
mesh.vertex_groups[b.name].add([i], 1, "REPLACE")
mesh.parent = arm
mod = mesh.modifiers.new("FixedMixamoInput", "ARMATURE")
mod.object = arm
arm.data.pose_position = "REST"
mesh.data.materials.clear()
manifest = dict(character="LittleBear", height=HEIGHT, vertices=len(p),
skeleton_source=str(REFERENCE.relative_to(ROOT)),
input_skin="Non-anatomical keepalive placeholders only; all reference weights discarded",
landmarks={n: list(v) for n, v in positions.items()},
max_axis_rotation_drift_rad=drift,
bones={b.name: dict(parent=b.parent.name if b.parent else None,
head=list(arm.matrix_world @ b.head_local),
world_matrix=[list(r) for r in arm.matrix_world @ b.matrix_local]) for b in arm.data.bones})
write("input_manifest.json", manifest)
bpy.ops.wm.save_as_mainfile(filepath=str(OUT / "prepared.blend"), compress=True)
activate(mesh)
arm.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(OUT / "source.glb"), export_format="GLB", export_yup=True,
use_selection=True, export_skins=True, export_animations=False, export_def_bones=False)
print("PREPARED", len(arm.data.bones), len(p), "shoulder fraction", arm_z)
def restore():
bpy.ops.wm.open_mainfile(filepath=str(OUT / "prepared.blend"))
arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
mesh = next(o for o in bpy.data.objects if o.type == "MESH")
arm.data.pose_position = "POSE"
names = [b.name for b in arm.data.bones]
p = points(mesh)
decoded = json.loads((OUT / "decoded_weights.json").read_text())
tree = KDTree(len(decoded))
for i, row in enumerate(decoded):
x, y, z = row["position_y_up"]
tree.insert(Vector((x, -z, y)), i)
tree.balance()
weights = np.zeros((len(p), len(names)))
distances = []
for i, pos in enumerate(p):
_, j, dist = tree.find(Vector(pos))
distances.append(dist)
for n, w in decoded[j]["weights"].items():
weights[i, names.index(n)] += w
assert max(distances) < 1e-5, max(distances)
weights /= weights.sum(1, keepdims=True)
raw = weights.copy()
fit = json.loads((OUT / "input_manifest.json").read_text())
marks = fit["landmarks"]
wrist_x = abs(marks["LeftHand"][0]) * HEIGHT
head_z = marks["Head"][2] * HEIGHT
for i, pos in enumerate(p):
side = "Left" if pos[0] > 0 else "Right"
if pos[2] > head_z:
allowed = ["mixamorig:Head"]
elif abs(pos[0]) > wrist_x:
allowed = ["mixamorig:" + side + "Hand"]
elif abs(pos[0]) > .16 * HEIGHT and abs(pos[2] / HEIGHT - marks["LeftArm"][2]) < .085:
allowed = ["mixamorig:" + side + part for part in ["Arm", "ForeArm", "Hand"]]
elif pos[2] < .35 * HEIGHT:
allowed = ["mixamorig:Hips"] + ["mixamorig:" + side + part for part in ["UpLeg", "Leg", "Foot", "ToeBase", "Toe_End"]]
if pos[2] > .24 * HEIGHT and abs(pos[0]) < .06 * HEIGHT:
allowed += ["mixamorig:LeftUpLeg", "mixamorig:RightUpLeg"]
else:
allowed = ["mixamorig:" + part for part in ["Hips", "Spine", "Spine1", "Spine2", "Neck", "Head"]]
allowed += ["mixamorig:" + s + part for s in ["Left", "Right"] for part in ["Shoulder", "Arm", "UpLeg"]]
keep = np.array([n in allowed for n in names])
# Preserve mitten hand contribution at the wrist instead of tiny finger joints.
if "mixamorig:" + side + "Hand" in allowed:
hand = names.index("mixamorig:" + side + "Hand")
weights[i, hand] = sum(weights[i, j] for j, n in enumerate(names) if n.startswith("mixamorig:" + side + "Hand"))
weights[i, ~keep] = 0
if weights[i].sum() < 1e-8:
if len(allowed) == 1:
weights[i, names.index(allowed[0])] = 1
else:
near = min(allowed, key=lambda n: (arm.data.bones[n].head_local - Vector(pos)).length)
weights[i, names.index(near)] = 1
weights[i] /= weights[i].sum()
# The connected crotch must blend continuously across the left/right
# boundary. Pin its center softly to Hips rather than switching legs.
if .24 * HEIGHT < pos[2] < .40 * HEIGHT:
t = min(abs(pos[0]) / (.055 * HEIGHT), 1.)
vertical = min(max((pos[2] / HEIGHT - .24) / .09, 0.), 1.)
center = (1 - t * t * (3 - 2 * t)) * vertical * vertical * (3 - 2 * vertical)
weights[i] *= 1 - center
weights[i, names.index("mixamorig:Hips")] += center
ids = np.argsort(-weights[i])[4:]
weights[i, ids] = 0
weights[i, weights[i] < .005] = 0
weights[i] /= weights[i].sum()
mesh.vertex_groups.clear()
for n in names:
mesh.vertex_groups.new(name=n)
for i, row in enumerate(weights):
for j in np.flatnonzero(row):
mesh.vertex_groups[names[j]].add([i], float(row[j]), "REPLACE")
for b in arm.data.bones:
assert (b.parent.name if b.parent else None) == fit["bones"][b.name]["parent"]
expected = np.array(fit["bones"][b.name]["world_matrix"])
assert np.max(abs(np.array(arm.matrix_world @ b.matrix_local) - expected)) < 1e-6
material(mesh)
bpy.ops.wm.save_as_mainfile(filepath=str(OUT / "LittleBear_Rigged.blend"), compress=True)
activate(mesh)
arm.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(OUT / "LittleBear_Rigged.glb"), export_format="GLB", export_yup=True,
use_selection=True, export_skins=True, export_animations=False, export_def_bones=False)
target = OUT / "LittleBear_Rigged.fbx"
bpy.ops.export_scene.fbx(filepath=str(target), use_selection=True, object_types={"ARMATURE", "MESH"},
axis_forward="-Z", axis_up="Y", global_scale=1, apply_unit_scale=True,
apply_scale_options="FBX_SCALE_ALL", bake_space_transform=False, add_leaf_bones=False,
use_armature_deform_only=False, armature_nodetype="NULL", bake_anim=False,
mesh_smooth_type="OFF", use_mesh_modifiers=False, path_mode="COPY", embed_textures=True)
write("skin_receipt.json", dict(bones=len(names), vertices=len(p),
triangles=sum(len(poly.vertices) - 2 for poly in mesh.data.polygons), height=HEIGHT,
maxInfluences=int(np.count_nonzero(weights, axis=1).max()),
localCleanupChangedVertices=int(np.any(abs(weights - raw) > .001, axis=1).sum()),
weightsSource="TokenRig prediction, supplied fixed Mixamo skeleton; no website weights",
maxPredictionMappingDistanceM=max(distances),
fbxSha256=hashlib.sha256(target.read_bytes()).hexdigest()))
render(mesh, "rigged_rest")
print("RIGGED_EXPORT", target)
def verify_export():
def snapshot():
arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
mesh = next(o for o in bpy.data.objects if o.type == "MESH")
return dict(points=points(mesh),
bones={b.name: dict(parent=b.parent.name if b.parent else None,
head=list(arm.matrix_world @ b.head_local)) for b in arm.data.bones},
weights=[{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-8} for v in mesh.data.vertices],
uv=sorted(map(tuple, np.round(np.array([tuple(v.uv) for v in mesh.data.uv_layers.active.data]), 5))),
triangles=sum(len(p.vertices) - 2 for p in mesh.data.polygons))
bpy.ops.wm.open_mainfile(filepath=str(OUT / "LittleBear_Rigged.blend"))
expected = snapshot()
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(OUT / "LittleBear_Rigged.fbx"), use_anim=False)
actual = snapshot()
tree = KDTree(len(expected["points"]))
for i, p in enumerate(expected["points"]):
tree.insert(Vector(p), i)
tree.balance()
position_error = weight_error = 0.
for p, row in zip(actual["points"], actual["weights"]):
_, i, distance = tree.find(Vector(p))
position_error = max(position_error, distance)
ref = expected["weights"][i]
weight_error = max(weight_error, max(abs(row.get(n, 0) - ref.get(n, 0)) for n in set(row) | set(ref)))
assert set(actual["bones"]) == set(expected["bones"])
assert all(actual["bones"][n]["parent"] == expected["bones"][n]["parent"] for n in actual["bones"])
bone_error = max(float(np.linalg.norm(np.array(actual["bones"][n]["head"]) - expected["bones"][n]["head"])) for n in actual["bones"])
assert position_error < 1e-5 and weight_error < 1e-5 and bone_error < 1e-5
assert actual["uv"] == expected["uv"]
assert actual["triangles"] == expected["triangles"] == 3000
assert max(len(row) for row in actual["weights"]) <= 4
report = dict(bones=len(actual["bones"]), triangles=actual["triangles"],
maxPositionErrorM=position_error, maxWeightError=weight_error, maxBoneHeadErrorM=bone_error,
uvPreserved=True, parentHierarchyPreserved=True, sourceVertices=len(expected["points"]),
roundtripVertices=len(actual["points"]), valid=True)
write("export_verification.json", report)
print("ROUNDTRIP_VERIFIED", json.dumps(report))
if __name__ == "__main__":
action = sys.argv[sys.argv.index("--") + 1]
OUT.mkdir(parents=True, exist_ok=True)
{"inspect": inspect, "inspect_high": inspect_high, "prepare": prepare, "restore": restore, "verify_export": verify_export}[action]()
@@ -0,0 +1,42 @@
"""Render both signs of the actual FBX Z axis to identify anatomical front."""
import sys,json,math
from pathlib import Path
import bpy
from mathutils import Vector
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/character_front_audit';OUT.mkdir(parents=True,exist_ok=True)
for folder,name,kind in [('campguide_studio','CampGuideStudio','static'),('lmgirl_tpose_studio','LmGirlTpose','static'),
('campguide_studio','CampGuideStudio','high'),('lmgirl_tpose_studio','LmGirlTpose','high')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
base=ROOT/'Tools/output'/folder/'static'
if kind=='static':bpy.ops.import_scene.fbx(filepath=str(base/(name+'.fbx')),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(base.parent/'model.glb'))
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
if kind=='static':
material=bpy.data.materials.new('AuditTexture');material.use_nodes=True
node=material.node_tree.nodes.new('ShaderNodeTexImage');node.image=bpy.data.images.load(str(base/(name+'_color.png')))
material.node_tree.nodes.active=node
material.node_tree.links.new(node.outputs['Color'],material.node_tree.nodes.get('Principled BSDF').inputs['Base Color'])
obj.data.materials.clear();obj.data.materials.append(material)
else:
for slot in obj.material_slots:
if slot.material and slot.material.use_nodes:
for node in slot.material.node_tree.nodes:
if node.type=='TEX_IMAGE' and any(link.to_socket.name=='Base Color' for link in node.outputs['Color'].links):
slot.material.node_tree.nodes.active=node;break
pts=[obj.matrix_world@v.co for v in obj.data.vertices];lo=Vector([min(v[i] for v in pts) for i in range(3)]);hi=Vector([max(v[i] for v in pts) for i in range(3)])
h=hi.z-lo.z;center=(lo+hi)/2
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH'
scene.render.resolution_x=512;scene.render.resolution_y=640;scene.render.resolution_percentage=100
scene.render.image_settings.file_format='PNG';scene.world=bpy.data.worlds.new('AuditWorld');scene.world.color=(.7,.7,.7)
shading=scene.display.shading;shading.light='STUDIO';shading.color_type='TEXTURE';shading.show_shadows=True;shading.show_cavity=True;shading.background_type='WORLD'
camera=bpy.data.objects.new('AuditCamera',bpy.data.cameras.new('AuditCamera'));scene.collection.objects.link(camera);scene.camera=camera
camera.data.type='ORTHO';camera.data.ortho_scale=h*1.2
# A Y-up FBX imported into Blender is rotated +90 X: +Z becomes -Y.
for sign,axis in [('plus_z',Vector((0,-1,0))),('minus_z',Vector((0,1,0)))]:
camera.location=center+axis*h*3
camera.rotation_euler=(center-camera.location).to_track_quat('-Z','Y').to_euler()
scene.render.filepath=str(OUT/(name+('_high' if kind=='high' else '')+'_from_'+sign+'.png'))
bpy.ops.render.render(write_still=True)
print('FRONT_VIEW',scene.render.filepath)
+50
View File
@@ -0,0 +1,50 @@
"""Isolated A/B trial of the installed Studio baker; never edits the shared backend.
Blender -- --job DIR --output DIR --ray-mode legacy|adaptive
Uses the same source geometry and Studio UVs, keeping baked/exported normals consistent.
"""
import argparse
import hashlib
import json
import sys
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument('--job', required=True)
parser.add_argument('--output', required=True)
parser.add_argument('--ray-mode', choices=['legacy', 'adaptive'], default='adaptive')
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:])
job, out = Path(args.job).resolve(), Path(args.output).resolve()
out.mkdir(parents=True, exist_ok=True)
backend = Path('D:/UD/AI/XModel/LLM3DGraphics/backend/blender_lowpoly_bake.py')
source = backend.read_text(encoding='utf-8')
baseline_hash = hashlib.sha256(backend.read_bytes()).hexdigest()
needle = 'outward_faces = bias_normals_outward(low)'
assert source.count(needle) == 1
source = source.replace(needle, 'outward_faces = 0 # Preserve the same tangent basis used by the normal bake')
if args.ray_mode == 'adaptive':
needle = 'ray = estimate_ray_distance(low)'
assert source.count(needle) == 1
source = source.replace(needle, '''
# Trial: surface separation rather than a fixed 4% of the full bounding box.
_bvh = BVHTree.FromObject(high, bpy.context.evaluated_depsgraph_get())
_distances = []
for _poly in low.data.polygons:
_hit = _bvh.find_nearest(low.matrix_world @ _poly.center)
if _hit[0] is not None:
_distances.append(float(_hit[3]))
_diag = float(low.dimensions.length)
_p99 = float(np.percentile(_distances, 99))
ray = min(max(_p99 * 1.5, _diag * .0005), _diag * .01)
print('TRIAL_PROJECTION ' + json.dumps(dict(p99=_p99, bbox_diagonal=_diag,
cage_extrusion=ray, legacy_cage_extrusion=_diag*.04)))
''')
sys.argv = [str(backend), '--', str(job/'model.glb'), str(out/'model_baked.glb'),
'1', '1024', str(job/'model_lowpoly_uv.glb')]
(out/'baker_snapshot.py').write_text(source, encoding='utf-8')
(out/'trial_settings.json').write_text(json.dumps(dict(
source_job=str(job), shared_backend_sha256=baseline_hash,
ray_mode=args.ray_mode, post_bake_normal_bias=False,
preserve_studio_uv=True, source_unchanged=True), indent=2), encoding='utf-8')
exec(compile(source, str(out/'baker_snapshot.py'), 'exec'),
{'__file__': str(backend), '__name__': '__main__'})
@@ -0,0 +1,57 @@
"""Verify a final static FBX against its Studio GLB, using full scene transforms."""
import sys,json,math,hashlib
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_decimate import prepare_game_export,validate_fbx_yup_scene
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
name=sys.argv[sys.argv.index('--')+2]
source=folder/'model_ex.glb';target=folder/'static'/(name+'.fbx')
out=folder/'axis_pipeline_audit';out.mkdir(parents=True,exist_ok=True)
def load(path):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.fbx':bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False)
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
assert len(meshes)==1
return meshes[0]
def uv(obj):return np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data],dtype='<f4')
def triangles(obj):return sum(len(p.vertices)-2 for p in obj.data.polygons)
obj=load(source);prepare_game_export(obj,restore_axis=True)
expected=np.array([Matrix.Rotation(math.pi/2,4,'X')@obj.matrix_world@v.co for v in obj.data.vertices])
source_uv=uv(obj);source_triangles=triangles(obj)
axes=validate_fbx_yup_scene(str(target))
obj=load(target);actual=np.array([obj.matrix_world@v.co for v in obj.data.vertices]);target_uv=uv(obj)
assert actual.shape==expected.shape and triangles(obj)==source_triangles
error=float(np.max(np.abs(actual-expected)));assert error<2e-6
assert np.array_equal(target_uv,source_uv)
lo=actual.min(0);hi=actual.max(0)
assert np.argmax(hi-lo)==2,'The complete scene is horizontal in Blender Z-up'
origin_error=max(abs(lo[2]),abs((lo[0]+hi[0])/2),abs((lo[1]+hi[1])/2));assert origin_error<2e-6
legacy=out/'legacy'/(name+'.fbx')
legacy_rejected=None
if legacy.is_file():
try:validate_fbx_yup_scene(str(legacy));legacy_rejected=False
except ValueError:legacy_rejected=True
report=dict(source=str(source),fbx=str(target),sha256=hashlib.sha256(target.read_bytes()).hexdigest(),
axes=axes,vertices=len(actual),triangles=source_triangles,uv_preserved=True,
uv_sha256=hashlib.sha256(target_uv.tobytes()).hexdigest(),upright=True,
height_m=float(hi[2]-lo[2]),bottom_center_error_m=float(origin_error),
max_position_error_m=error,legacy_rejected_by_axis_guard=legacy_rejected,
blender_world_bounds_min=lo.tolist(),blender_world_bounds_max=hi.tolist())
convention=json.loads((Path(__file__).resolve().parents[2]/'Config/Art/ModelConvention.json').read_text(encoding='utf-8'))
receipt_path=folder/'mesh_receipt.json'
receipt=json.loads(receipt_path.read_text(encoding='utf-8')) if receipt_path.is_file() else {}
front=receipt.get('fbxAxes',{}).get('anatomicalFront') if receipt.get('fbxSha256')==report['sha256'] else None
report.update(project_forward=convention['forwardAxis'],anatomical_front=front,
project_front_conforms=(front==convention['forwardAxis']) if front else None,
front_visual_check_required=front is None)
(out/'verification.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('STATIC_VERIFICATION '+json.dumps(report))
@@ -0,0 +1,30 @@
"""Compare Unity evaluation against raw FBX joint trajectories, pelvis-relative."""
import json,math
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/campguide_studio/playback_audit'
reference=json.loads((OUT/'fbx_reference.json').read_text(encoding='utf-8'))[1]
comparison=json.loads((OUT/'comparison.json').read_text(encoding='utf-8'))
def sub(a,b):return [a[i]-b[i] for i in range(3)]
def length(a):return math.sqrt(sum(v*v for v in a))
def unity(p):return [-p[0],p[2],-p[1]]
def angle(a,b):return math.degrees(math.acos(max(-1,min(1,sum(a[i]*b[i] for i in range(3))/(length(a)*length(b))))))
report=[]
for entry in comparison['samples']:
maximum=0;feet_max=0;angles=[];gaps=[]
for frame,pose in enumerate(entry['frames']):
raw={name:unity(p) for name,p in reference['samples'][frame]['world'].items()}
for name,p in pose.items():
delta=sub(sub(p,pose['mixamorig:Hips']),sub(raw[name],raw['mixamorig:Hips']))
error=length(delta);maximum=max(maximum,error)
if name.endswith('Foot'):feet_max=max(feet_max,error)
for side in ('Left','Right'):
ankle='mixamorig:'+side+'Foot';toe='mixamorig:'+side+'ToeBase'
angles.append(angle(sub(pose[toe],pose[ankle]),sub(raw[toe],raw[ankle])))
gaps.append(pose['mixamorig:RightFoot'][0]-pose['mixamorig:LeftFoot'][0])
report.append(dict(variant=entry['tag'],max_joint_error_m=maximum,max_ankle_error_m=feet_max,
max_foot_direction_error_degrees=max(angles),min_signed_ankle_gap_m=min(gaps),crossed_frames=sum(g<0 for g in gaps)))
print(json.dumps(report,indent=2))
assert report[0]['max_joint_error_m']<.001,'Generic reference does not match the original FBX'
(OUT/'summary.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
+4 -2
View File
@@ -12,6 +12,8 @@ from bl_decimate import prepare_game_export,export_fbx_source_yup,validate_fbx_y
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/regression'
OUT.mkdir(parents=True,exist_ok=True)
LEGACY=OUT.parent/'legacy/LmGirlTpose.fbx'
if not LEGACY.is_file():LEGACY=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
def points(obj):return np.array([obj.matrix_world@v.co for v in obj.data.vertices])
def uv(obj):return np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data])
@@ -66,7 +68,7 @@ boy=ROOT/'Tools/output/campguide_studio/model_ex.glb'
_,r=run('boy_from_glb',boy,True);results.append(r)
path,r=run('girl_from_glb',girl,True);results.append(r)
for i in range(1,3):path,r=run('girl_repeat_'+str(i),path,None);results.append(r)
_,r=run('girl_from_legacy',ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx',None);results.append(r)
_,r=run('girl_from_legacy',LEGACY,None);results.append(r)
# Export failure must also restore selection, names, geometry and temporary data.
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
prepare_game_export(obj);before=snapshot(obj)
@@ -75,7 +77,7 @@ except (RuntimeError,OSError):pass
else:raise AssertionError('Expected export failure')
unchanged(obj,before)
results.append(dict(case='failure_cleanup',passed=True))
try:validate_fbx_yup_scene(str(ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'))
try:validate_fbx_yup_scene(str(LEGACY))
except ValueError:pass
else:raise AssertionError('Legacy rotated FBX passed the export guard')
results.append(dict(case='reject_legacy_rotation',passed=True))