Files
orisys-sensor-design/blender/scripts/patch_projection.py
T
ykcheungab 267334d543 fix cli redundant images
finger-xynova first design completed
2026-09-28 16:18:17 +08:00

210 lines
10 KiB
Python

"""Blender adapter for evaluated, camera-local loop-triangle fragments."""
import math
from patch_geometry import barycentric, cell_fragments, interpolate_uv
def _polygon_area_3d(points):
"""Area of a planar polygon given its vertices in order (fan from vertex 0)."""
if len(points) < 3:
return 0.0
total = 0.0
for k in range(1, len(points) - 1):
total += (points[k] - points[0]).cross(points[k + 1] - points[0]).length
return total * 0.5
def _point_segment_distance(point, a, b):
"""Shortest 3D distance from ``point`` to the segment ``a``-``b``."""
ab = b - a
denom = ab.dot(ab)
if denom <= 0.0:
return (point - a).length
t = (point - a).dot(ab) / denom
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
return (point - (a + ab * t)).length
def _min_seam_distance(point, segments):
"""Shortest distance from ``point`` to any of the seam segments."""
best = None
for a, b in segments:
d = _point_segment_distance(point, a, b)
if best is None or d < best:
best = d
return best if best is not None else float("inf")
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid,
fill_unresolved_leaves=False, seam_segments=None,
seam_search_mm=0.0):
"""Evaluated loop-triangle fragments for one camera, per UV target.
``fill_unresolved_leaves`` accepts fragments whose visibility could not be resolved at
the finest subdivision instead of leaving them unpainted. It trades a slight stretch at
silhouette-adjacent leaves for a marker with no holes; the default is the conservative
behaviour, so single-view artifacts stay byte-identical.
``seam_segments`` is the authored seam as world-space ``(a, b)`` pairs. When given, every
fragment is measured against it and the distance is reported as
``fragment_seam_distance_mm``, which is what the seam margin filters on. Only triangles
whose nearest vertex is within ``seam_search_mm`` plus the triangle's own radius are
measured exactly; everything else is reported as ``inf`` (it cannot be inside a margin of
that size, and the exact number would be wasted work on the whole surface).
"""
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
# Triangle-level and fragment-level records, shared by every UV target: the
# fragment walk is identical for all of them (they differ only in which layer
# the barycentrics are resolved against), so recording it per target would be
# the same numbers repeated. Consumed by ownership assignment; the single-view
# path never reads them.
triangle_vertices = []
triangle_area_mm2 = []
fragment_triangle = []
fragment_incidence_deg = []
fragment_footprint_mm2 = []
fragment_seam_distance_mm = []
triangle_seam_distance_mm = []
if seam_segments:
# Per-vertex distance to the seam polyline, computed once. A fragment's distance is
# then only worth measuring exactly where its triangle can plausibly reach the
# margin, which keeps this off the whole surface.
vertex_seam_distance = [
_min_seam_distance(evaluated.matrix_world @ vertex.co, seam_segments)
for vertex in mesh.vertices
]
else:
vertex_seam_distance = None
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])
triangle_vertices.append(tuple(triangle.vertices))
triangle_area_mm2.append(_polygon_area_3d(world))
if vertex_seam_distance is not None:
triangle_near = min(vertex_seam_distance[i] for i in triangle.vertices)
node = sum(world, Vector()) / 3.0
radius = max((p - node).length for p in world)
triangle_seam_distance_mm.append(triangle_near)
else:
triangle_near = radius = None
# Incidence of this triangle to the camera. ``abs`` on purpose: the mesh
# carries inward-facing normals on most faces, and ownership cares about the
# angle between the surface and the view ray, not about winding. Whether the
# winding is a defect is reported separately, not decided here.
normal = (world[1] - world[0]).cross(world[2] - world[0])
centroid = (world[0] + world[1] + world[2]) / 3.0
if normal.length > 1e-15:
normal.normalize()
to_camera = (origin - centroid) if camera.data.type == 'PERSP' else -forward
if to_camera.length > 1e-15:
cosine = min(1.0, max(0.0, abs(normal.dot(to_camera.normalized()))))
incidence_deg = math.degrees(math.acos(cosine))
else:
incidence_deg = 90.0
else:
incidence_deg = 90.0
cache = {}
# ``cache`` and ``world`` are bound as defaults: ``cache`` is rebound
# for every loop triangle, and a late-bound closure would read the
# next triangle's cache (or the final triangle's, if a probe were
# deferred), yielding a wrong visibility answer per triangle.
def visible(weights, cache=cache, world=world):
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,
fill_unresolved_leaves):
# Fragments are triangles (cell_fragments fans and refines to triples),
# so the surface footprint is exact, not an approximation.
points = [sum((p * w for p, w in zip(world, barycentric(p))), Vector())
for p in fragment]
fragment_triangle.append(triangle.index)
fragment_incidence_deg.append(incidence_deg)
fragment_footprint_mm2.append(_polygon_area_3d(points))
if vertex_seam_distance is not None:
if triangle_near <= seam_search_mm + radius:
fragment_seam_distance_mm.append(
min(_min_seam_distance(point, seam_segments) for point in points)
)
else:
# Cannot be inside any margin this size; skip the exact measurement.
fragment_seam_distance_mm.append(float("inf"))
for name in uv_names:
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
output[name]['patches'].append((i, j, polygon))
output['_meta'] = {
'triangle_count': len(mesh.loop_triangles),
'triangle_vertices': triangle_vertices,
'triangle_area_mm2': triangle_area_mm2,
'fragment_triangle': fragment_triangle,
'fragment_incidence_deg': fragment_incidence_deg,
'fragment_footprint_mm2': fragment_footprint_mm2,
# None (rather than a list) when no seam was supplied, so a consumer can tell
# "not measured" from "measured and far away" without guessing.
'fragment_seam_distance_mm': fragment_seam_distance_mm or None,
'triangle_seam_distance_mm': triangle_seam_distance_mm or None,
'camera_type': camera.data.type,
}
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('Visibility sampling: seven probes per fragment; uniformly probed '
'fragments are accepted or dropped whole, mixed boundary fragments '
'refine to 1/4 cell or depth 6 and unresolved leaves are rejected. '
'Sub-sample occluders may be missed; hit tolerance max(1e-7, distance*2e-5).')
return output
finally:
evaluated.to_mesh_clear()