fix cli redundant images
finger-xynova first design completed
This commit is contained in:
@@ -1,24 +1,29 @@
|
||||
"""Multi-view production texture generator — step 1: one independent texture per view.
|
||||
"""Multi-view production texture generator with per-triangle ownership.
|
||||
|
||||
blender -b sensors/finger/r01/source/geometry.blend \\
|
||||
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json>
|
||||
|
||||
Each *pair* in the job is ``(camera, uv_map, artifacts)`` and is generated exactly like a
|
||||
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour
|
||||
table is seeded, and the result is written into that UV layout. There is no ownership
|
||||
partition, no seam handling and no merge — two pairs may cover the same surface region and
|
||||
the overlap is deliberately left unresolved (see ``texture_views.py`` for why the merge has
|
||||
to happen in surface space, and why it is a later step).
|
||||
Each *pair* is a production view: a REGION camera, the UV layout it writes into, and the
|
||||
artifacts. All pairs share one PATTERN camera (normally the sensing camera), so every layout
|
||||
carries the same surface pattern — `uv_1` and `uv_2` are different parameterisations of one
|
||||
pattern, not two patterns.
|
||||
|
||||
Why the mask exists: both layouts are full-surface, so emitting every fragment into both
|
||||
files would double-ink essentially the whole part. Every loop triangle is therefore assigned
|
||||
to exactly one pair (`texture_merge.assign_ownership`) and each file emits only the fragments
|
||||
of the triangles it owns. Zero double ink is then a property of the partition rather than
|
||||
something measured after printing.
|
||||
|
||||
This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are
|
||||
untouched, and the helpers below are imported from it rather than forked, so the raster,
|
||||
SVG, PDF, clipping and colour code paths stay identical between the two entry points.
|
||||
untouched, and the helpers below are imported from it rather than forked, so the raster, SVG,
|
||||
PDF, clipping and colour code paths stay identical between the two entry points.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -26,6 +31,7 @@ from typing import Any
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
# Blender -P does not consistently include the script directory on sys.path.
|
||||
_SCRIPT_DIR = str(Path(__file__).resolve().parent)
|
||||
@@ -43,8 +49,44 @@ from random_patch_dd02c import ( # noqa: E402
|
||||
generate_patch_texture,
|
||||
precompute_cell_colors,
|
||||
)
|
||||
from texture_merge import ( # noqa: E402
|
||||
MergeConfigError,
|
||||
MergePolicy,
|
||||
ViewFragments,
|
||||
assign_ownership,
|
||||
components_from_seam,
|
||||
validate_seam,
|
||||
)
|
||||
import seam_gap # noqa: E402
|
||||
|
||||
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
|
||||
GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1"
|
||||
GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
|
||||
PRINT_VIEWS_SCHEMA = "orisys-sensor-design/print-views-v1"
|
||||
PRINT_VIEWS_FILENAME = "_print_views.json"
|
||||
|
||||
#: Line tags the host routes on (`src/orisys_sensor_design/console.py`). Kept as literals
|
||||
#: because one process runs under Blender and the other is stdlib-only; a host-side test
|
||||
#: asserts the two agree.
|
||||
#:
|
||||
#: Everything this script prints is tagged and the **host decides what to show**, rather than
|
||||
#: the script suppressing its own output. That matters on failure: the host replays the whole
|
||||
#: child stdout at every level, so a quiet run must still have produced something to replay.
|
||||
#: Untagged output — Blender's banner, the shared generator's `… saved to …` lines and
|
||||
#: `patch_projection`'s census — is treated by the host as internal and shown only at
|
||||
#: `--verbose`.
|
||||
REPORT_PREFIX = "::report::"
|
||||
DETAIL_PREFIX = "::detail::"
|
||||
|
||||
|
||||
def report(message: str) -> None:
|
||||
"""A decision or scene fact: shown by default."""
|
||||
print(f"{REPORT_PREFIX} {message}", flush=True)
|
||||
|
||||
|
||||
def detail(message: str) -> None:
|
||||
"""An internal: shown only with `--verbose`."""
|
||||
print(f"{DETAIL_PREFIX} {message}", flush=True)
|
||||
DEFAULT_MESH = "sensing_surface"
|
||||
ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||
_DEFAULT_COLOR_R = 0.05
|
||||
@@ -110,6 +152,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw = job.get("pairs")
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise ConfigError("job 'pairs' must be a non-empty list")
|
||||
default_pattern = job.get("pattern_camera")
|
||||
pairs: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_paths: dict[str, str] = {}
|
||||
@@ -125,7 +168,13 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
seen_ids.add(pair_id)
|
||||
camera = entry.get("camera")
|
||||
if not isinstance(camera, str) or not camera.strip():
|
||||
raise ConfigError(f"{where}.camera: must be a non-empty string")
|
||||
raise ConfigError(f"{where}.camera: must be a non-empty region camera name")
|
||||
pattern_camera = entry.get("pattern_camera") or default_pattern
|
||||
if not isinstance(pattern_camera, str) or not pattern_camera.strip():
|
||||
raise ConfigError(
|
||||
f"{where}.pattern_camera: must name the pattern camera (or set the "
|
||||
"job-level 'pattern_camera')"
|
||||
)
|
||||
uv_map = entry.get("uv_map")
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
raise ConfigError(f"{where}.uv_map: must be a non-empty string")
|
||||
@@ -154,6 +203,7 @@ def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
{
|
||||
"id": pair_id,
|
||||
"camera": camera,
|
||||
"pattern_camera": pattern_camera,
|
||||
"uv_map": uv_map,
|
||||
"seed": None if seed is None else int(seed),
|
||||
"artifacts": paths,
|
||||
@@ -184,8 +234,80 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
||||
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
||||
raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})")
|
||||
|
||||
merge = job.get("merge")
|
||||
if merge is not None and not isinstance(merge, dict):
|
||||
raise ConfigError("job 'merge' must be a mapping")
|
||||
try:
|
||||
policy = MergePolicy(**(merge or {}))
|
||||
except (MergeConfigError, TypeError) as exc:
|
||||
raise ConfigError(f"job 'merge': {exc}") from exc
|
||||
|
||||
seam = job.get("seam") or {}
|
||||
if not isinstance(seam, dict):
|
||||
raise ConfigError("job 'seam' must be a mapping")
|
||||
boundary = seam.get("boundary", "use_seam")
|
||||
if boundary != "use_seam":
|
||||
raise ConfigError(
|
||||
f"job 'seam.boundary': only 'use_seam' is supported (got {boundary!r})"
|
||||
)
|
||||
pins: dict[int, str] = {}
|
||||
for index, pin in enumerate(seam.get("pins") or []):
|
||||
if not isinstance(pin, dict):
|
||||
raise ConfigError(f"job 'seam.pins[{index}]': must be a mapping")
|
||||
try:
|
||||
component = int(pin["component"])
|
||||
view = str(pin["view"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ConfigError(
|
||||
f"job 'seam.pins[{index}]': needs integer 'component' and string 'view'"
|
||||
) from exc
|
||||
if component in pins:
|
||||
raise ConfigError(f"job 'seam.pins[{index}]': duplicate pin for component {component}")
|
||||
pins[component] = view
|
||||
|
||||
gap = seam.get("gap") or {}
|
||||
if not isinstance(gap, dict):
|
||||
raise ConfigError("job 'seam.gap' must be a mapping")
|
||||
gap_treatment = str(gap.get("treatment", "no_print"))
|
||||
if gap_treatment != "no_print":
|
||||
raise ConfigError(
|
||||
f"job 'seam.gap.treatment': {gap_treatment!r} is not implemented; only 'no_print' "
|
||||
"does anything today"
|
||||
)
|
||||
gap_enabled = bool(gap.get("enabled", False))
|
||||
gap_apply = str(gap.get("apply", "post_process"))
|
||||
gap_inset_mm = float(gap.get("inset_mm", 0.0))
|
||||
gap_total_mm = float(gap.get("total_mm", 0.0))
|
||||
gap_tolerance_mm = float(gap.get("alignment_tolerance_mm", 0.0))
|
||||
if gap_enabled and gap_inset_mm <= 0.0:
|
||||
raise ConfigError(
|
||||
"job 'seam.gap': enabled but inset_mm is not positive; nothing would be removed"
|
||||
)
|
||||
if gap_apply not in ("post_process", "geometry_trim"):
|
||||
raise ConfigError(
|
||||
f"job 'seam.gap.apply': must be 'post_process' or 'geometry_trim' (got {gap_apply!r})"
|
||||
)
|
||||
|
||||
print_block = job.get("print")
|
||||
if print_block is not None and not isinstance(print_block, dict):
|
||||
raise ConfigError("job 'print' must be a mapping")
|
||||
base_fill = (print_block or {}).get("base_fill", "none")
|
||||
if base_fill not in ("none", "inked", "owned"):
|
||||
raise ConfigError(
|
||||
f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})"
|
||||
)
|
||||
|
||||
return {
|
||||
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
|
||||
"base_fill": base_fill,
|
||||
"gap": {
|
||||
"enabled": gap_enabled,
|
||||
"apply": gap_apply,
|
||||
"treatment": gap_treatment,
|
||||
"inset_mm": gap_inset_mm,
|
||||
"total_mm": gap_total_mm,
|
||||
"alignment_tolerance_mm": gap_tolerance_mm,
|
||||
},
|
||||
"width": _as_int(settings.get("width", 2048), "settings.width"),
|
||||
"height": _as_int(settings.get("height", 2048), "settings.height"),
|
||||
"vector_width_mm": _as_positive_number(
|
||||
@@ -207,10 +329,236 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
||||
cross.get("thickness_px", 3), "settings.cross.thickness_px"
|
||||
),
|
||||
"cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"),
|
||||
"policy": policy,
|
||||
"pins": pins,
|
||||
"pairs": _resolve_pairs(job),
|
||||
}
|
||||
|
||||
|
||||
def _seam_edges(obj) -> list[tuple[int, int]]:
|
||||
"""Marked ``use_seam`` edges, as vertex-index pairs.
|
||||
|
||||
Read from the object's own mesh: a seam is a topology flag, not an attribute, and the
|
||||
surface has no modifiers, so raw and evaluated vertex indices coincide. (UV *data* is a
|
||||
different matter — it is empty on the raw mesh and only materialises on the evaluated
|
||||
one, which is why every UV read goes through ``build_patch_geometry``.)
|
||||
"""
|
||||
return [
|
||||
(int(edge.vertices[0]), int(edge.vertices[1]))
|
||||
for edge in obj.data.edges
|
||||
if edge.use_seam
|
||||
]
|
||||
|
||||
|
||||
def _angles_from(cam_pos, centroid):
|
||||
"""Azimuth/elevation/polar of one position about one centroid, in whatever frame given."""
|
||||
import math
|
||||
from mathutils import Vector
|
||||
|
||||
direction = (Vector(cam_pos) - Vector(centroid))
|
||||
if direction.length <= 1e-12:
|
||||
raise SceneError("camera coincides with the mesh centroid; angle is undefined")
|
||||
distance = direction.length
|
||||
direction.normalize()
|
||||
return {
|
||||
"position_mm": [round(v, 6) for v in cam_pos],
|
||||
"view_direction": [round(v, 9) for v in direction],
|
||||
"distance_to_centroid_mm": round(distance, 6),
|
||||
"azimuth_deg": round(math.degrees(math.atan2(direction.y, direction.x)) % 360.0, 4),
|
||||
"elevation_deg": round(
|
||||
math.degrees(math.asin(max(-1.0, min(1.0, direction.z)))), 4
|
||||
),
|
||||
"polar_from_z_deg": round(
|
||||
math.degrees(math.acos(max(-1.0, min(1.0, direction.z)))), 4
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _view_angles(obj, cam, centroid_world):
|
||||
"""Camera pose as azimuth/elevation, in BOTH the world frame and the part's own frame.
|
||||
|
||||
Two frames, because they answer different questions and silently picking one is how a
|
||||
reader ends up comparing an export against Blender and concluding the export is wrong:
|
||||
|
||||
* ``world`` — measured against the scene axes. This is the frame Blender's Transform panel
|
||||
shows for the camera's location and rotation, so it is the one to check against.
|
||||
* ``object_local`` — measured against the mesh object's own axes. Physically meaningful
|
||||
for a body of revolution: ``polar_from_z_deg`` is then the angle between the view and the
|
||||
part's axis, which is the number that says how obliquely the view meets the surface.
|
||||
|
||||
They differ whenever the mesh object carries a rotation. On `finger-xynova/r01`
|
||||
`sensing_surface` is rotated 110 degrees about X, which alone flips the sign of the
|
||||
reported elevation between the two frames.
|
||||
|
||||
Azimuth is ``atan2(y, x)`` in the XY plane of the chosen frame, from +X, wrapping to
|
||||
[0, 360). Elevation is signed from that plane. ``polar_from_z_deg`` is from +Z.
|
||||
"""
|
||||
import math
|
||||
from mathutils import Vector
|
||||
|
||||
world_pos = cam.matrix_world.translation.copy()
|
||||
to_local = obj.matrix_world.inverted()
|
||||
local_pos = to_local @ world_pos
|
||||
local_centroid = to_local @ Vector(centroid_world)
|
||||
|
||||
out = {
|
||||
"name": cam.name,
|
||||
"type": cam.data.type,
|
||||
"world": _angles_from(world_pos, centroid_world),
|
||||
"object_local": _angles_from(local_pos, local_centroid),
|
||||
# Recorded so the two frames above can be reconciled by hand against Blender, and so a
|
||||
# non-unit camera scale cannot quietly change the framing unnoticed.
|
||||
"camera_transform": {
|
||||
"rotation_euler_deg": [
|
||||
round(math.degrees(v), 4) for v in cam.matrix_world.to_euler()
|
||||
],
|
||||
"scale": [round(v, 6) for v in cam.matrix_world.to_scale()],
|
||||
"parent": cam.parent.name if cam.parent else None,
|
||||
},
|
||||
"part_transform": {
|
||||
"rotation_euler_deg": [round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()],
|
||||
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
||||
},
|
||||
"ortho_scale_mm": round(float(cam.data.ortho_scale), 6) if cam.data.type == "ORTHO" else None,
|
||||
"lens_mm": round(float(cam.data.lens), 6) if cam.data.type == "PERSP" else None,
|
||||
"sensor_width_mm": round(float(cam.data.sensor_width), 6),
|
||||
"clip_start_mm": round(float(cam.data.clip_start), 6),
|
||||
"clip_end_mm": round(float(cam.data.clip_end), 6),
|
||||
}
|
||||
# Flat aliases in the WORLD frame, so the obvious key name means the obvious thing
|
||||
# (what Blender shows) instead of the part frame.
|
||||
out.update({
|
||||
k: v for k, v in out["world"].items()
|
||||
if k.endswith("_deg") or k in ("view_direction", "distance_to_centroid_mm", "position_mm")
|
||||
})
|
||||
out["azimuth_deg_frame"] = "world"
|
||||
return out
|
||||
|
||||
|
||||
def _diagnose_zero_fragments(obj, cam, scene, depsgraph, sample=400):
|
||||
"""Explain a camera that produced no fragments, naming whatever steals the rays.
|
||||
|
||||
The usual cause is not a bad camera but a duplicate or occluding object: the same surface
|
||||
present twice at identical coordinates, or housing geometry in front of it. Visibility
|
||||
requires the first hit to be *this* object, so either case rejects every fragment — and
|
||||
without this message the run just produces blank textures. Report the hit objects so the
|
||||
author knows what to remove or hide.
|
||||
"""
|
||||
from collections import Counter
|
||||
from mathutils import Vector
|
||||
|
||||
evaluated = obj.evaluated_get(depsgraph)
|
||||
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
try:
|
||||
mesh.calc_loop_triangles()
|
||||
view = cam.matrix_world.normalized().inverted()
|
||||
origin = cam.matrix_world.translation.copy()
|
||||
forward = (cam.matrix_world.to_quaternion() @ Vector((0.0, 0.0, -1.0))).normalized()
|
||||
triangles = list(mesh.loop_triangles)
|
||||
step = max(1, len(triangles) // max(1, sample))
|
||||
hits: Counter = Counter()
|
||||
in_frame = 0
|
||||
for triangle in triangles[::step]:
|
||||
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||
point = (world[0] + world[1] + world[2]) / 3.0
|
||||
depth = -(view @ point).z
|
||||
if depth <= 0.0:
|
||||
hits["behind the camera"] += 1
|
||||
continue
|
||||
in_frame += 1
|
||||
ray_origin = origin if cam.data.type == "PERSP" else point - forward * depth
|
||||
direction = point - ray_origin
|
||||
distance = direction.length
|
||||
if distance <= 1e-9:
|
||||
hits["degenerate ray"] += 1
|
||||
continue
|
||||
direction.normalize()
|
||||
tolerance = max(1e-7, distance * 2e-5)
|
||||
hit, location, _n, _i, hit_obj, _m = scene.ray_cast(
|
||||
depsgraph, ray_origin, direction, distance=distance + tolerance
|
||||
)
|
||||
if not hit or hit_obj is None:
|
||||
hits["nothing (ray missed)"] += 1
|
||||
continue
|
||||
name = getattr(hit_obj, "original", hit_obj).name
|
||||
if name == obj.name:
|
||||
hits[f"{name} (this object, but the hit point is off)"] += 1
|
||||
else:
|
||||
hits[name] += 1
|
||||
lines = [
|
||||
f"camera {cam.name!r}: sampled {len(triangles[::step])} triangles, "
|
||||
f"{in_frame} in front of the camera",
|
||||
]
|
||||
for name, count in hits.most_common(6):
|
||||
lines.append(f" first ray hit -> {name}: {count}")
|
||||
top = hits.most_common(1)
|
||||
if top and not top[0][0].startswith(obj.name):
|
||||
lines.append(
|
||||
f" '{top[0][0]}' is intercepting the rays. A duplicate surface at the same "
|
||||
f"coordinates will do this: coincident geometry is not occluded by {obj.name!r} "
|
||||
"and wins the raycast. Remove or hide the duplicate, or the mesh layer is not "
|
||||
"the one being textured."
|
||||
)
|
||||
return lines
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def _triangle_seam_edges(
|
||||
triangle_vertices: list[tuple[int, int, int]],
|
||||
seam_edges: list[tuple[int, int]],
|
||||
) -> dict[int, list[tuple[int, int]]]:
|
||||
"""Map triangle index -> the seam edges that belong to it (at most three).
|
||||
|
||||
Only triangles touching the seam need a cut, so this keeps the post-process off the other
|
||||
~98% of the surface.
|
||||
"""
|
||||
by_edge: dict[tuple[int, int], list[int]] = {}
|
||||
for index, (a, b, c) in enumerate(triangle_vertices):
|
||||
for x, y in ((a, b), (b, c), (c, a)):
|
||||
by_edge.setdefault((x, y) if x < y else (y, x), []).append(index)
|
||||
out: dict[int, list[tuple[int, int]]] = {}
|
||||
for a, b in seam_edges:
|
||||
key = (a, b) if a < b else (b, a)
|
||||
for index in by_edge.get(key, ()):
|
||||
out.setdefault(index, []).append(key)
|
||||
return out
|
||||
|
||||
|
||||
def _mesh_centroid_world(obj, depsgraph):
|
||||
"""Area-weighted centroid of the evaluated surface, in world mm.
|
||||
|
||||
Area-weighted rather than vertex-mean: the mesh is not uniformly tessellated, so a vertex
|
||||
mean would drift toward whatever region happens to be subdivided more finely.
|
||||
"""
|
||||
from mathutils import Vector
|
||||
|
||||
evaluated = obj.evaluated_get(depsgraph)
|
||||
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
try:
|
||||
mesh.calc_loop_triangles()
|
||||
total = 0.0
|
||||
acc = Vector((0.0, 0.0, 0.0))
|
||||
for triangle in mesh.loop_triangles:
|
||||
p = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
|
||||
area = (p[1] - p[0]).cross(p[2] - p[0]).length * 0.5
|
||||
acc += (p[0] + p[1] + p[2]) / 3.0 * area
|
||||
total += area
|
||||
if total <= 0.0:
|
||||
raise SceneError("sensing surface has no area; cannot report view angles")
|
||||
centroid = acc / total
|
||||
return [centroid.x, centroid.y, centroid.z]
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def _write_ground_truth(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(
|
||||
json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args(_argv_after_double_dash())
|
||||
try:
|
||||
@@ -223,19 +571,21 @@ def main() -> int:
|
||||
# UV map cannot leave a half-written set of artifacts behind.
|
||||
try:
|
||||
obj = _prepare_mesh_object(config["mesh"])
|
||||
resolved = [
|
||||
(
|
||||
pair,
|
||||
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"),
|
||||
_require_uv_layer(obj, pair["uv_map"]),
|
||||
resolved = []
|
||||
for pair in config["pairs"]:
|
||||
region_cam = _require_object(
|
||||
pair["camera"], "CAMERA", f"view {pair['id']!r} region camera"
|
||||
)
|
||||
for pair in config["pairs"]
|
||||
]
|
||||
pattern_cam = _require_object(
|
||||
pair["pattern_camera"], "CAMERA", f"view {pair['id']!r} pattern camera"
|
||||
)
|
||||
uv_layer = _require_uv_layer(obj, pair["uv_map"])
|
||||
resolved.append((pair, region_cam, pattern_cam, uv_layer))
|
||||
except SceneError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for pair, _cam, _layer in resolved:
|
||||
for pair, _rc, _pc, _layer in resolved:
|
||||
for format_name, output in pair["artifacts"].items():
|
||||
try:
|
||||
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -250,45 +600,302 @@ def main() -> int:
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
start_time = time.time()
|
||||
policy: MergePolicy = config["policy"]
|
||||
priority = [pair["id"] for pair, _a, _b, _c in resolved]
|
||||
|
||||
print(
|
||||
detail(
|
||||
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
|
||||
f"{config['width']}x{config['height']} raster, "
|
||||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm"
|
||||
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm, "
|
||||
f"merge mode={policy.mode} limit={policy.incidence_limit_deg:g}deg"
|
||||
)
|
||||
|
||||
for pair, cam, uv_layer in resolved:
|
||||
pair_id = pair["id"]
|
||||
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
||||
# A colour table per pair: cell (i, j) indexes a different place on the surface in
|
||||
# each camera's grid, so sharing one table would place the same colour on unrelated
|
||||
# surface while looking like consistency.
|
||||
if pair["seed"] is not None:
|
||||
np.random.seed(pair["seed"])
|
||||
print(f" colour seed {pair['seed']}")
|
||||
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
# ---- 1. Region geometry per pair: the ownership authority ----------------------
|
||||
region_fragments: dict[str, ViewFragments] = {}
|
||||
meta: dict[str, Any] | None = None
|
||||
for pair, region_cam, _pattern_cam, _layer in resolved:
|
||||
try:
|
||||
geometry = build_patch_geometry(
|
||||
obj,
|
||||
cam,
|
||||
scene,
|
||||
depsgraph,
|
||||
[pair["uv_map"]],
|
||||
config["nx"],
|
||||
config["ny"],
|
||||
config["edge_sample_grid"],
|
||||
obj, region_cam, scene, depsgraph, [pair["uv_map"]],
|
||||
config["nx"], config["ny"], config["edge_sample_grid"],
|
||||
fill_unresolved_leaves=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface the real cause to stderr
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: patch geometry failed for view {pair_id!r}: {exc}", file=sys.stderr
|
||||
f"ERROR: region geometry failed for view {pair['id']!r} "
|
||||
f"(camera {pair['camera']!r}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
region_fragments[pair["id"]] = ViewFragments.from_meta(geometry["_meta"])
|
||||
if meta is None:
|
||||
meta = geometry["_meta"]
|
||||
count = len(region_fragments[pair["id"]].triangle)
|
||||
detail(
|
||||
f" region {pair['id']!r} camera={pair['camera']!r}: {count} fragments"
|
||||
)
|
||||
if count == 0:
|
||||
# Never ship a blank texture and call it success. Zero fragments from a region
|
||||
# camera means either the camera is wrong or something is in front of the
|
||||
# surface, and both produce an all-background artifact that looks deliberate.
|
||||
print(
|
||||
f"ERROR: region camera {pair['camera']!r} produced no fragments for view "
|
||||
f"{pair['id']!r}; there is nothing to own and the texture would be blank.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for line in _diagnose_zero_fragments(obj, region_cam, scene, depsgraph):
|
||||
print(f" {line}", file=sys.stderr)
|
||||
return 1
|
||||
assert meta is not None
|
||||
|
||||
# ---- 2. Seam -> components, validated ------------------------------------------
|
||||
components = None
|
||||
seam_edges: list[tuple[int, int]] = []
|
||||
if policy.mode == "authored":
|
||||
seam_edges = _seam_edges(obj)
|
||||
triangle_vertices = [tuple(v) for v in meta["triangle_vertices"]]
|
||||
components = components_from_seam(triangle_vertices, seam_edges)
|
||||
problems = validate_seam(triangle_vertices, seam_edges, components)
|
||||
if problems:
|
||||
for message in problems:
|
||||
print(f"ERROR: seam: {message}", file=sys.stderr)
|
||||
return 1
|
||||
report(
|
||||
f"seam: {len(seam_edges)} marked edges, "
|
||||
f"{len(set(components))} component(s) of "
|
||||
f"{[sum(1 for c in components if c == k) for k in sorted(set(components))]} triangles"
|
||||
)
|
||||
|
||||
# ---- 3. Ownership --------------------------------------------------------------
|
||||
try:
|
||||
result = assign_ownership(
|
||||
meta["triangle_area_mm2"], region_fragments, priority, policy,
|
||||
components=components, pins=config["pins"],
|
||||
)
|
||||
except MergeConfigError as exc:
|
||||
print(f"ERROR: ownership: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
owner: dict[int, str] = result["owner"]
|
||||
report(
|
||||
f"ownership: {len(owner)}/{meta['triangle_count']} triangles owned, "
|
||||
f"hole {result['hole_mm2']:.3f} mm^2 ({result['hole_fraction']:.4f}), "
|
||||
f"per view "
|
||||
+ ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items()))
|
||||
)
|
||||
|
||||
# ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------
|
||||
# The seam is passed here (not to the region pass) because only the emitted geometry needs
|
||||
# to know how far each fragment sits from it.
|
||||
gap = config["gap"]
|
||||
seam_segments = None
|
||||
if gap["enabled"] and seam_edges:
|
||||
matrix_world = obj.matrix_world
|
||||
vertices = obj.data.vertices
|
||||
seam_segments = [
|
||||
(matrix_world @ vertices[a].co, matrix_world @ vertices[b].co)
|
||||
for a, b in seam_edges
|
||||
]
|
||||
print(
|
||||
f" seam margin: {gap['treatment']}, total {gap['total_mm']:g} mm "
|
||||
f"(alignment tolerance {gap['alignment_tolerance_mm']:g} mm), "
|
||||
f"inset {gap['inset_mm']:g} mm per file"
|
||||
)
|
||||
elif gap["enabled"]:
|
||||
print(
|
||||
" seam margin: requested but there is no authored seam, so nothing is inset",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
triangle_seam_edges: dict[int, list[tuple[int, int]]] = {}
|
||||
if gap["enabled"] and gap["apply"] == "post_process" and seam_edges:
|
||||
triangle_seam_edges = _triangle_seam_edges(
|
||||
[tuple(v) for v in meta["triangle_vertices"]], seam_edges
|
||||
)
|
||||
print(
|
||||
f" seam margin: post-process clip on {len(triangle_seam_edges)} "
|
||||
"seam-adjacent triangles"
|
||||
)
|
||||
|
||||
pattern_cameras: dict[str, Any] = {}
|
||||
for pair, _rc, pattern_cam, _layer in resolved:
|
||||
pattern_cameras[pair["pattern_camera"]] = pattern_cam
|
||||
uv_maps = [pair["uv_map"] for pair, _a, _b, _c in resolved]
|
||||
pattern_geometry: dict[str, Any] | None = None
|
||||
pattern_meta: dict[str, Any] | None = None
|
||||
for name, cam in pattern_cameras.items():
|
||||
try:
|
||||
built = build_patch_geometry(
|
||||
obj, cam, scene, depsgraph, uv_maps,
|
||||
config["nx"], config["ny"], config["edge_sample_grid"],
|
||||
fill_unresolved_leaves=True,
|
||||
seam_segments=seam_segments,
|
||||
seam_search_mm=gap["inset_mm"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
f"ERROR: pattern geometry failed for camera {name!r}: {exc}", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
if pattern_geometry is None:
|
||||
pattern_geometry = built
|
||||
pattern_meta = built["_meta"]
|
||||
detail(
|
||||
f" pattern camera {name!r}: "
|
||||
f"{len(pattern_meta['fragment_triangle'])} fragments per layout"
|
||||
)
|
||||
assert pattern_geometry is not None and pattern_meta is not None
|
||||
|
||||
pattern_triangles = list(pattern_meta["fragment_triangle"])
|
||||
|
||||
# ---- 5. Emit: mask each layout to its owned triangles ---------------------------
|
||||
emitted_stats: dict[str, dict[str, Any]] = {}
|
||||
for pair, region_cam, _pattern_cam, uv_layer in resolved:
|
||||
pair_id = pair["id"]
|
||||
uv_map = pair["uv_map"]
|
||||
detail(f"--- view {pair_id!r}: region={pair['camera']!r} pattern={pair['pattern_camera']!r} uv_map={uv_map!r}")
|
||||
if pair["seed"] is not None:
|
||||
np.random.seed(pair["seed"])
|
||||
detail(f" colour seed {pair['seed']}")
|
||||
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
||||
|
||||
patches = pattern_geometry[uv_map]["patches"]
|
||||
mesh_layer = pattern_geometry[uv_map]["mesh"]
|
||||
fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
|
||||
triangle_seam = pattern_meta.get("triangle_seam_distance_mm")
|
||||
gap_cfg = config["gap"]
|
||||
inset = gap_cfg["inset_mm"] if gap_cfg["enabled"] else 0.0
|
||||
is_post = gap_cfg["enabled"] and gap_cfg["apply"] == "post_process" and triangle_seam_edges
|
||||
|
||||
def outside_margin(distance: float | None) -> bool:
|
||||
"""True when this fragment/triangle is clear of the seam margin."""
|
||||
return distance is None or distance >= inset
|
||||
|
||||
def gap_planes(triangle_index: int):
|
||||
"""Cached UV half-planes cutting this triangle back from its seam edges."""
|
||||
cached = plane_cache.get(triangle_index)
|
||||
if cached is None:
|
||||
indices = meta["triangle_vertices"][triangle_index]
|
||||
world = [obj.matrix_world @ obj.data.vertices[i].co for i in indices]
|
||||
edges = [
|
||||
(obj.matrix_world @ obj.data.vertices[a].co,
|
||||
obj.matrix_world @ obj.data.vertices[b].co)
|
||||
for a, b in triangle_seam_edges.get(triangle_index, ())
|
||||
]
|
||||
cached = seam_gap.triangle_gap_planes(
|
||||
world, mesh_layer[triangle_index], edges, inset
|
||||
)
|
||||
plane_cache[triangle_index] = cached
|
||||
return cached
|
||||
|
||||
plane_cache: dict[int, list] = {}
|
||||
owned_here = [k for k, t in enumerate(pattern_triangles) if owner.get(t) == pair_id]
|
||||
if is_post:
|
||||
# Post-process: every owned fragment is kept, then clipped. A fragment is a
|
||||
# triangle, so clipping against the seam's offset half-space leaves a straight edge
|
||||
# at the requested physical distance instead of a fragment-sized sawtooth.
|
||||
keep = list(owned_here)
|
||||
emitted_patches = []
|
||||
trimmed = 0
|
||||
for k in keep:
|
||||
i, j, polygon = patches[k]
|
||||
planes = gap_planes(pattern_triangles[k])
|
||||
if planes:
|
||||
before_area = seam_gap.polygon_area(polygon)
|
||||
clipped = seam_gap.clip_polygon(polygon, planes)
|
||||
if len(clipped) < 3:
|
||||
continue
|
||||
# Most fragments are cut rather than removed, so counting removals alone
|
||||
# would understate what the margin did.
|
||||
if clipped != list(polygon) and (
|
||||
abs(seam_gap.polygon_area(clipped) - before_area) > 1e-12
|
||||
):
|
||||
trimmed += 1
|
||||
polygon = clipped
|
||||
emitted_patches.append((i, j, polygon))
|
||||
trimmed_polygons = trimmed
|
||||
else:
|
||||
# Trim mode: drop whole fragments inside the margin.
|
||||
keep = [
|
||||
k for k in owned_here
|
||||
if outside_margin(None if fragment_seam is None else fragment_seam[k])
|
||||
]
|
||||
emitted_patches = [patches[k] for k in keep]
|
||||
|
||||
owned_triangles = sorted(t for t, v in owner.items() if v == pair_id)
|
||||
kept_triangles = sorted({pattern_triangles[k] for k in keep})
|
||||
# The mesh layer is the white ground the patches sit on; it has to respect the margin
|
||||
# too, or the ground would bridge the very band the margin exists to leave empty.
|
||||
if config["base_fill"] == "none":
|
||||
base_layer: list[Any] = []
|
||||
else:
|
||||
candidates = kept_triangles if config["base_fill"] == "inked" else owned_triangles
|
||||
base_layer = []
|
||||
for t in candidates:
|
||||
if is_post:
|
||||
planes = gap_planes(t)
|
||||
polygon = mesh_layer[t]
|
||||
if planes:
|
||||
polygon = seam_gap.clip_polygon(polygon, planes)
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
base_layer.append(polygon)
|
||||
elif outside_margin(None if triangle_seam is None else triangle_seam[t]):
|
||||
base_layer.append(mesh_layer[t])
|
||||
filtered = {"mesh": base_layer, "patches": emitted_patches}
|
||||
|
||||
# What the margin actually achieved. Post-process hits the requested width by
|
||||
# construction (it cuts at the offset half-space); trim mode can only be measured,
|
||||
# because it snaps outward to whole fragments.
|
||||
if is_post:
|
||||
achieved_gap = gap_cfg["total_mm"]
|
||||
else:
|
||||
surviving = [fragment_seam[k] for k in keep] if fragment_seam is not None else []
|
||||
achieved_gap = (2.0 * min(surviving)) if surviving else None
|
||||
emitted_stats[pair_id] = {
|
||||
"uv_map": uv_map,
|
||||
"region_camera": pair["camera"],
|
||||
"pattern_camera": pair["pattern_camera"],
|
||||
"owned_triangles": len(owned_triangles),
|
||||
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
|
||||
"inked_triangles": len(kept_triangles),
|
||||
"inked_mm2": sum(meta["triangle_area_mm2"][t] for t in kept_triangles),
|
||||
"emitted_fragments": len(emitted_patches),
|
||||
"total_fragments": len(patches),
|
||||
"gap_enabled": gap_cfg["enabled"],
|
||||
"gap_apply": gap_cfg["apply"] if gap_cfg["enabled"] else None,
|
||||
"gap_requested_total_mm": gap_cfg["total_mm"] if gap_cfg["enabled"] else 0.0,
|
||||
"gap_achieved_total_mm": None if achieved_gap is None else round(achieved_gap, 6),
|
||||
"gap_removed_fragments": len(owned_here) - len(keep),
|
||||
"gap_clipped_polygons": len(keep) - len(emitted_patches) if is_post else 0,
|
||||
"gap_trimmed_polygons": trimmed_polygons if is_post else 0,
|
||||
"base_triangles": len(base_layer),
|
||||
"seed": pair["seed"],
|
||||
"artifacts": pair["artifacts"],
|
||||
}
|
||||
summary = (
|
||||
f" emitting {len(emitted_patches)} of {len(patches)} fragments over "
|
||||
f"{len(kept_triangles)} triangles; base fill={config['base_fill']}"
|
||||
)
|
||||
if gap_cfg["enabled"]:
|
||||
summary += f"; margin[{gap_cfg['apply']}] {gap_cfg['total_mm']:g} mm"
|
||||
if is_post:
|
||||
summary += (
|
||||
f", {trimmed_polygons} trimmed, "
|
||||
f"{len(keep) - len(emitted_patches)} clipped away"
|
||||
)
|
||||
else:
|
||||
summary += (
|
||||
f", removed {len(owned_here) - len(keep)} fragments, "
|
||||
f"achieved {achieved_gap:.4g} mm"
|
||||
if achieved_gap is not None else ", nothing removed"
|
||||
)
|
||||
report(summary)
|
||||
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
try:
|
||||
generate_patch_texture(
|
||||
obj,
|
||||
cam,
|
||||
pattern_cameras[pair["pattern_camera"]],
|
||||
scene,
|
||||
depsgraph,
|
||||
nx=config["nx"],
|
||||
@@ -309,7 +916,7 @@ def main() -> int:
|
||||
uv_layer=uv_layer,
|
||||
image_name=f"PatchTexture_{pair_id}",
|
||||
patch_colors=patch_colors,
|
||||
geometry=geometry[pair["uv_map"]],
|
||||
geometry=filtered,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(
|
||||
@@ -328,14 +935,163 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved)
|
||||
print(f"Multi-view complete: {artifact_count} file(s)")
|
||||
for pair, _cam, _layer in resolved:
|
||||
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
||||
for format_name, output in pair["artifacts"].items():
|
||||
path = Path(output)
|
||||
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)")
|
||||
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
# ---- 6. Print-view report: the angle each texture is printed from ---------------
|
||||
# Ships next to the textures because the angle is what an operator or a downstream
|
||||
# machine needs in order to place the part for each pass.
|
||||
centroid_world = _mesh_centroid_world(obj, depsgraph)
|
||||
centroid_local = obj.matrix_world.inverted() @ Vector(centroid_world)
|
||||
inked_triangles = {t for t in set(pattern_triangles) if owner.get(t) is not None}
|
||||
triangle_area = meta["triangle_area_mm2"]
|
||||
angle_report: dict[str, Any] = {
|
||||
"schema": PRINT_VIEWS_SCHEMA,
|
||||
"mesh": config["mesh"],
|
||||
"units": "mm",
|
||||
"angle_convention": (
|
||||
"TWO frames are reported per camera, because they answer different questions. "
|
||||
"`world` is measured against the scene axes: that is the frame Blender's "
|
||||
"Transform panel shows for the camera's location and rotation, so check the "
|
||||
"numbers here against Blender. `object_local` is measured against the mesh "
|
||||
"object's own axes, which is the physically meaningful frame when the part is a "
|
||||
"body of revolution. They differ whenever the mesh object carries a rotation. "
|
||||
"In both: azimuth = atan2(y, x) in the frame's XY plane from +X, wrapped to "
|
||||
"[0, 360); elevation = signed angle from that plane; polar_from_z_deg = angle "
|
||||
"from that frame's +Z. The flat azimuth_deg/elevation_deg keys are the WORLD frame."
|
||||
),
|
||||
"centroid_world_mm": [round(v, 6) for v in centroid_world],
|
||||
"centroid_object_mm": [round(v, 6) for v in centroid_local],
|
||||
"part_transform": {
|
||||
"rotation_euler_deg": [
|
||||
round(math.degrees(v), 4) for v in obj.matrix_world.to_euler()
|
||||
],
|
||||
"scale": [round(v, 6) for v in obj.matrix_world.to_scale()],
|
||||
},
|
||||
"settings": {
|
||||
"nx": config["nx"],
|
||||
"ny": config["ny"],
|
||||
"raster": [config["width"], config["height"]],
|
||||
"page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
|
||||
"seed": config["seed"],
|
||||
},
|
||||
"ownership": {
|
||||
"mode": policy.mode,
|
||||
"incidence_limit_deg": policy.incidence_limit_deg,
|
||||
"min_footprint_mm2": policy.min_footprint_mm2,
|
||||
"min_triangle_coverage": policy.min_triangle_coverage,
|
||||
"max_hole_fraction": policy.max_hole_fraction,
|
||||
"triangles": meta["triangle_count"],
|
||||
"owned_mm2": round(result["owned_mm2"], 6),
|
||||
"hole_mm2": round(result["hole_mm2"], 6),
|
||||
"total_mm2": round(result["total_mm2"], 6),
|
||||
"hole_fraction": round(result["hole_fraction"], 8),
|
||||
"inked_mm2": round(sum(triangle_area[t] for t in inked_triangles), 6),
|
||||
"inked_fraction": round(
|
||||
sum(triangle_area[t] for t in inked_triangles) / result["total_mm2"], 8
|
||||
) if result["total_mm2"] else 0.0,
|
||||
},
|
||||
"seam_margin": dict(config["gap"]),
|
||||
"views": [],
|
||||
}
|
||||
if components is not None:
|
||||
angle_report["seam"] = {
|
||||
"boundary": "use_seam",
|
||||
"edges": len(seam_edges),
|
||||
"components": (max(components) + 1) if components else 0,
|
||||
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
||||
}
|
||||
|
||||
for pair, region_cam, _pattern_cam, _layer in resolved:
|
||||
entry = _view_angles(obj, region_cam, centroid_world)
|
||||
entry["id"] = pair["id"]
|
||||
entry.update(emitted_stats[pair["id"]])
|
||||
angle_report["views"].append(entry)
|
||||
|
||||
angle_report["pattern_camera"] = _view_angles(
|
||||
obj, pattern_cameras[resolved[0][0]["pattern_camera"]], centroid_world
|
||||
)
|
||||
|
||||
report_path = Path(resolved[0][0]["artifacts"]["png"]).parent / PRINT_VIEWS_FILENAME
|
||||
report_path.write_text(json.dumps(angle_report, indent=2) + "\n", encoding="utf-8")
|
||||
detail("Print views (azimuth/elevation; `world` matches Blender's Transform panel):")
|
||||
for entry in angle_report["views"]:
|
||||
world = entry["world"]
|
||||
local = entry["object_local"]
|
||||
detail(
|
||||
f" [{entry['id']}] region={entry['region_camera']} {entry['type']}\n"
|
||||
f" world az={world['azimuth_deg']:8.2f} el={world['elevation_deg']:+8.2f} "
|
||||
f"polar={world['polar_from_z_deg']:7.2f} d={world['distance_to_centroid_mm']:.2f}mm\n"
|
||||
f" object-local az={local['azimuth_deg']:8.2f} el={local['elevation_deg']:+8.2f} "
|
||||
f"polar={local['polar_from_z_deg']:7.2f}\n"
|
||||
f" owned={entry['owned_mm2']:.3f}mm^2 inked={entry['inked_mm2']:.3f}mm^2"
|
||||
)
|
||||
pc = angle_report["pattern_camera"]
|
||||
detail(
|
||||
f" [pattern] {pc['name']} {pc['type']}\n"
|
||||
f" world az={pc['world']['azimuth_deg']:8.2f} "
|
||||
f"el={pc['world']['elevation_deg']:+8.2f} polar={pc['world']['polar_from_z_deg']:7.2f}\n"
|
||||
f" object-local az={pc['object_local']['azimuth_deg']:8.2f} "
|
||||
f"el={pc['object_local']['elevation_deg']:+8.2f} "
|
||||
f"polar={pc['object_local']['polar_from_z_deg']:7.2f}"
|
||||
)
|
||||
detail(f" angle report -> {report_path}")
|
||||
|
||||
# ---- 7. Ground truth: makes every no-Blender test runnable on real fragments ----
|
||||
truth = {
|
||||
"schema": GROUND_TRUTH_SCHEMA,
|
||||
"mesh": config["mesh"],
|
||||
"pattern_camera": sorted(pattern_cameras),
|
||||
"policy": {
|
||||
"mode": policy.mode,
|
||||
"incidence_limit_deg": policy.incidence_limit_deg,
|
||||
"min_footprint_mm2": policy.min_footprint_mm2,
|
||||
"min_triangle_coverage": policy.min_triangle_coverage,
|
||||
"max_hole_fraction": policy.max_hole_fraction,
|
||||
"component_policy": policy.component_policy,
|
||||
},
|
||||
"seam": {
|
||||
"boundary": "use_seam",
|
||||
"edge_count": len(seam_edges),
|
||||
"component_count": (max(components) + 1) if components else 0,
|
||||
"component_owner": {str(k): v for k, v in result["component_owner"].items()},
|
||||
},
|
||||
"ownership": {
|
||||
"owner": {str(k): v for k, v in sorted(owner.items())},
|
||||
"owned_mm2": result["owned_mm2"],
|
||||
"hole_mm2": result["hole_mm2"],
|
||||
"total_mm2": result["total_mm2"],
|
||||
"hole_fraction": result["hole_fraction"],
|
||||
"per_view_mm2": result["per_view_mm2"],
|
||||
"per_view_triangles": result["per_view_triangles"],
|
||||
},
|
||||
"triangle_area_mm2": list(meta["triangle_area_mm2"]),
|
||||
"triangle_vertices": [list(v) for v in meta["triangle_vertices"]],
|
||||
"components": list(components) if components is not None else None,
|
||||
"views": {
|
||||
view: {
|
||||
"triangle": list(fragments.triangle),
|
||||
"incidence_deg": list(fragments.incidence_deg),
|
||||
"footprint_mm2": list(fragments.footprint_mm2),
|
||||
}
|
||||
for view, fragments in region_fragments.items()
|
||||
},
|
||||
"emitted": {
|
||||
pair["id"]: {
|
||||
"uv_map": pair["uv_map"],
|
||||
"fragment_triangles": [
|
||||
pattern_triangles[k]
|
||||
for k in range(len(pattern_triangles))
|
||||
if owner.get(pattern_triangles[k]) == pair["id"]
|
||||
],
|
||||
}
|
||||
for pair, _a, _b, _c in resolved
|
||||
},
|
||||
}
|
||||
truth_path = Path(resolved[0][0]["artifacts"]["png"]).parent / GROUND_TRUTH_FILENAME
|
||||
_write_ground_truth(truth_path, truth)
|
||||
detail(f" ground truth -> {truth_path}")
|
||||
|
||||
# The artifact inventory is owned by the HOST, which is the only side that can verify the
|
||||
# files exist and are non-empty. Repeating it here was 11 of the run's lines printed twice.
|
||||
detail(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user