component-studio / scripts /project_character_reference.py
mantrakp's picture
Integrate isolated Astra refinement, learned rigging and native Kimodo animation
381de33 verified
Raw History Blame Contribute Delete
6.8 kB
"""Bake the front reference into the existing UV atlas, retaining unseen source texture."""
import argparse
from pathlib import Path
import sys
import json
import bpy
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
p = argparse.ArgumentParser()
p.add_argument("--mesh", required=True)
p.add_argument("--reference", required=True)
p.add_argument("--output", required=True)
a = p.parse_args(sys.argv[sys.argv.index("--") + 1 :])
out = Path(a.output)
out.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath=str(Path(a.mesh).resolve()))
objects = [o for o in bpy.context.scene.objects if o.type == "MESH"]
for obj in objects:
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
obj.select_set(False)
vertices = []
triangles = []
offset = 0
for obj in objects:
obj.data.calc_loop_triangles()
vertices.extend(tuple(v.co) for v in obj.data.vertices)
triangles.extend(tuple(offset + i for i in t.vertices) for t in obj.data.loop_triangles)
offset += len(obj.data.vertices)
vertices = np.array(vertices)
lo = vertices.min(0)
hi = vertices.max(0)
ref = bpy.data.images.load(str(Path(a.reference).resolve()))
w, h = ref.size
pixels = np.array(ref.pixels[:]).reshape(h, w, 4)
mask = pixels[:, :, :3].min(2) < 0.88
ys, xs = np.where(mask)
uvmin = np.array([xs.min() / w, ys.min() / h])
uvmax = np.array([xs.max() / w, ys.max() / h])
sx = (uvmax[0] - uvmin[0]) / (hi[0] - lo[0])
sz = (uvmax[1] - uvmin[1]) / (hi[2] - lo[2])
tx = uvmin[0] - lo[0] * sx
tz = uvmin[1] - lo[2] * sz
bvh = BVHTree.FromPolygons(vertices.tolist(), triangles, all_triangles=True)
old_mat = objects[0].data.materials[0]
old_tex = (
next(
n.image
for n in old_mat.node_tree.nodes
if n.type == "TEX_IMAGE" and n.image and "baseColor" in n.image.name
)
if any(n.type == "TEX_IMAGE" and n.image and "baseColor" in n.image.name for n in old_mat.node_tree.nodes)
else next(n.image for n in old_mat.node_tree.nodes if n.type == "TEX_IMAGE" and n.image)
)
# Resolve the actual base-color connection instead of relying on material node order.
bsdf = next(n for n in old_mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED")
if bsdf.inputs["Base Color"].is_linked:
old_tex = bsdf.inputs["Base Color"].links[0].from_node.image
mat = bpy.data.materials.new("Reference projection bake")
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
emit = nodes.new("ShaderNodeEmission")
links.new(emit.outputs[0], output.inputs["Surface"])
original = nodes.new("ShaderNodeTexImage")
original.image = old_tex
projected = nodes.new("ShaderNodeTexImage")
projected.image = ref
projected.extension = "CLIP"
geom = nodes.new("ShaderNodeNewGeometry")
sep = nodes.new("ShaderNodeSeparateXYZ")
links.new(geom.outputs["Position"], sep.inputs[0])
combine = nodes.new("ShaderNodeCombineXYZ")
for channel, scale, translate in [("X", sx, tx), ("Z", sz, tz)]:
mult = nodes.new("ShaderNodeMath")
mult.operation = "MULTIPLY_ADD"
mult.inputs[1].default_value = scale
mult.inputs[2].default_value = translate
links.new(sep.outputs[channel], mult.inputs[0])
links.new(mult.outputs[0], combine.inputs["X" if channel == "X" else "Y"])
links.new(combine.outputs[0], projected.inputs["Vector"])
attribute = nodes.new("ShaderNodeAttribute")
attribute.attribute_name = "projection_weight"
mix = nodes.new("ShaderNodeMixRGB")
links.new(attribute.outputs["Fac"], mix.inputs[0])
links.new(original.outputs["Color"], mix.inputs[1])
links.new(projected.outputs["Color"], mix.inputs[2])
links.new(mix.outputs[0], emit.inputs["Color"])
baked = bpy.data.images.new("Astronaut reference atlas", 4096, 4096, alpha=True)
target = nodes.new("ShaderNodeTexImage")
target.image = baked
nodes.active = target
weights = []
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
attr = obj.data.attributes.new("projection_weight", "FLOAT", "POINT")
for v in obj.data.vertices:
origin = Vector((v.co.x, -2.0, v.co.z))
hit, normal, index, distance = bvh.ray_cast(origin, Vector((0, 1, 0)))
visible = hit is not None and abs(hit.y - v.co.y) < 0.004
facing = max(0.0, min(1.0, (-v.normal.y - 0.15) / 0.5))
facing = facing * facing * (3 - 2 * facing)
u = v.co.x * sx + tx
vv = v.co.z * sz + tz
px = int(np.clip(u * w, 0, w - 1))
py = int(np.clip(vv * h, 0, h - 1))
foreground = pixels[py, px, :3].min() < 0.94
weight = float(visible and foreground) * facing
attr.data[v.index].value = weight
weights.append(weight)
obj.data.materials.clear()
obj.data.materials.append(mat)
obj.select_set(True)
# A joined bake copy writes one atlas; original component objects remain separate.
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.duplicate()
copies = list(bpy.context.selected_objects)
bpy.context.view_layer.objects.active = copies[0]
bpy.ops.object.join()
proxy = bpy.context.object
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = 1
scene.render.bake.margin = 16
scene.render.bake.use_clear = True
bpy.ops.object.bake(type="EMIT")
baked.filepath_raw = str((out / "reference-atlas.png").resolve())
baked.file_format = "PNG"
baked.save()
bpy.data.objects.remove(proxy, do_unlink=True)
final = bpy.data.materials.new("Astronaut painted suit")
final.use_nodes = True
node = final.node_tree.nodes.get("Principled BSDF")
node.inputs["Roughness"].default_value = 0.7
node.inputs["Metallic"].default_value = 0
tex = final.node_tree.nodes.new("ShaderNodeTexImage")
tex.image = baked
final.node_tree.links.new(tex.outputs["Color"], node.inputs["Base Color"])
for obj in objects:
obj.data.materials.clear()
obj.data.materials.append(final)
bpy.ops.object.select_all(action="DESELECT")
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.export_scene.gltf(
filepath=str((out / "character-static.glb").resolve()), export_format="GLB", use_selection=True
)
(out / "texture-transfer.json").write_text(
json.dumps(
{
"method": "Visibility- and normal-weighted projection of original reference, baked to existing UVs; original texture retained on unseen surfaces",
"resolution": 4096,
"projection": [float(sx), float(sz), float(tx), float(tz)],
"vertices_with_reference": sum(x > 0.5 for x in weights),
"vertices": len(weights),
"roughness": 0.7,
},
indent=2,
)
)