fixed xynova texture generation problem
This commit is contained in:
@@ -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