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)