267334d543
finger-xynova first design completed
476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""Ownership assignment and seam validation for multi-view marker textures (stdlib only).
|
|
|
|
The problem this solves
|
|
-----------------------
|
|
`uv_1` and `uv_2` are both *full-surface* layouts, and both carry the same pattern
|
|
(grounded on the sensing camera). Printing both therefore double-inks essentially the whole
|
|
part. Ownership is the fix: assign every loop triangle to exactly one production view, then
|
|
mask each output file to the fragments of the triangles it owns.
|
|
|
|
Why a triangle, and why that makes zero overlap a theorem
|
|
--------------------------------------------------------
|
|
A triangle is a disjoint surface element. If every triangle has exactly one owner and file
|
|
*k* emits only owner-*k* fragments, then no surface element is inked twice — regardless of
|
|
how well the two print passes are registered. Registration then only matters *at the seam*,
|
|
where an error produces a small local gap or overlap instead of a doubled pattern.
|
|
|
|
Two modes
|
|
---------
|
|
* ``computed`` — pick the first view in priority order that qualifies on each triangle
|
|
(visible, within the incidence limit, above the footprint floor, and covering at least
|
|
``min_triangle_coverage`` of the triangle's area). The seam falls where the metric puts it.
|
|
* ``authored`` — the artist's ``use_seam`` edges cut the triangle adjacency graph into
|
|
connected components, and each component is assigned whole to one view. Disjointness is
|
|
again a theorem (components partition the triangles), and the seam is exactly where it was
|
|
drawn.
|
|
|
|
Both are deterministic: triangles are visited in sorted index order, views in a fixed
|
|
priority order, and there is no scoring and no float tie-break.
|
|
|
|
Measured on `sensors/finger/r01` (2026-09-27, blend sha256 61093B1A…)
|
|
---------------------------------------------------------------------
|
|
The authored seam is the y = 0 plane, which is exactly the boundary where
|
|
``incidence_uv1 == incidence_uv2`` because the two cameras are mirrored about XZ. So
|
|
``authored`` and ``computed`` agree to within 0.0014 of coverage at a 60 degree incidence
|
|
limit and are identical from 62 degrees up. Neither mode is a compromise here.
|
|
|
|
A trap this module guards against
|
|
---------------------------------
|
|
Assigning a component by *count of visible triangles* does not work on this mesh: both views
|
|
see ~100% of each component's triangles, so the count ties and the naive rule silently hands
|
|
both components to the first view. Component assignment therefore uses **qualifying footprint**
|
|
(which folds in both the incidence limit and the footprint floor), reported per view so the
|
|
decision is auditable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import dataclass, field
|
|
from typing import Iterable, Mapping, Sequence
|
|
|
|
# Defaults, calibrated on `sensors/finger/r01`. See the module docstring and the plan:
|
|
# measured hole fraction is 0.0218 at 60 deg and 0.0000 at 62 deg, so 60 fails the 0.02 gate.
|
|
DEFAULT_INCIDENCE_LIMIT_DEG = 62.0
|
|
DEFAULT_MIN_FOOTPRINT_MM2 = 2.0e-3
|
|
DEFAULT_MIN_TRIANGLE_COVERAGE = 0.60
|
|
DEFAULT_MAX_HOLE_FRACTION = 0.02
|
|
|
|
MODE_COMPUTED = "computed"
|
|
MODE_AUTHORED = "authored"
|
|
_MODES = (MODE_COMPUTED, MODE_AUTHORED)
|
|
|
|
POLICY_COMPUTED = "computed"
|
|
POLICY_BEST_COVERAGE = "best_coverage"
|
|
POLICY_PINNED = "pinned"
|
|
|
|
|
|
class MergeConfigError(ValueError):
|
|
"""Raised for an unusable merge policy or seam. Always names the offender."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ViewFragments:
|
|
"""One view's fragments, parallel arrays as produced by `build_patch_geometry`.
|
|
|
|
``triangle`` is the loop-triangle index each fragment belongs to; ``incidence_deg`` and
|
|
``footprint_mm2`` are per fragment. Every emitted fragment is visible by construction
|
|
(`cell_fragments` drops or rejects invisible ones), so there is no visibility flag here.
|
|
"""
|
|
|
|
triangle: Sequence[int]
|
|
incidence_deg: Sequence[float]
|
|
footprint_mm2: Sequence[float]
|
|
|
|
def __post_init__(self) -> None:
|
|
if not (len(self.triangle) == len(self.incidence_deg) == len(self.footprint_mm2)):
|
|
raise MergeConfigError(
|
|
"ViewFragments arrays must be parallel: got "
|
|
f"triangle={len(self.triangle)} incidence={len(self.incidence_deg)} "
|
|
f"footprint={len(self.footprint_mm2)}"
|
|
)
|
|
|
|
@classmethod
|
|
def from_meta(cls, meta: Mapping) -> "ViewFragments":
|
|
"""Build from a `build_patch_geometry` ``_meta`` block."""
|
|
return cls(
|
|
triangle=list(meta["fragment_triangle"]),
|
|
incidence_deg=list(meta["fragment_incidence_deg"]),
|
|
footprint_mm2=list(meta["fragment_footprint_mm2"]),
|
|
)
|
|
|
|
def by_triangle(self) -> dict[int, list[tuple[float, float]]]:
|
|
out: dict[int, list[tuple[float, float]]] = defaultdict(list)
|
|
for t, inc, foot in zip(self.triangle, self.incidence_deg, self.footprint_mm2):
|
|
out[t].append((inc, foot))
|
|
return dict(out)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MergePolicy:
|
|
"""Thresholds for `assign_ownership`. Bounds are inclusive (>= / <=)."""
|
|
|
|
mode: str = MODE_COMPUTED
|
|
incidence_limit_deg: float = DEFAULT_INCIDENCE_LIMIT_DEG
|
|
min_footprint_mm2: float = DEFAULT_MIN_FOOTPRINT_MM2
|
|
min_triangle_coverage: float = DEFAULT_MIN_TRIANGLE_COVERAGE
|
|
max_hole_fraction: float = DEFAULT_MAX_HOLE_FRACTION
|
|
component_policy: str = POLICY_BEST_COVERAGE
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.mode not in _MODES:
|
|
raise MergeConfigError(f"mode: must be one of {_MODES} (got {self.mode!r})")
|
|
if self.component_policy not in (POLICY_BEST_COVERAGE, POLICY_PINNED):
|
|
raise MergeConfigError(
|
|
"component_policy: must be 'best_coverage' or 'pinned' "
|
|
f"(got {self.component_policy!r})"
|
|
)
|
|
if not 0.0 <= self.min_triangle_coverage <= 1.0:
|
|
raise MergeConfigError(
|
|
f"min_triangle_coverage: must be in [0, 1] (got {self.min_triangle_coverage!r})"
|
|
)
|
|
if not 0.0 <= self.incidence_limit_deg <= 90.0:
|
|
raise MergeConfigError(
|
|
f"incidence_limit_deg: must be in [0, 90] (got {self.incidence_limit_deg!r})"
|
|
)
|
|
if self.min_footprint_mm2 < 0.0:
|
|
raise MergeConfigError(
|
|
f"min_footprint_mm2: must be >= 0 (got {self.min_footprint_mm2!r})"
|
|
)
|
|
|
|
|
|
def edge_key(a: int, b: int) -> tuple[int, int]:
|
|
"""Canonical undirected vertex-pair key."""
|
|
return (a, b) if a < b else (b, a)
|
|
|
|
|
|
def triangle_edge_keys(vertices: Sequence[int]) -> tuple[tuple[int, int], ...]:
|
|
"""The three undirected edge keys of a triangle given its vertex indices."""
|
|
if len(vertices) != 3:
|
|
raise MergeConfigError(f"triangle_vertices: expected 3 indices, got {len(vertices)}")
|
|
a, b, c = vertices
|
|
return (edge_key(a, b), edge_key(b, c), edge_key(c, a))
|
|
|
|
|
|
def edge_use_counts(
|
|
triangle_vertices: Sequence[Sequence[int]],
|
|
) -> tuple[Counter, set[tuple[int, int]], set[tuple[int, int]]]:
|
|
"""Return (use counts, boundary edges, non-manifold edges)."""
|
|
counts: Counter = Counter()
|
|
for vertices in triangle_vertices:
|
|
counts.update(triangle_edge_keys(vertices))
|
|
boundary = {k for k, n in counts.items() if n == 1}
|
|
non_manifold = {k for k, n in counts.items() if n > 2}
|
|
return counts, boundary, non_manifold
|
|
|
|
|
|
def components_from_seam(
|
|
triangle_vertices: Sequence[Sequence[int]],
|
|
seam_edges: Iterable[tuple[int, int]],
|
|
) -> list[int]:
|
|
"""Connected components of the triangle adjacency graph with seam edges cut.
|
|
|
|
Two triangles are adjacent when they share an edge that is *not* a seam edge. Returns
|
|
one component id per triangle, numbered in order of first appearance (deterministic).
|
|
"""
|
|
seam = {edge_key(*e) for e in seam_edges}
|
|
incident: dict[tuple[int, int], list[int]] = defaultdict(list)
|
|
for index, vertices in enumerate(triangle_vertices):
|
|
for key in triangle_edge_keys(vertices):
|
|
incident[key].append(index)
|
|
|
|
parent = list(range(len(triangle_vertices)))
|
|
|
|
def find(x: int) -> int:
|
|
while parent[x] != x:
|
|
parent[x] = parent[parent[x]]
|
|
x = parent[x]
|
|
return x
|
|
|
|
for key, tris in incident.items():
|
|
if key in seam:
|
|
continue
|
|
for other in tris[1:]:
|
|
ra, rb = find(tris[0]), find(other)
|
|
if ra != rb:
|
|
parent[ra] = rb
|
|
|
|
ids: dict[int, int] = {}
|
|
out: list[int] = []
|
|
for index in range(len(triangle_vertices)):
|
|
root = find(index)
|
|
if root not in ids:
|
|
ids[root] = len(ids)
|
|
out.append(ids[root])
|
|
return out
|
|
|
|
|
|
def validate_seam(
|
|
triangle_vertices: Sequence[Sequence[int]],
|
|
seam_edges: Iterable[tuple[int, int]],
|
|
components: Sequence[int],
|
|
) -> list[str]:
|
|
"""Return human-readable problems, empty when the seam is a usable partition.
|
|
|
|
The load-bearing rule is #1: for a cut to *separate* anything, every seam edge must have
|
|
its two adjacent faces in **different** components. That is the partition proof. Rules
|
|
2-3 are diagnostics that catch the shapes which make rule 1 fail in non-obvious ways.
|
|
"""
|
|
errors: list[str] = []
|
|
seam = {edge_key(*e) for e in seam_edges}
|
|
if not seam:
|
|
errors.append("seam: no seam edges; a cut needs at least one")
|
|
return errors
|
|
if len(components) != len(triangle_vertices):
|
|
errors.append(
|
|
f"components: expected one per triangle ({len(triangle_vertices)}), "
|
|
f"got {len(components)}"
|
|
)
|
|
return errors
|
|
|
|
_, boundary, non_manifold = edge_use_counts(triangle_vertices)
|
|
incident: dict[tuple[int, int], list[int]] = defaultdict(list)
|
|
for index, vertices in enumerate(triangle_vertices):
|
|
for key in triangle_edge_keys(vertices):
|
|
incident[key].append(index)
|
|
|
|
unknown = sorted(k for k in seam if k not in incident)
|
|
if unknown:
|
|
errors.append(
|
|
f"seam: {len(unknown)} seam edge(s) are not edges of any triangle, e.g. {unknown[:3]}"
|
|
)
|
|
|
|
# Rule 1 — the partition proof.
|
|
same_component = [
|
|
k for k in sorted(seam)
|
|
if k in incident and len(incident[k]) == 2
|
|
and components[incident[k][0]] == components[incident[k][1]]
|
|
]
|
|
if same_component:
|
|
errors.append(
|
|
f"seam: {len(same_component)} seam edge(s) join two triangles in the SAME "
|
|
f"component, so the cut does not separate there, e.g. {same_component[:3]} — "
|
|
"a dangling or branching seam leaves the surface connected around its end"
|
|
)
|
|
|
|
# Rule 2 — every seam edge must be interior, so both sides are surface.
|
|
on_boundary = [k for k in sorted(seam) if len(incident.get(k, ())) < 2]
|
|
if on_boundary:
|
|
errors.append(
|
|
f"seam: {len(on_boundary)} seam edge(s) are mesh-boundary or missing edges and "
|
|
f"cannot separate two faces, e.g. {on_boundary[:3]}"
|
|
)
|
|
|
|
# Rule 3 — degree form. A closed loop (all degrees 2) cuts a closed surface; on a
|
|
# surface with boundary a simple open path also cuts, but only if BOTH of its free ends
|
|
# land on the mesh boundary. Anything else (degree >= 3, or a free end in the interior)
|
|
# is what usually makes rule 1 fail.
|
|
degree: Counter = Counter()
|
|
for a, b in seam:
|
|
degree[a] += 1
|
|
degree[b] += 1
|
|
interior_free_ends = sorted(
|
|
v for v, d in degree.items() if d == 1 and not any(v in k for k in boundary)
|
|
)
|
|
if interior_free_ends:
|
|
errors.append(
|
|
f"seam: {len(interior_free_ends)} free seam end(s) are NOT on the mesh boundary "
|
|
f"(e.g. vertex {interior_free_ends[0]}); the cut is open and cannot separate"
|
|
)
|
|
branching = sorted(v for v, d in degree.items() if d >= 3)
|
|
if branching:
|
|
errors.append(
|
|
f"seam: {len(branching)} seam vertex/vertices have degree >= 3 "
|
|
f"(e.g. vertex {branching[0]}); the cut branches and is not a simple curve"
|
|
)
|
|
return errors
|
|
|
|
|
|
def component_areas(components: Sequence[int], triangle_area_mm2: Sequence[float]) -> dict[int, float]:
|
|
"""Total surface area per component."""
|
|
out: dict[int, float] = defaultdict(float)
|
|
for comp, area in zip(components, triangle_area_mm2):
|
|
out[comp] += area
|
|
return dict(out)
|
|
|
|
|
|
def qualifying_footprint(
|
|
fragments: Sequence[tuple[float, float]],
|
|
policy: MergePolicy,
|
|
) -> float:
|
|
"""Sum of footprint over fragments that pass the per-fragment gates."""
|
|
return sum(
|
|
foot
|
|
for incidence, foot in fragments
|
|
if incidence <= policy.incidence_limit_deg and foot >= policy.min_footprint_mm2
|
|
)
|
|
|
|
|
|
def assign_components(
|
|
view_fragments: Mapping[str, ViewFragments],
|
|
priority: Sequence[str],
|
|
components: Sequence[int],
|
|
component_count: int,
|
|
policy: MergePolicy,
|
|
pins: Mapping[int, str] | None = None,
|
|
) -> tuple[dict[int, str], dict[int, dict[str, float]]]:
|
|
"""Assign each component to one view. Returns (component -> view, audit table).
|
|
|
|
Uses **qualifying footprint**, not visible-triangle count: on `sensors/finger/r01` both
|
|
views see ~100% of each component's triangles, so a count ties and would silently hand
|
|
every component to the first view in priority order.
|
|
"""
|
|
pins = dict(pins or {})
|
|
by_triangle: dict[str, dict[int, list[tuple[float, float]]]] = {
|
|
view: view_fragments[view].by_triangle() for view in priority
|
|
}
|
|
comp_tris: dict[int, list[int]] = defaultdict(list)
|
|
for tri, comp in enumerate(components):
|
|
comp_tris[comp].append(tri)
|
|
|
|
scores: dict[int, dict[str, float]] = {}
|
|
for comp in range(component_count):
|
|
scores[comp] = {
|
|
view: sum(
|
|
qualifying_footprint(by_triangle[view].get(tri, ()), policy)
|
|
for tri in comp_tris.get(comp, ())
|
|
)
|
|
for view in priority
|
|
}
|
|
|
|
owners: dict[int, str] = {}
|
|
for comp in range(component_count):
|
|
if comp in pins:
|
|
view = pins[comp]
|
|
if view not in priority:
|
|
raise MergeConfigError(
|
|
f"pins: component {comp} pinned to unknown view {view!r}; "
|
|
f"known views are {list(priority)}"
|
|
)
|
|
owners[comp] = view
|
|
continue
|
|
ranked = sorted(scores[comp].items(), key=lambda kv: (-kv[1], priority.index(kv[0])))
|
|
if ranked[0][1] <= 0.0:
|
|
# No view can qualify anywhere in this component: leave it unowned so every
|
|
# triangle lands in the reported hole census rather than being painted blind.
|
|
continue
|
|
owners[comp] = ranked[0][0]
|
|
return owners, scores
|
|
|
|
|
|
def assign_ownership(
|
|
triangle_area_mm2: Sequence[float],
|
|
view_fragments: Mapping[str, ViewFragments],
|
|
priority: Sequence[str],
|
|
policy: MergePolicy | None = None,
|
|
components: Sequence[int] | None = None,
|
|
pins: Mapping[int, str] | None = None,
|
|
) -> dict:
|
|
"""Assign every triangle to at most one view; return the owner map plus a census.
|
|
|
|
Returns a dict with ``owner`` (triangle -> view; absent means a hole), ``owned_mm2``,
|
|
``hole_mm2``, ``hole_fraction``, ``per_view_mm2``, and — for authored mode —
|
|
``component_owner`` and ``component_scores`` so the decision can be audited.
|
|
"""
|
|
policy = policy or MergePolicy()
|
|
priority = tuple(priority)
|
|
if not priority:
|
|
raise MergeConfigError("priority: at least one view is required")
|
|
if len(set(priority)) != len(priority):
|
|
raise MergeConfigError(f"priority: duplicate view id in {list(priority)}")
|
|
missing = [view for view in priority if view not in view_fragments]
|
|
if missing:
|
|
raise MergeConfigError(f"view_fragments: missing data for view(s) {missing}")
|
|
|
|
triangle_count = len(triangle_area_mm2)
|
|
by_triangle = {view: view_fragments[view].by_triangle() for view in priority}
|
|
owner: dict[int, str] = {}
|
|
component_owner: dict[int, str] = {}
|
|
scores: dict[int, dict[str, float]] = {}
|
|
|
|
if policy.mode == MODE_AUTHORED:
|
|
if components is None:
|
|
raise MergeConfigError("mode 'authored' requires the component map from the seam")
|
|
if len(components) != triangle_count:
|
|
raise MergeConfigError(
|
|
f"components: expected {triangle_count} entries, got {len(components)}"
|
|
)
|
|
component_count = (max(components) + 1) if triangle_count else 0
|
|
component_owner, scores = assign_components(
|
|
view_fragments, priority, components, component_count, policy, pins
|
|
)
|
|
for tri in range(triangle_count):
|
|
view = component_owner.get(components[tri])
|
|
if view is None:
|
|
continue
|
|
if qualifying_footprint(by_triangle[view].get(tri, ()), policy) <= 0.0:
|
|
# The component's owner cannot qualify on this triangle: a reported hole,
|
|
# never silently painted or handed to another view.
|
|
continue
|
|
owner[tri] = view
|
|
else:
|
|
for tri in range(triangle_count):
|
|
area = triangle_area_mm2[tri]
|
|
for view in priority:
|
|
fragments = by_triangle[view].get(tri, ())
|
|
covered = qualifying_footprint(fragments, policy)
|
|
if covered <= 0.0:
|
|
continue
|
|
if area > 0.0 and covered < policy.min_triangle_coverage * area:
|
|
continue
|
|
owner[tri] = view
|
|
break
|
|
|
|
owned_mm2 = sum(triangle_area_mm2[t] for t in owner)
|
|
total_mm2 = sum(triangle_area_mm2)
|
|
per_view: dict[str, float] = defaultdict(float)
|
|
for tri, view in owner.items():
|
|
per_view[view] += triangle_area_mm2[tri]
|
|
|
|
return {
|
|
"mode": policy.mode,
|
|
"owner": owner,
|
|
"owned_mm2": owned_mm2,
|
|
"hole_mm2": total_mm2 - owned_mm2,
|
|
"total_mm2": total_mm2,
|
|
"hole_fraction": (total_mm2 - owned_mm2) / total_mm2 if total_mm2 else 0.0,
|
|
"per_view_mm2": dict(per_view),
|
|
"per_view_triangles": {
|
|
view: sum(1 for v in owner.values() if v == view) for view in priority
|
|
},
|
|
"component_owner": component_owner,
|
|
"component_scores": scores,
|
|
"policy": policy,
|
|
}
|
|
|
|
|
|
def ownership_disjoint(owner: Mapping[int, str]) -> bool:
|
|
"""Trivially true for a mapping, but kept explicit: one owner per triangle key."""
|
|
return len(set(owner)) == len(owner)
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_INCIDENCE_LIMIT_DEG",
|
|
"DEFAULT_MAX_HOLE_FRACTION",
|
|
"DEFAULT_MIN_FOOTPRINT_MM2",
|
|
"DEFAULT_MIN_TRIANGLE_COVERAGE",
|
|
"MODE_AUTHORED",
|
|
"MODE_COMPUTED",
|
|
"POLICY_BEST_COVERAGE",
|
|
"POLICY_COMPUTED",
|
|
"POLICY_PINNED",
|
|
"MergeConfigError",
|
|
"MergePolicy",
|
|
"ViewFragments",
|
|
"assign_components",
|
|
"assign_ownership",
|
|
"component_areas",
|
|
"components_from_seam",
|
|
"edge_key",
|
|
"edge_use_counts",
|
|
"ownership_disjoint",
|
|
"qualifying_footprint",
|
|
"triangle_edge_keys",
|
|
"validate_seam",
|
|
]
|