Spaces:
Running on Zero
Running on Zero
File size: 6,902 Bytes
5c331a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | """Project a reference onto visible UV texels; retain occluded and shared-backside texels."""
import argparse
import json
from pathlib import Path
import sys
import bpy
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
p = argparse.ArgumentParser()
p.add_argument('--folder', required=True)
p.add_argument('--front-axis', choices=['-Y', '+Y', '-X', '+X'], default='-Y')
p.add_argument('--strength', type=float, default=.9)
a = p.parse_args(sys.argv[sys.argv.index('--') + 1:])
folder = Path(a.folder)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
bpy.ops.import_scene.gltf(filepath=str(folder / 'source.glb'))
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
front = np.array({'-Y': [0, -1, 0], '+Y': [0, 1, 0], '-X': [-1, 0, 0], '+X': [1, 0, 0]}[a.front_axis], dtype=float)
right = np.cross(front, [0, 0, 1]) * -1
vertices, triangles, records = [], [], []
offset = 0
for obj in objects:
mesh = obj.data
mesh.calc_loop_triangles()
world = np.array([obj.matrix_world @ v.co for v in mesh.vertices])
normals = np.array([obj.matrix_world.to_3x3().inverted().transposed() @ v.normal for v in mesh.vertices])
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12)
vertices.extend(world.tolist())
triangles.extend(tuple(offset + i for i in t.vertices) for t in mesh.loop_triangles)
records.append((obj, world, normals))
offset += len(world)
vertices = np.array(vertices)
bvh = BVHTree.FromPolygons(vertices.tolist(), triangles, all_triangles=True)
height = np.ptp(vertices[:, 2])
horizontal = vertices @ right
span = np.ptp(horizontal)
horizontal_min = horizontal.min()
vertical_min = vertices[:, 2].min()
if height <= 0 or span <= 0:
raise ValueError('Degenerate projection bounds')
reference = np.load(folder / 'reference.npz')
rgba, mask, bounds = reference['rgba'], reference['mask'], reference['bounds']
ref_h, ref_w = mask.shape
x0, y0, x1, y1 = bounds
metrics = {'front_axis': a.front_axis, 'strength': a.strength,
'method': 'per-texel UV projection with BVH occlusion, normal falloff and shared-UV protection',
'material_color_boundaries_protected': True, 'geometry_unchanged': True, 'uv_unchanged': True, 'pbr_channels_preserved': True, 'parts': []}
inputs = json.loads((folder / 'inputs.json').read_text())
for item in inputs:
matches = [r for r in records if r[0].name == item['part_id'] or r[0].name.startswith(item['part_id'] + ' ')]
if len(matches) != 1:
raise ValueError(f"Cannot identify unique component {item['part_id']}")
obj, world, normals = matches[0]
source = np.load(folder / item['file'])
h, w = source.shape[:2]
weights = np.zeros((h, w), dtype=np.float32)
colors = np.zeros((h, w, 3), dtype=np.uint8)
blocked = np.zeros((h, w), dtype=bool)
uv_data = obj.data.uv_layers.active.data
for triangle in obj.data.loop_triangles:
uv = np.array([uv_data[i].uv for i in triangle.loops])
if np.any(uv < -1e-6) or np.any(uv > 1 + 1e-6):
raise ValueError('Tiled UVs are unsupported for reference projection; source retained.')
pixel = uv * [w, -h] + [0, h]
low = np.maximum(np.floor(pixel.min(0)).astype(int), 0)
high = np.minimum(np.ceil(pixel.max(0)).astype(int), [w - 1, h - 1])
if np.any(high < low):
continue
yy, xx = np.mgrid[low[1]:high[1] + 1, low[0]:high[0] + 1]
points = np.stack([xx.ravel() + .5, yy.ravel() + .5], axis=1)
v0, v1 = pixel[1] - pixel[0], pixel[2] - pixel[0]
determinant = v0[0] * v1[1] - v0[1] * v1[0]
if abs(determinant) < 1e-10:
continue
delta = points - pixel[0]
b1 = (delta[:, 0] * v1[1] - delta[:, 1] * v1[0]) / determinant
b2 = (v0[0] * delta[:, 1] - v0[1] * delta[:, 0]) / determinant
bary = np.stack([1 - b1 - b2, b1, b2], axis=1)
inside = np.all(bary >= -1e-7, axis=1)
if not inside.any():
continue
bary = bary[inside]
xx, yy = xx.ravel()[inside], yy.ravel()[inside]
ids = list(triangle.vertices)
xyz = bary @ world[ids]
n = bary @ normals[ids]
facing = np.clip((n @ front - .35) / .55, 0, 1)
facing = facing * facing * (3 - 2 * facing)
rx = np.clip(np.rint(x0 + (xyz @ right - horizontal_min) / span * (x1 - x0)).astype(int), 0, ref_w - 1)
ry = np.clip(np.rint(y1 - (xyz[:, 2] - vertical_min) / height * (y1 - y0)).astype(int), 0, ref_h - 1)
eligible = (facing > 0) & mask[ry, rx]
visible = np.zeros(len(xyz), dtype=bool)
for index in np.flatnonzero(eligible):
point = xyz[index]
hit, _, _, _ = bvh.ray_cast(Vector(point + front * height * 2), Vector(-front))
visible[index] = hit is not None and np.linalg.norm(np.array(hit) - point) < height * 1e-4
original_rgb = source[yy, xx, :3].astype(float) / 255
target_rgb = rgba[ry, rx, :3].astype(float) / 255
original_sat = np.ptp(original_rgb, axis=1) / np.maximum(original_rgb.max(1), 1e-6)
target_sat = np.ptp(target_rgb, axis=1) / np.maximum(target_rgb.max(1), 1e-6)
colored = np.clip((original_sat - .35) / .15, 0, 1)
washed_out = np.clip((.45 - target_sat) / .15, 0, 1)
color_agreement = 1 - colored * washed_out
source_chroma = original_rgb - original_rgb.mean(1, keepdims=True)
target_chroma = target_rgb - target_rgb.mean(1, keepdims=True)
cosine = np.sum(source_chroma * target_chroma, axis=1) / np.maximum(
np.linalg.norm(source_chroma, axis=1) * np.linalg.norm(target_chroma, axis=1), 1e-6)
changed_hue = colored * np.clip((target_sat - .35) / .15, 0, 1)
color_agreement *= 1 - changed_hue * (1 - np.clip((cosine - .3) / .5, 0, 1))
weight = facing * visible * color_agreement * a.strength
blocked[yy[weight == 0], xx[weight == 0]] = True
keep = weight > weights[yy, xx]
weights[yy[keep], xx[keep]] = weight[keep]
colors[yy[keep], xx[keep]] = rgba[ry[keep], rx[keep], :3]
weights[blocked] = 0
changed = weights > 0
result = source.copy()
result[:, :, :3] = np.rint(source[:, :, :3] * (1 - weights[:, :, None]) + colors * weights[:, :, None]).astype(np.uint8)
np.save(folder / ('projected-' + item['file']), result)
assert np.array_equal(source[~changed], result[~changed])
assert np.array_equal(source[:, :, 3], result[:, :, 3])
metrics['parts'].append({'part_id': item['part_id'], 'changed_texels': int(changed.sum()),
'total_texels': int(h * w), 'protected_texels_unchanged': True,
'alpha_unchanged': True})
(folder / 'projection.json').write_text(json.dumps(metrics, indent=2))
print(json.dumps(metrics), flush=True)
|