fixed xynova texture generation problem
This commit is contained in:
@@ -147,6 +147,14 @@ share one set of settings and one seed, and both read the same camera-space
|
|||||||
patch grid, so a given physical patch carries the same colour in either file —
|
patch grid, so a given physical patch carries the same colour in either file —
|
||||||
the images differ only in how the unwrap arranges the patches.
|
the images differ only in how the unwrap arranges the patches.
|
||||||
|
|
||||||
|
**Square in which space?** The patch grid is defined in the camera image, not in
|
||||||
|
flat UV coordinates or physical surface distances. For square camera pixels,
|
||||||
|
`nx / ny` must match the camera image aspect ratio (80×80 for a square frame,
|
||||||
|
80×45 for 16:9). Changing the PNG dimensions does not change the camera grid.
|
||||||
|
Curvature and UV unwrapping can make these camera-space squares look rectangular,
|
||||||
|
skewed, or curved in the flat texture; that alone is not a defect. Thin spikes
|
||||||
|
outside the UV surface, however, are not expected projection distortion.
|
||||||
|
|
||||||
Generated artifacts are **committed** so a revision's textures can be synced to
|
Generated artifacts are **committed** so a revision's textures can be synced to
|
||||||
hardware production. They are stored as real files, not Git LFS pointers, so a
|
hardware production. They are stored as real files, not Git LFS pointers, so a
|
||||||
plain `git clone` yields usable images and `.blend` files; `.gitattributes` only
|
plain `git clone` yields usable images and `.blend` files; `.gitattributes` only
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Camera-cell clipping without UV topology assumptions (stdlib only).
|
||||||
|
|
||||||
|
Attributes are interpolated in homogeneous space before perspective division,
|
||||||
|
then as barycentric/w and 1/w in screen space. UVs are resolved only against
|
||||||
|
one evaluated loop triangle; neither island joining nor periodic wrapping occurs.
|
||||||
|
|
||||||
|
Visibility is sampled, not an exact occluder polygon arrangement. A fragment
|
||||||
|
whose seven probes (vertices, edge midpoints, centroid) all agree is emitted or
|
||||||
|
dropped as one unit; occluders smaller than the probe spacing can therefore be
|
||||||
|
missed inside such a fragment. Only mixed fragments — one straddling an
|
||||||
|
occlusion boundary — are refined, down to MIXED_SPAN_CELLS (1/4 cell) or depth
|
||||||
|
6, and an unresolved mixed leaf is rejected rather than guessed, so occluded
|
||||||
|
surface is left white. Seven probes per leaf are NOT a proof of full visibility.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Screen-space tolerance for resolving a fragment that straddles an occlusion
|
||||||
|
# boundary, expressed as a fraction of one camera grid cell. The boundary is a
|
||||||
|
# curve, so cost grows as 1/tolerance; 1/4 cell already resolves the silhouette
|
||||||
|
# to a few output pixels at production raster size, while 1/128 cell (sub-pixel)
|
||||||
|
# multiplied polygon count by ~6 for no visible gain.
|
||||||
|
MIXED_SPAN_CELLS = 1 / 4
|
||||||
|
|
||||||
|
|
||||||
|
def clip_halfspace(points, distance):
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
prev = points[-1]
|
||||||
|
dp = distance(prev)
|
||||||
|
for cur in points:
|
||||||
|
dc = distance(cur)
|
||||||
|
if (dp >= 0) != (dc >= 0):
|
||||||
|
t = dp / (dp - dc)
|
||||||
|
result.append(tuple(a + t * (b - a) for a, b in zip(prev, cur)))
|
||||||
|
if dc >= 0:
|
||||||
|
result.append(cur)
|
||||||
|
prev, dp = cur, dc
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def clip_rect(points, x0, x1, y0, y1):
|
||||||
|
for axis, bound, sign in ((0, x0, 1), (0, x1, -1), (1, y0, 1), (1, y1, -1)):
|
||||||
|
points = clip_halfspace(points, lambda p: sign * (p[axis] - bound))
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def area(points):
|
||||||
|
return abs(sum(a[0] * b[1] - b[0] * a[1]
|
||||||
|
for a, b in zip(points, points[1:] + points[:1]))) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def project_triangle(clip_vertices):
|
||||||
|
"""Clip against all six camera planes, return (u,v,1/w,b0/w,b1/w,b2/w)."""
|
||||||
|
points = [tuple(v) + tuple(float(i == j) for j in range(3))
|
||||||
|
for i, v in enumerate(clip_vertices)]
|
||||||
|
for axis in range(3):
|
||||||
|
for sign in (-1, 1):
|
||||||
|
points = clip_halfspace(points, lambda p: p[3] + sign * p[axis])
|
||||||
|
return [((p[0] / p[3] + 1) / 2, (p[1] / p[3] + 1) / 2,
|
||||||
|
1 / p[3], *(b / p[3] for b in p[4:]))
|
||||||
|
for p in points if p[3] > 1e-15]
|
||||||
|
|
||||||
|
|
||||||
|
def barycentric(point):
|
||||||
|
return tuple(v / point[2] for v in point[3:6])
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_uv(point, loop_uvs):
|
||||||
|
weights = barycentric(point)
|
||||||
|
return tuple(sum(w * uv[k] for w, uv in zip(weights, loop_uvs)) for k in range(2))
|
||||||
|
|
||||||
|
|
||||||
|
def mean(*points):
|
||||||
|
return tuple(sum(values) / len(points) for values in zip(*points))
|
||||||
|
|
||||||
|
|
||||||
|
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
|
||||||
|
a, b, c = triangle
|
||||||
|
ab, bc, ca = mean(a, b), mean(b, c), mean(c, a)
|
||||||
|
probes = (a, b, c, ab, bc, ca, mean(a, b, c))
|
||||||
|
states = [visible(barycentric(p)) for p in probes]
|
||||||
|
span = max((max(p[k] for p in triangle) - min(p[k] for p in triangle)) * n
|
||||||
|
for k, n in ((0, nx), (1, ny)))
|
||||||
|
# A uniformly visible fragment is emitted as-is. Subdividing it would only
|
||||||
|
# split an area that already shares one cell colour, multiplying polygon
|
||||||
|
# count by ~4**3 per fragment for no visual or vector benefit. Only mixed
|
||||||
|
# fragments (an occlusion boundary crossing them) are refined, because only
|
||||||
|
# there does a sub-fragment disagree with its neighbours.
|
||||||
|
if all(states):
|
||||||
|
yield triangle
|
||||||
|
return
|
||||||
|
if not any(states):
|
||||||
|
return
|
||||||
|
if depth >= 6 or span <= MIXED_SPAN_CELLS:
|
||||||
|
# Never accept an unresolved mixed leaf, even if its centroid is visible.
|
||||||
|
if all(states):
|
||||||
|
yield triangle
|
||||||
|
return
|
||||||
|
children = []
|
||||||
|
for child in ((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)):
|
||||||
|
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid, depth + 1))
|
||||||
|
# Coalesce only the exact parent triangle after every child was accepted.
|
||||||
|
# This is not a UV hull or a cross-triangle/island join.
|
||||||
|
if children and abs(sum(area(list(t)) for t in children) - area(list(triangle))) <= area(list(triangle)) * 1e-12:
|
||||||
|
yield triangle
|
||||||
|
else:
|
||||||
|
yield from children
|
||||||
|
|
||||||
|
|
||||||
|
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
|
||||||
|
"""Yield (column,row,screen polygon) fragments of ONE loop triangle."""
|
||||||
|
projected = project_triangle(clip_vertices)
|
||||||
|
if len(projected) < 3 or area(projected) < 1e-18:
|
||||||
|
return
|
||||||
|
i0 = max(0, math.floor(min(p[0] for p in projected) * nx))
|
||||||
|
i1 = min(nx - 1, math.floor(max(p[0] for p in projected) * nx))
|
||||||
|
j0 = max(0, math.floor(min(p[1] for p in projected) * ny))
|
||||||
|
j1 = min(ny - 1, math.floor(max(p[1] for p in projected) * ny))
|
||||||
|
for j in range(j0, j1 + 1):
|
||||||
|
for i in range(i0, i1 + 1):
|
||||||
|
polygon = clip_rect(projected, i / nx, (i + 1) / nx, j / ny, (j + 1) / ny)
|
||||||
|
if len(polygon) < 3 or area(polygon) < 1e-18:
|
||||||
|
continue
|
||||||
|
for k in range(1, len(polygon) - 1):
|
||||||
|
tri = (polygon[0], polygon[k], polygon[k + 1])
|
||||||
|
if area(list(tri)) > 1e-18:
|
||||||
|
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid):
|
||||||
|
yield i, j, fragment
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Blender adapter for evaluated, camera-local loop-triangle fragments."""
|
||||||
|
from patch_geometry import cell_fragments, interpolate_uv
|
||||||
|
|
||||||
|
|
||||||
|
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid):
|
||||||
|
from mathutils import Vector
|
||||||
|
if cam.data.type not in {'PERSP', 'ORTHO'}:
|
||||||
|
raise ValueError('Patch projection requires a perspective or orthographic camera')
|
||||||
|
camera = cam.evaluated_get(depsgraph)
|
||||||
|
evaluated = obj.evaluated_get(depsgraph)
|
||||||
|
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||||
|
try:
|
||||||
|
mesh.calc_loop_triangles()
|
||||||
|
layers = {name: mesh.uv_layers.get(name) for name in uv_names}
|
||||||
|
if any(layer is None for layer in layers.values()):
|
||||||
|
raise ValueError('An evaluated mesh UV layer is missing')
|
||||||
|
render = scene.render
|
||||||
|
projection = camera.calc_matrix_camera(
|
||||||
|
depsgraph, x=render.resolution_x, y=render.resolution_y,
|
||||||
|
scale_x=render.pixel_aspect_x, scale_y=render.pixel_aspect_y)
|
||||||
|
view = camera.matrix_world.normalized().inverted()
|
||||||
|
clip_matrix = projection @ view
|
||||||
|
origin = camera.matrix_world.translation
|
||||||
|
forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
|
||||||
|
output = {name: {'mesh': [], 'patches': []} for name in uv_names}
|
||||||
|
ray_count = 0
|
||||||
|
for triangle in mesh.loop_triangles:
|
||||||
|
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||||
|
clips = [tuple(clip_matrix @ p.to_4d()) for p in world]
|
||||||
|
uvs = {name: [tuple(layer.data[i].uv) for i in triangle.loops]
|
||||||
|
for name, layer in layers.items()}
|
||||||
|
for name in uv_names:
|
||||||
|
output[name]['mesh'].append(uvs[name])
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
def visible(weights):
|
||||||
|
nonlocal ray_count
|
||||||
|
key = tuple(round(w, 12) for w in weights)
|
||||||
|
if key in cache:
|
||||||
|
return cache[key]
|
||||||
|
point = sum((p * w for p, w in zip(world, weights)), Vector())
|
||||||
|
depth = -(view @ point).z
|
||||||
|
ray_origin = origin if camera.data.type == 'PERSP' else point - forward * depth
|
||||||
|
direction = point - ray_origin
|
||||||
|
distance = direction.length
|
||||||
|
# Relative hit-position tolerance in world units; never rely on
|
||||||
|
# original/evaluated polygon indices (modifiers can change them).
|
||||||
|
tolerance = max(1e-7, distance * 2e-5)
|
||||||
|
if distance <= tolerance:
|
||||||
|
cache[key] = False
|
||||||
|
return False
|
||||||
|
direction.normalize()
|
||||||
|
hit, location, normal, index, hit_obj, matrix = scene.ray_cast(
|
||||||
|
depsgraph, ray_origin, direction, distance=distance + tolerance)
|
||||||
|
ray_count += 1
|
||||||
|
same_object = hit_obj is not None and getattr(hit_obj, 'original', hit_obj) == obj.original
|
||||||
|
result = bool(hit and same_object and (location - point).length <= tolerance)
|
||||||
|
cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid):
|
||||||
|
for name in uv_names:
|
||||||
|
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
|
||||||
|
output[name]['patches'].append((i, j, polygon))
|
||||||
|
print(f'Evaluated {len(mesh.loop_triangles)} loop triangles; {ray_count} visibility rays; '
|
||||||
|
f'{len(output[uv_names[0]]["patches"])} local fragments per UV target')
|
||||||
|
print(f'Visibility sampling: seven probes per fragment; uniformly probed '
|
||||||
|
f'fragments are accepted or dropped whole, mixed boundary fragments '
|
||||||
|
f'refine to 1/4 cell or depth 6 and unresolved leaves are rejected. '
|
||||||
|
f'Sub-sample occluders may be missed; hit tolerance max(1e-7, distance*2e-5).')
|
||||||
|
return output
|
||||||
|
finally:
|
||||||
|
evaluated.to_mesh_clear()
|
||||||
@@ -33,6 +33,11 @@ import bpy
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from mathutils import Vector
|
from mathutils import Vector
|
||||||
|
|
||||||
|
# Blender -P does not consistently include the script directory on sys.path.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from patch_geometry import clip_rect
|
||||||
|
from patch_projection import build_patch_geometry
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Common Utilities
|
# Common Utilities
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -522,6 +527,20 @@ def generate_valid_color(neighbors, r):
|
|||||||
color[channel] = sampled
|
color[channel] = sampled
|
||||||
return color
|
return color
|
||||||
|
|
||||||
|
def precompute_cell_colors(nx, ny, r):
|
||||||
|
"""Assign every seeded grid cell once, independent of geometry/UV visibility."""
|
||||||
|
colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
||||||
|
for j in range(ny):
|
||||||
|
for i in range(nx):
|
||||||
|
neighbors = []
|
||||||
|
if j > 0:
|
||||||
|
neighbors.append(colors[j - 1, i])
|
||||||
|
if i > 0:
|
||||||
|
neighbors.append(colors[j, i - 1])
|
||||||
|
colors[j, i] = generate_valid_color(neighbors, r) if neighbors else np.random.rand(3)
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
def checker_patch_color(i, j):
|
def checker_patch_color(i, j):
|
||||||
"""Return a black/white checker color for patch index (i, j)."""
|
"""Return a black/white checker color for patch index (i, j)."""
|
||||||
if (i + j) % 2 == 0:
|
if (i + j) % 2 == 0:
|
||||||
@@ -848,7 +867,7 @@ def generate_patch_texture(
|
|||||||
vector_width_mm=164.0, vector_height_mm=160.0,
|
vector_width_mm=164.0, vector_height_mm=160.0,
|
||||||
uv_centre=None, cross_length=40,
|
uv_centre=None, cross_length=40,
|
||||||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||||
uv_layer=None, image_name="PatchTexture"
|
uv_layer=None, image_name="PatchTexture", patch_colors=None, geometry=None
|
||||||
):
|
):
|
||||||
if not output_path:
|
if not output_path:
|
||||||
raise ValueError("output_path is required (absolute PNG path)")
|
raise ValueError("output_path is required (absolute PNG path)")
|
||||||
@@ -856,50 +875,22 @@ def generate_patch_texture(
|
|||||||
_warn_if_page_aspect_differs(
|
_warn_if_page_aspect_differs(
|
||||||
texture_width, texture_height, vector_width_mm, vector_height_mm
|
texture_width, texture_height, vector_width_mm, vector_height_mm
|
||||||
)
|
)
|
||||||
cam_data = cam.data
|
if uv_layer is None:
|
||||||
render = scene.render
|
uv_layer = obj.data.uv_layers.active
|
||||||
res_x = render.resolution_x
|
if uv_layer is None:
|
||||||
res_y = render.resolution_y
|
raise ValueError("A UV layer is required")
|
||||||
if cam_data.sensor_fit == 'HORIZONTAL':
|
if patch_colors is None:
|
||||||
sensor_width = cam_data.sensor_width
|
patch_colors = precompute_cell_colors(nx, ny, r)
|
||||||
sensor_height = sensor_width / (res_x / res_y)
|
if geometry is None:
|
||||||
else:
|
geometry = build_patch_geometry(
|
||||||
sensor_height = cam_data.sensor_height
|
obj, cam, scene, depsgraph, [uv_layer.name], nx, ny, edge_sample_grid
|
||||||
sensor_width = sensor_height * (res_x / res_y)
|
)[uv_layer.name]
|
||||||
focal = cam_data.lens
|
|
||||||
frame_width = sensor_width / focal
|
|
||||||
frame_height = sensor_height / focal
|
|
||||||
|
|
||||||
edge_sample_grid = max(2, int(edge_sample_grid))
|
def pixel_polygon(uv_polygon):
|
||||||
patch_uv_polygons = []
|
# Authored per-loop UVs are authoritative, including wide/mirrored islands.
|
||||||
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
|
# Clip to this output tile; do not infer a periodic seam or draw copies.
|
||||||
skipped_patches = 0
|
return [(u * (texture_width - 1), v * (texture_height - 1))
|
||||||
|
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)]
|
||||||
print(f"Raycasting {nx*ny} patches...")
|
|
||||||
for j in range(ny):
|
|
||||||
v_start = j / ny
|
|
||||||
v_end = (j+1) / ny
|
|
||||||
for i in range(nx):
|
|
||||||
u_start = i / nx
|
|
||||||
u_end = (i+1) / nx
|
|
||||||
uv_poly = sample_patch_uv_polygon(
|
|
||||||
scene, depsgraph, cam, obj,
|
|
||||||
u_start, u_end, v_start, v_end,
|
|
||||||
frame_width, frame_height,
|
|
||||||
edge_sample_grid=edge_sample_grid, uv_layer=uv_layer
|
|
||||||
)
|
|
||||||
if uv_poly is None:
|
|
||||||
patch_uv_polygons.append(None)
|
|
||||||
skipped_patches += 1
|
|
||||||
continue
|
|
||||||
patch_uv_polygons.append(uv_poly)
|
|
||||||
|
|
||||||
neighbors = []
|
|
||||||
if j > 0: neighbors.append(patch_colors[j-1, i])
|
|
||||||
if i > 0: neighbors.append(patch_colors[j, i-1])
|
|
||||||
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
|
|
||||||
patch_colors[j, i] = color
|
|
||||||
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
|
|
||||||
|
|
||||||
# Create image – start with black background
|
# Create image – start with black background
|
||||||
img_name = image_name
|
img_name = image_name
|
||||||
@@ -913,9 +904,8 @@ def generate_patch_texture(
|
|||||||
|
|
||||||
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
|
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
|
||||||
# the exact same polygons for native SVG/PDF output.
|
# the exact same polygons for native SVG/PDF output.
|
||||||
mesh_polygons = mesh_uv_pixel_polygons(
|
mesh_polygons = [pixel_polygon(poly) for poly in geometry['mesh']]
|
||||||
obj, texture_width, texture_height, uv_layer=uv_layer
|
mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3]
|
||||||
)
|
|
||||||
for pixel_poly in mesh_polygons:
|
for pixel_poly in mesh_polygons:
|
||||||
fill_polygon(
|
fill_polygon(
|
||||||
None, pixels, texture_width, texture_height,
|
None, pixels, texture_width, texture_height,
|
||||||
@@ -924,14 +914,12 @@ def generate_patch_texture(
|
|||||||
|
|
||||||
# Draw the visible patches and retain each editable vector shape.
|
# Draw the visible patches and retain each editable vector shape.
|
||||||
patch_shapes = []
|
patch_shapes = []
|
||||||
idx = 0
|
for i, j, uv_poly in geometry['patches']:
|
||||||
for j in range(ny):
|
color = patch_colors[j, i]
|
||||||
for i in range(nx):
|
|
||||||
uv_poly = patch_uv_polygons[idx]; idx += 1
|
|
||||||
if uv_poly is None: continue
|
|
||||||
color = patch_colors[j,i]
|
|
||||||
rgba = (color[0], color[1], color[2], 1.0)
|
rgba = (color[0], color[1], color[2], 1.0)
|
||||||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
pixel_poly = pixel_polygon(uv_poly)
|
||||||
|
if len(pixel_poly) < 3:
|
||||||
|
continue
|
||||||
patch_shapes.append((pixel_poly, rgba))
|
patch_shapes.append((pixel_poly, rgba))
|
||||||
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||||
|
|
||||||
@@ -1608,13 +1596,17 @@ def main():
|
|||||||
if config["seed"] is not None:
|
if config["seed"] is not None:
|
||||||
np.random.seed(config["seed"])
|
np.random.seed(config["seed"])
|
||||||
print(f"Using colour seed {config['seed']}")
|
print(f"Using colour seed {config['seed']}")
|
||||||
# One RNG snapshot is replayed for every target, so the same physical patch
|
# Both targets share the very same cell table and visibility-clipped fragments.
|
||||||
# keeps the same colour in each UV layout (and reruns reproduce when seeded).
|
patch_colors = precompute_cell_colors(config['nx'], config['ny'], config['color_r'])
|
||||||
rng_state = np.random.get_state()
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
geometry = build_patch_geometry(
|
||||||
|
obj, cam, scene, depsgraph, [name for name, _, _ in targets],
|
||||||
|
config['nx'], config['ny'], config['edge_sample_grid'])
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'ERROR: patch geometry failed: {exc}', file=sys.stderr)
|
||||||
|
return 1
|
||||||
for uv_map, artifacts, uv_layer in targets:
|
for uv_map, artifacts, uv_layer in targets:
|
||||||
np.random.set_state(rng_state)
|
|
||||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||||
print(f"--- UV map {uv_map!r}")
|
print(f"--- UV map {uv_map!r}")
|
||||||
try:
|
try:
|
||||||
@@ -1634,6 +1626,7 @@ def main():
|
|||||||
edge_sample_grid=config["edge_sample_grid"],
|
edge_sample_grid=config["edge_sample_grid"],
|
||||||
uv_layer=uv_layer,
|
uv_layer=uv_layer,
|
||||||
image_name=f"PatchTexture_{uv_map}",
|
image_name=f"PatchTexture_{uv_map}",
|
||||||
|
patch_colors=patch_colors, geometry=geometry[uv_map],
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Run with python -m unittest discover -s blender/scripts -p test_patch_geometry.py."""
|
||||||
|
import unittest
|
||||||
|
from patch_geometry import (area, barycentric, cell_fragments, clip_rect,
|
||||||
|
interpolate_uv, project_triangle, visible_triangles)
|
||||||
|
|
||||||
|
|
||||||
|
class GeometryTests(unittest.TestCase):
|
||||||
|
def test_perspective_correct(self):
|
||||||
|
points = project_triangle([(-1, -1, 0, 1), (2, -2, 0, 2), (-4, 4, 0, 4)])
|
||||||
|
p = tuple((a + b + c) / 3 for a, b, c in zip(*points))
|
||||||
|
self.assertAlmostEqual(sum(barycentric(p)), 1)
|
||||||
|
uv = interpolate_uv(p, [(0, 0), (1, 0), (0, 1)])
|
||||||
|
self.assertAlmostEqual(uv[0], 2 / 7)
|
||||||
|
self.assertAlmostEqual(uv[1], 1 / 7)
|
||||||
|
|
||||||
|
def test_quad_both_loop_triangles_cover_square(self):
|
||||||
|
corners = [(-1, -1, 0, 1), (1, -1, 0, 1), (1, 1, 0, 1), (-1, 1, 0, 1)]
|
||||||
|
fragments = []
|
||||||
|
for indices in ((0, 1, 2), (0, 2, 3)):
|
||||||
|
fragments.extend(cell_fragments([corners[k] for k in indices], 3, 2, lambda _: True))
|
||||||
|
self.assertAlmostEqual(sum(area(list(p)) for _, _, p in fragments), 1)
|
||||||
|
for i, j, poly in fragments:
|
||||||
|
self.assertTrue(all(i / 3 - 1e-12 <= p[0] <= (i + 1) / 3 + 1e-12 for p in poly))
|
||||||
|
self.assertTrue(all(j / 2 - 1e-12 <= p[1] <= (j + 1) / 2 + 1e-12 for p in poly))
|
||||||
|
|
||||||
|
def test_frustum_and_near_clipping(self):
|
||||||
|
poly = project_triangle([(-2, -1, -2, 1), (1, -1, 0, 1), (0, 2, 0, 1)])
|
||||||
|
self.assertGreater(len(poly), 2)
|
||||||
|
for p in poly:
|
||||||
|
self.assertTrue(0 <= p[0] <= 1 and 0 <= p[1] <= 1)
|
||||||
|
self.assertAlmostEqual(sum(barycentric(p)), 1)
|
||||||
|
self.assertTrue(all(w >= -1e-12 for w in barycentric(p)))
|
||||||
|
|
||||||
|
def test_behind_camera_is_empty(self):
|
||||||
|
self.assertEqual(project_triangle([(-1, -1, 0, -1), (1, -1, 0, -1), (0, 1, 0, -1)]), [])
|
||||||
|
|
||||||
|
def test_wide_uv_triangle_does_not_wrap(self):
|
||||||
|
poly = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
|
||||||
|
uvs = [(0.02, 0.1), (0.98, 0.1), (0.02, 0.9)]
|
||||||
|
self.assertEqual([interpolate_uv(p, uvs) for p in poly], uvs)
|
||||||
|
|
||||||
|
def test_seam_islands_remain_independent(self):
|
||||||
|
p = (0, 0, 1, 0.5, 0.25, 0.25)
|
||||||
|
left = interpolate_uv(p, [(0, 0), (.1, 0), (0, .1)])
|
||||||
|
right = interpolate_uv(p, [(.9, .9), (1, .9), (.9, 1)])
|
||||||
|
self.assertLess(left[0], .1)
|
||||||
|
self.assertGreater(right[0], .9)
|
||||||
|
|
||||||
|
def test_hidden_rejected(self):
|
||||||
|
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
|
||||||
|
self.assertEqual(list(visible_triangles(tri, lambda _: False, 1, 1)), [])
|
||||||
|
|
||||||
|
def test_mixed_visibility_conservative(self):
|
||||||
|
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
|
||||||
|
accepted = list(visible_triangles(tri, lambda w: w[1] < .4, 1, 1))
|
||||||
|
self.assertTrue(accepted)
|
||||||
|
self.assertTrue(all(barycentric(p)[1] < .4 for t in accepted for p in t))
|
||||||
|
self.assertLess(sum(area(list(t)) for t in accepted), .5)
|
||||||
|
|
||||||
|
def test_unresolved_depth_limit_rejected(self):
|
||||||
|
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
|
||||||
|
self.assertEqual(list(visible_triangles(tri, lambda w: w[1] < .4, 1, 1, depth=10)), [])
|
||||||
|
|
||||||
|
def test_uv_tile_clips_without_repeating(self):
|
||||||
|
poly = clip_rect([(-.2, .1), (.2, .1), (.2, .9)], 0, 1, 0, 1)
|
||||||
|
self.assertTrue(all(0 <= p[0] <= .2 for p in poly))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 606 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 6.0 MiB |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 815 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 5.9 MiB |
@@ -0,0 +1,67 @@
|
|||||||
|
# Xynova camera-projected patch investigation
|
||||||
|
|
||||||
|
## Intended pattern
|
||||||
|
|
||||||
|
The requested squares are defined in the image seen by `cam_sensor`, not in the
|
||||||
|
flat compute PNG or in physical surface distances. The current 1000×1000 camera
|
||||||
|
and 80×80 grid define nominal 12.5×12.5 camera-pixel cells. Silhouette and occlusion
|
||||||
|
boundaries may clip those cells.
|
||||||
|
|
||||||
|
Changing PNG width/height or forcing UV shapes to squares is not a correction of
|
||||||
|
the camera pattern. An independent raycast using Blender loop triangles, a BVH,
|
||||||
|
and triangle barycentric UV interpolation measured the following centre-region
|
||||||
|
steps (camera samples (0.5,0.5), (0.5125,0.5), (0.5,0.5125)):
|
||||||
|
|
||||||
|
| UV layout | Horizontal step in texture pixels | Vertical step | Ratio |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| compute | 59.3465 | 50.7074 | 1.17037 |
|
||||||
|
| produce | 106.1594 | 90.2955 | 1.17569 |
|
||||||
|
|
||||||
|
These are local mapped edge lengths, not axis-aligned bounding boxes. They show
|
||||||
|
that camera-square elements can legitimately look rectangular in the flat UV
|
||||||
|
artwork. Curved surfaces also make the distortion position-dependent.
|
||||||
|
|
||||||
|
## Confirmed interpolation defect
|
||||||
|
|
||||||
|
The investigated blend contains 4,818 triangles and 16 quads with no modifiers.
|
||||||
|
A valid quad does not need to be removed or manually triangulated to be usable.
|
||||||
|
However, the original `get_uv_from_face()` always selected a polygon's first
|
||||||
|
three vertices, even for a ray hitting the other tessellated half of a quad. It
|
||||||
|
also formed barycentric weights from unsigned triangle areas, which are not a
|
||||||
|
valid containment-aware extrapolation outside that first triangle.
|
||||||
|
|
||||||
|
At an actual tessellated triangle centre on compute polygon 4826:
|
||||||
|
|
||||||
|
- Correct UV: approximately `(0.0480605, 0.2511341)`.
|
||||||
|
- Original generator UV: approximately `(-0.0245351, 0.3876915)`.
|
||||||
|
|
||||||
|
The error is hundreds of output pixels and can leave the tile. The original
|
||||||
|
periodic-wrapping heuristic can then relocate the bad polygon into another part
|
||||||
|
of the texture. Another tested compute polygon (2876) produced a UV error of
|
||||||
|
approximately 0.1471, mostly in V.
|
||||||
|
|
||||||
|
## Separate seam-construction weakness
|
||||||
|
|
||||||
|
The original generator connected independently raycast camera-cell corners into
|
||||||
|
one UV polygon (or their convex hull near the silhouette). That loses the face
|
||||||
|
and UV-chart identity of each sample. A cell spanning a UV seam cannot generally
|
||||||
|
be represented by one polygon connecting all its samples. A UV-span threshold
|
||||||
|
or a final raster mask can hide some symptoms, but neither repairs the mapping
|
||||||
|
and a raster-only mask leaves incorrect SVG/PDF geometry.
|
||||||
|
|
||||||
|
The approved correction uses triangle-local camera-cell intersections, keeps
|
||||||
|
separate UV fragments across seams, and applies perspective-correct interpolation.
|
||||||
|
Verification must check the vector fragments against the actual UV triangles and
|
||||||
|
check their reprojection against the camera grid, not merely inspect the PNG.
|
||||||
|
|
||||||
|
## Source preservation baseline
|
||||||
|
|
||||||
|
Source: `sensors/finger-xynova/r01/source/geometry.blend`
|
||||||
|
|
||||||
|
SHA-256 recorded before implementation:
|
||||||
|
|
||||||
|
`574e1e4501db224e025a0b44327afa53726d71a25c36aa8c36d8c45a6f0317b2`
|
||||||
|
|
||||||
|
The blend was already modified relative to Git HEAD when this investigation
|
||||||
|
started. That user modification must be preserved. No camera configuration,
|
||||||
|
mesh editing, UV re-unwrapping, staging, or commits are part of this repair.
|
||||||
+3
-1
@@ -24,4 +24,6 @@ orisys-design = "orisys_sensor_design.cli:main"
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
# blender/scripts holds the stdlib-only patch-geometry helpers and their unit
|
||||||
|
# tests; those tests do not import bpy, so they run in the normal suite.
|
||||||
|
testpaths = ["tests", "blender/scripts"]
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,293 @@
|
|||||||
|
"""Read-only Blender verification of native patch SVGs; no generator imports.
|
||||||
|
|
||||||
|
Usage (from repository root):
|
||||||
|
blender --background --factory-startup --python-exit-code 1 --python \
|
||||||
|
tools/verify_xynova_texture.py -- --job build/textures/finger-xynova/r01/_texture_job.json
|
||||||
|
|
||||||
|
Optional --svg FILE --uv-map produce verifies a candidate instead of job outputs.
|
||||||
|
JSON evidence is printed, never written by this script. Redirect stdout outside
|
||||||
|
production outputs if desired. --expected-blend-sha256 HASH pins a baseline.
|
||||||
|
|
||||||
|
Checks raw (not periodic-unwrapped) loop-triangle UV union by geometric polygon
|
||||||
|
subtraction, including polygon interiors, not just sampled vertices or a raster
|
||||||
|
mask. Pixel coordinates use the exporter's (width-1,height-1) UV convention.
|
||||||
|
Camera-space geometry is recovered through barycentric UV->world interpolation
|
||||||
|
and Blender's own world_to_camera_view. A patch must fit one nx/ny grid cell;
|
||||||
|
clipped triangle fragments need not themselves be squares. Grid tolerance is in
|
||||||
|
camera pixels; UV area tolerance is in texture pixels squared. Non-square UV
|
||||||
|
polygons are expected on curved geometry and are not inherently defects.
|
||||||
|
|
||||||
|
Limits: floating-point tolerances (not symbolic exact arithmetic); native SVG
|
||||||
|
polygon vocabulary only; overlapping UV islands have ambiguous inverse mapping
|
||||||
|
and are reported as inconclusive for camera verification. No visibility, color,
|
||||||
|
cell completeness, PNG/PDF consistency, or render certification. Modifiers and
|
||||||
|
non-perspective cameras are rejected rather than silently using wrong geometry.
|
||||||
|
Source blend is opened in memory, NEVER saved. Hashes are checked before/after.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import numpy as np
|
||||||
|
from bpy_extras.object_utils import world_to_camera_view
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cross(a, b):
|
||||||
|
return a[0] * b[1] - a[1] * b[0]
|
||||||
|
|
||||||
|
|
||||||
|
def area(p):
|
||||||
|
if len(p) < 3:
|
||||||
|
return 0.0
|
||||||
|
return abs(sum(cross(p[i] - p[0], p[i + 1] - p[0])
|
||||||
|
for i in range(1, len(p) - 1))) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def halfplane(p, a, b, inside):
|
||||||
|
"""Clip convex polygon to one directed line; no raster approximations."""
|
||||||
|
if not len(p):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
s = p[-1]
|
||||||
|
ds = cross(b - a, s - a) * inside
|
||||||
|
for e in p:
|
||||||
|
de = cross(b - a, e - a) * inside
|
||||||
|
if (ds >= 0) != (de >= 0):
|
||||||
|
out.append(s + (e - s) * (ds / (ds - de)))
|
||||||
|
if de >= 0:
|
||||||
|
out.append(e)
|
||||||
|
s, ds = e, de
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def subtract_triangle(p, tri):
|
||||||
|
"""Return disjoint convex pieces of p outside tri, plus intersection."""
|
||||||
|
remaining = p
|
||||||
|
outside = []
|
||||||
|
for i in range(3):
|
||||||
|
a, b = tri[i], tri[(i + 1) % 3]
|
||||||
|
piece = halfplane(remaining, a, b, -1)
|
||||||
|
if area(piece) > 1e-12:
|
||||||
|
outside.append(piece)
|
||||||
|
remaining = halfplane(remaining, a, b, 1)
|
||||||
|
if area(remaining) < 1e-12:
|
||||||
|
return outside, []
|
||||||
|
return outside, remaining
|
||||||
|
|
||||||
|
|
||||||
|
def convex_parts(p):
|
||||||
|
signed = [cross(p[(i + 1) % len(p)] - p[i],
|
||||||
|
p[(i + 2) % len(p)] - p[(i + 1) % len(p)]) for i in range(len(p))]
|
||||||
|
if min(signed) >= -1e-9 or max(signed) <= 1e-9:
|
||||||
|
return [p]
|
||||||
|
return [[np.array((v.x, v.y), dtype=float) for v in tri]
|
||||||
|
for tri in tessellate_polygon([[Vector((x, y, 0)) for x, y in p]])]
|
||||||
|
|
||||||
|
|
||||||
|
def read_patches(path, width, height):
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
if [float(v) for v in root.attrib['viewBox'].split()] != [0, 0, width, height]:
|
||||||
|
raise ValueError('SVG viewBox differs from job dimensions')
|
||||||
|
groups = {e.get('id'): e for e in root.iter() if e.tag.endswith('}g')}
|
||||||
|
artwork, patches = groups['artwork'], groups['patches']
|
||||||
|
compact = re.sub(r'\s+', '', artwork.get('transform', ''))
|
||||||
|
if compact != f'translate(0{height})scale(1-1)':
|
||||||
|
raise ValueError('Unsupported artwork transform; expected native Y flip')
|
||||||
|
if patches not in list(artwork) or patches.get('transform'):
|
||||||
|
raise ValueError('Unsupported patch hierarchy/transform')
|
||||||
|
result = []
|
||||||
|
for e in patches:
|
||||||
|
if not e.tag.endswith('}polygon') or e.get('transform'):
|
||||||
|
raise ValueError('Only untransformed native patch polygons supported')
|
||||||
|
values = [float(x) for x in re.split(r'[\s,]+', e.attrib['points'].strip())]
|
||||||
|
p = np.asarray(values, dtype=float).reshape(-1, 2)
|
||||||
|
if len(p) < 3 or not np.all(np.isfinite(p)):
|
||||||
|
raise ValueError('Invalid patch polygon')
|
||||||
|
result.append((e.get('id'), p))
|
||||||
|
if not result:
|
||||||
|
raise ValueError('SVG has no patches')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mesh_data(obj, uv_name, width, height):
|
||||||
|
mesh = obj.data
|
||||||
|
mesh.calc_loop_triangles()
|
||||||
|
layer = mesh.uv_layers.get(uv_name)
|
||||||
|
if layer is None:
|
||||||
|
raise ValueError('Missing UV map: ' + uv_name)
|
||||||
|
triangles, world, face_ids = [], [], []
|
||||||
|
degenerate = 0
|
||||||
|
for t in mesh.loop_triangles:
|
||||||
|
uv = np.asarray([tuple(layer.data[i].uv) for i in t.loops], dtype=float)
|
||||||
|
uv *= (width - 1, height - 1)
|
||||||
|
xyz = np.asarray([tuple(obj.matrix_world @ mesh.vertices[i].co) for i in t.vertices])
|
||||||
|
det = cross(uv[1] - uv[0], uv[2] - uv[0])
|
||||||
|
if abs(det) < 1e-10:
|
||||||
|
degenerate += 1
|
||||||
|
continue
|
||||||
|
if det < 0:
|
||||||
|
uv = uv[[0, 2, 1]]
|
||||||
|
xyz = xyz[[0, 2, 1]]
|
||||||
|
triangles.append(uv)
|
||||||
|
world.append(xyz)
|
||||||
|
face_ids.append(t.polygon_index)
|
||||||
|
tris = np.asarray(triangles)
|
||||||
|
if not len(tris):
|
||||||
|
raise ValueError('No nondegenerate UV loop triangles')
|
||||||
|
return tris, np.asarray(world), face_ids, degenerate
|
||||||
|
|
||||||
|
|
||||||
|
def verify(svg, uv_name, obj, cam, scene, settings, args):
|
||||||
|
w, h = settings['width'], settings['height']
|
||||||
|
nx, ny = settings['nx'], settings['ny']
|
||||||
|
tris, xyz, faces, degenerate = mesh_data(obj, uv_name, w, h)
|
||||||
|
tmin, tmax = tris.min(axis=1), tris.max(axis=1)
|
||||||
|
inverses = np.linalg.inv(np.stack((tris[:, 1] - tris[:, 0], tris[:, 2] - tris[:, 0]), axis=2))
|
||||||
|
patches = read_patches(svg, w, h)
|
||||||
|
camera_size = np.asarray((scene.render.resolution_x, scene.render.resolution_y), dtype=float)
|
||||||
|
grid = np.asarray((nx, ny), dtype=float)
|
||||||
|
tol = args.camera_tolerance_px / camera_size
|
||||||
|
outside_total = 0.0
|
||||||
|
outside_fail = []
|
||||||
|
camera_fail = []
|
||||||
|
ambiguous = []
|
||||||
|
camera_checked = 0
|
||||||
|
camera_bounds = {}
|
||||||
|
worst_span = np.zeros(2)
|
||||||
|
max_outside = 0.0
|
||||||
|
for patch_id, poly in patches:
|
||||||
|
parts = convex_parts(poly)
|
||||||
|
parea = sum(area(p) for p in parts)
|
||||||
|
candidates = np.flatnonzero(np.all(tmax >= poly.min(axis=0), axis=1) &
|
||||||
|
np.all(tmin <= poly.max(axis=0), axis=1))
|
||||||
|
remaining = parts
|
||||||
|
projected = []
|
||||||
|
sum_intersections = 0.0
|
||||||
|
for k in candidates:
|
||||||
|
# Intersect original polygon separately: UV overlaps must not be
|
||||||
|
# silently assigned to whichever triangle happened to occur first.
|
||||||
|
for p in parts:
|
||||||
|
_, inter = subtract_triangle(p, tris[k])
|
||||||
|
if area(inter) > 1e-10:
|
||||||
|
sum_intersections += area(inter)
|
||||||
|
ab = (inverses[k] @ (np.asarray(inter) - tris[k, 0]).T).T
|
||||||
|
points = xyz[k, 0] + ab[:, :1] * (xyz[k, 1] - xyz[k, 0]) + ab[:, 1:] * (xyz[k, 2] - xyz[k, 0])
|
||||||
|
projected.extend(tuple(world_to_camera_view(scene, cam, Vector(pt))) for pt in points)
|
||||||
|
next_remaining = []
|
||||||
|
for p in remaining:
|
||||||
|
pieces, _ = subtract_triangle(p, tris[k])
|
||||||
|
next_remaining.extend(pieces)
|
||||||
|
remaining = next_remaining
|
||||||
|
outside = sum(area(p) for p in remaining)
|
||||||
|
outside_total += outside
|
||||||
|
max_outside = max(max_outside, outside)
|
||||||
|
if outside > args.area_tolerance_px2:
|
||||||
|
outside_fail.append({'id': patch_id, 'outside_px2': outside, 'patch_px2': parea})
|
||||||
|
if sum_intersections > parea - outside + args.area_tolerance_px2:
|
||||||
|
ambiguous.append(patch_id)
|
||||||
|
continue
|
||||||
|
if not projected or outside > args.area_tolerance_px2:
|
||||||
|
continue
|
||||||
|
q = np.asarray(projected)
|
||||||
|
low, high = q[:, :2].min(axis=0), q[:, :2].max(axis=0)
|
||||||
|
cell = np.floor((low + high) * 0.5 * grid).astype(int)
|
||||||
|
worst_span = np.maximum(worst_span, (high - low) * camera_size)
|
||||||
|
camera_checked += 1
|
||||||
|
if (np.any(low < cell / grid - tol) or np.any(high > (cell + 1) / grid + tol)
|
||||||
|
or np.any(cell < 0) or np.any(cell >= grid) or np.any(q[:, 2] <= 0)):
|
||||||
|
camera_fail.append({'id': patch_id, 'camera_min': low.tolist(), 'camera_max': high.tolist()})
|
||||||
|
else:
|
||||||
|
key = tuple(cell)
|
||||||
|
old = camera_bounds.get(key, (low, high))
|
||||||
|
camera_bounds[key] = (np.minimum(old[0], low), np.maximum(old[1], high))
|
||||||
|
square_bounds = sum(np.all(np.abs(lo - np.array(key) / grid) <= tol) and
|
||||||
|
np.all(np.abs(hi - (np.array(key) + 1) / grid) <= tol)
|
||||||
|
for key, (lo, hi) in camera_bounds.items())
|
||||||
|
status = 'fail' if outside_fail or camera_fail else ('inconclusive' if ambiguous else 'pass')
|
||||||
|
return {'svg': str(svg), 'svg_sha256': sha256(svg), 'uv_map': uv_name, 'status': status,
|
||||||
|
'patch_count': len(patches), 'raw_loop_triangle_count': len(tris),
|
||||||
|
'degenerate_uv_triangles': degenerate, 'outside_union_total_px2': outside_total,
|
||||||
|
'outside_union_max_patch_px2': max_outside, 'outside_union_failed_patches': len(outside_fail),
|
||||||
|
'outside_union_examples': sorted(outside_fail, key=lambda x: -x['outside_px2'])[:10],
|
||||||
|
'camera_checked_patches': camera_checked, 'camera_failed_patches': len(camera_fail),
|
||||||
|
'camera_failure_examples': camera_fail[:10], 'ambiguous_uv_overlap_patches': len(ambiguous),
|
||||||
|
'ambiguous_examples': ambiguous[:10], 'max_camera_patch_span_px': worst_span.tolist(),
|
||||||
|
'camera_cells_touched': len(camera_bounds), 'cells_with_full_grid_aligned_bounds': int(square_bounds),
|
||||||
|
'note': 'Full cell bounds do not prove filled area/completeness. UV distortion is expected; outside-union area and multi-cell reprojection are defects.'}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument('--job', type=Path, required=True)
|
||||||
|
parser.add_argument('--svg', type=Path)
|
||||||
|
parser.add_argument('--uv-map')
|
||||||
|
parser.add_argument('--expected-blend-sha256')
|
||||||
|
parser.add_argument('--area-tolerance-px2', type=float, default=0.02,
|
||||||
|
help='Allowed numerical/6-decimal SVG quantization area per patch (default .02 texture px²)')
|
||||||
|
parser.add_argument('--camera-tolerance-px', type=float, default=0.05)
|
||||||
|
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
|
||||||
|
if bool(args.svg) != bool(args.uv_map):
|
||||||
|
parser.error('--svg and --uv-map must be supplied together')
|
||||||
|
if args.area_tolerance_px2 < 0 or args.camera_tolerance_px < 0:
|
||||||
|
parser.error('Tolerances must be nonnegative')
|
||||||
|
job = json.loads(args.job.read_text(encoding='utf-8'))
|
||||||
|
blend = Path(job['source_blend'])
|
||||||
|
before = sha256(blend)
|
||||||
|
if args.expected_blend_sha256 and before.lower() != args.expected_blend_sha256.lower():
|
||||||
|
raise ValueError('Source blend differs from expected baseline hash')
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
bpy.ops.wm.open_mainfile(filepath=str(blend))
|
||||||
|
if bpy.context.object and bpy.context.object.mode != 'OBJECT':
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
scene = bpy.context.scene
|
||||||
|
obj = bpy.data.objects[job['objects']['mesh']]
|
||||||
|
cam = bpy.data.objects[job['objects']['camera']]
|
||||||
|
if any(m.show_viewport or m.show_render for m in obj.modifiers):
|
||||||
|
raise ValueError('Enabled mesh modifiers: cannot certify original/evaluated correspondence')
|
||||||
|
if cam.data.type != 'PERSP':
|
||||||
|
raise ValueError('Only perspective camera supported')
|
||||||
|
settings = job['settings']
|
||||||
|
outputs = [{'uv_map': args.uv_map, 'artifacts': {'svg': str(args.svg)}}] if args.svg else job['outputs']
|
||||||
|
for output in outputs:
|
||||||
|
results.append(verify(Path(output['artifacts']['svg']), output['uv_map'], obj, cam, scene, settings, args))
|
||||||
|
pixel_aspect = scene.render.pixel_aspect_x / scene.render.pixel_aspect_y
|
||||||
|
camera_aspect = scene.render.resolution_x * pixel_aspect / scene.render.resolution_y
|
||||||
|
square = abs(camera_aspect * settings['ny'] / settings['nx'] - 1) < 1e-8
|
||||||
|
report = {'blender_version': bpy.app.version_string, 'source_blend': str(blend),
|
||||||
|
'source_sha256_before': before, 'source_sha256_after': sha256(blend),
|
||||||
|
'camera_resolution': [scene.render.resolution_x, scene.render.resolution_y],
|
||||||
|
'camera_pixel_aspect': pixel_aspect, 'grid': [settings['nx'], settings['ny']],
|
||||||
|
'camera_cells_are_square': square,
|
||||||
|
'area_tolerance_px2': args.area_tolerance_px2,
|
||||||
|
'camera_tolerance_px': args.camera_tolerance_px, 'results': results}
|
||||||
|
report['status'] = 'pass' if square and all(r['status'] == 'pass' for r in results) else 'fail_or_inconclusive'
|
||||||
|
print('XYNOVA_VERIFICATION_JSON_BEGIN', flush=True)
|
||||||
|
print(json.dumps(report, indent=2), flush=True)
|
||||||
|
print('XYNOVA_VERIFICATION_JSON_END', flush=True)
|
||||||
|
if report['status'] != 'pass':
|
||||||
|
raise RuntimeError('Texture verification failed or was inconclusive; see JSON evidence')
|
||||||
|
finally:
|
||||||
|
after = sha256(blend)
|
||||||
|
if after != before:
|
||||||
|
raise RuntimeError('SOURCE BLEND HASH CHANGED DURING VERIFICATION')
|
||||||
|
print('Source blend SHA256 unchanged: ' + after, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user