Spaces:
Paused
Paused
Download scripts/project_character_reference.py from mantrakp/component-studio: direct link, hf CLI and curl.
- Browser
- Download file 6.8 kB
-
https://huggingface.co/spaces/mantrakp/component-studio/resolve/main/scripts/project_character_reference.py
- Command line
-
hf download hf://spaces/mantrakp/component-studio/scripts/project_character_reference.py
-
curl -L -o project_character_reference.py https://huggingface.co/spaces/mantrakp/component-studio/resolve/main/scripts/project_character_reference.py
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, | |
| ) | |
| ) | |