fix algining xynova texture to uv printing fixture.

This commit is contained in:
ykcheungab
2026-09-29 16:33:31 +08:00
parent 267334d543
commit 37e3e8400b
33 changed files with 758477 additions and 886701 deletions
+209
View File
@@ -0,0 +1,209 @@
"""Per-view page sizing derived from the projection (stdlib only).
The problem
-----------
A pair's texture page comes from ``texture_patch.vector``, one hand-entered size shared by
every layout. But each production layout was authored by *Project from View (Bounds)* from its
own camera, so ``[0,1]^2`` of that layout means a **different physical size** in each one. On
`finger-xynova/r01` the layouts are 21.199 x 23.200 mm while the page says 18.5 x 16.4 mm —
23% wrong, and inverted from portrait to landscape.
`_warn_if_page_aspect_differs` cannot catch this: it compares the raster aspect against the
page aspect, and those two agree. The page is self-consistent and wrong about the geometry.
The derivation
--------------
For a layout that *is* a bounds-projection from a camera, ``u`` is an affine function of the
camera's image-plane ``x`` and ``v`` of ``y``. So fitting each axis recovers the mapping
exactly, and the page is its inverse:
u = a * x + b page_width_mm = 1 / |a|
v = c * y + d page_height_mm = 1 / |c|
The fit's **residual is the proof** that the layout really is such a projection. Measured
1.2e-7 on the real `uv_1`/`uv_2` layouts, against 0.17-0.70 for `produce`/`compute`, which are
unwraps and must keep a hand-set page. So the derivation verifies before it trusts, and refuses
rather than silently falling back — a silently-kept page is precisely the defect being fixed.
Two details that matter
-----------------------
* The camera's **object scale must be divided out**. `finger-xynova`'s cameras carry scale
0.014988; measuring with ``matrix_world.inverted()`` reports a 1414 mm extent. The caller must
pass image-plane coordinates measured through the scale-normalised view matrix, which is what
`patch_projection.build_patch_geometry` already uses — so the page agrees with the geometry
the generator actually clips.
* The page is set from the **rounded raster**, ``page = raster_px / px_per_mm``, so page aspect
and raster aspect are equal by construction and the existing uniform-scale guard cannot fire.
The cost is at most ``0.5 / px_per_mm`` = 0.0025 mm at 200 px/mm, two orders of magnitude
below the 0.1 mm alignment tolerance.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Sequence
#: What both shipped revisions already use exactly: 3280/16.4, 3200/16.0, 3700/18.5 and
#: 3280/16.4 are all 200.0, so only the proportion changes, not the density.
DEFAULT_PX_PER_MM = 200.0
#: A true bounds-projection fits to ~1e-7; an unwrap lands at 0.17-0.70. The gap is five orders
#: of magnitude, so the threshold is not delicate.
DEFAULT_RESIDUAL_TOLERANCE = 1e-5
#: Bounds fitting puts the UV box exactly on [0,1]^2.
DEFAULT_BBOX_TOLERANCE = 1e-5
MIN_PAGE_MM = 1e-3
class PageSizingError(ValueError):
"""The layout is not a verifiable bounds-projection, so no page can be derived."""
@dataclass(frozen=True)
class DerivedPage:
"""A verified page/raster pair for one layout."""
page_width_mm: float
page_height_mm: float
raster_width_px: int
raster_height_px: int
px_per_mm: float
slope_u: float
slope_v: float
residual_u: float
residual_v: float
uv_min: tuple[float, float]
uv_max: tuple[float, float]
@property
def page_aspect(self) -> float:
return self.page_width_mm / self.page_height_mm
@property
def raster_aspect(self) -> float:
return self.raster_width_px / self.raster_height_px
def describe(self) -> str:
return (
f"page {self.page_width_mm:.3f} x {self.page_height_mm:.3f} mm "
f"raster {self.raster_width_px} x {self.raster_height_px} px "
f"({self.px_per_mm:g} px/mm)"
)
def _fit(target: Sequence[float], source: Sequence[float]) -> tuple[float, float, float]:
"""Least-squares ``target = slope * source + intercept``; returns max residual too."""
count = len(source)
if count < 2:
raise PageSizingError(f"need at least 2 samples to fit an axis, got {count}")
mean_source = sum(source) / count
mean_target = sum(target) / count
sxx = sum((value - mean_source) ** 2 for value in source)
if sxx <= 0.0:
raise PageSizingError("the mesh has no extent along one axis of this view")
sxy = sum(
(value - mean_source) * (other - mean_target)
for value, other in zip(source, target)
)
slope = sxy / sxx
intercept = mean_target - slope * mean_source
residual = max(
abs(other - (slope * value + intercept)) for value, other in zip(source, target)
)
return slope, intercept, residual
def derive_page(
uv_samples: Iterable[Sequence[float]],
plane_samples: Iterable[Sequence[float]],
px_per_mm: float = DEFAULT_PX_PER_MM,
residual_tolerance: float = DEFAULT_RESIDUAL_TOLERANCE,
bbox_tolerance: float = DEFAULT_BBOX_TOLERANCE,
) -> DerivedPage:
"""Derive and verify the page for one layout.
``uv_samples`` and ``plane_samples`` are parallel sequences of ``(u, v)`` and the
corresponding camera image-plane ``(x, y)`` in millimetres, one entry per mesh loop.
Raises `PageSizingError` — never warns and carries on — when the layout is not a
bounds-projection from that camera.
"""
uvs = [(float(u), float(v)) for u, v in uv_samples]
planes = [(float(x), float(y)) for x, y in plane_samples]
if len(uvs) != len(planes):
raise PageSizingError(
f"uv_samples and plane_samples must be parallel: {len(uvs)} vs {len(planes)}"
)
if not uvs:
raise PageSizingError("no samples; the mesh has no loops")
if not isinstance(px_per_mm, (int, float)) or px_per_mm <= 0:
raise PageSizingError(f"px_per_mm must be > 0 (got {px_per_mm!r})")
slope_u, _intercept_u, residual_u = _fit(
[u for u, _ in uvs], [x for x, _ in planes]
)
slope_v, _intercept_v, residual_v = _fit(
[v for _, v in uvs], [y for _, y in planes]
)
if abs(slope_u) <= 0.0 or abs(slope_v) <= 0.0:
raise PageSizingError("a UV axis does not vary with the image plane; cannot size")
if residual_u > residual_tolerance or residual_v > residual_tolerance:
raise PageSizingError(
"this layout is not a bounds-projection from the named camera, so no page can be "
f"derived: u-vs-x max residual {residual_u:.3g}, v-vs-y max residual "
f"{residual_v:.3g} (tolerance {residual_tolerance:g}). Unwrapped layouts such as "
"'compute' land at 0.1-0.7 here. Set `page: fixed` for this pair to keep the "
"page from texture_patch.vector."
)
u_min = min(u for u, _ in uvs)
u_max = max(u for u, _ in uvs)
v_min = min(v for _, v in uvs)
v_max = max(v for _, v in uvs)
for name, low, high in (("u", u_min, u_max), ("v", v_min, v_max)):
if abs(low) > bbox_tolerance or abs(high - 1.0) > bbox_tolerance:
raise PageSizingError(
f"the layout's UV bounding box is not [0,1]^2 in {name}: "
f"[{low:.6f}, {high:.6f}] (tolerance {bbox_tolerance:g}), so it was not "
"fitted to the view bounds and a page cannot be derived"
)
# abs() because a mirrored axis still has a well-defined size; the sign only says the UV
# runs opposite to the image plane, which no page needs to encode.
page_width_mm = 1.0 / abs(slope_u)
page_height_mm = 1.0 / abs(slope_v)
if page_width_mm < MIN_PAGE_MM or page_height_mm < MIN_PAGE_MM:
raise PageSizingError(
f"degenerate page {page_width_mm:.6g} x {page_height_mm:.6g} mm; the mesh is "
"edge-on to this camera"
)
# Page from the rounded raster, so the two aspects are equal by construction.
raster_width_px = max(1, int(round(page_width_mm * float(px_per_mm))))
raster_height_px = max(1, int(round(page_height_mm * float(px_per_mm))))
return DerivedPage(
page_width_mm=raster_width_px / float(px_per_mm),
page_height_mm=raster_height_px / float(px_per_mm),
raster_width_px=raster_width_px,
raster_height_px=raster_height_px,
px_per_mm=float(px_per_mm),
slope_u=slope_u,
slope_v=slope_v,
residual_u=residual_u,
residual_v=residual_v,
uv_min=(u_min, v_min),
uv_max=(u_max, v_max),
)
__all__ = [
"DEFAULT_BBOX_TOLERANCE",
"DEFAULT_PX_PER_MM",
"DEFAULT_RESIDUAL_TOLERANCE",
"MIN_PAGE_MM",
"DerivedPage",
"PageSizingError",
"derive_page",
]
+102 -6
View File
@@ -58,6 +58,7 @@ from texture_merge import ( # noqa: E402
validate_seam, validate_seam,
) )
import seam_gap # noqa: E402 import seam_gap # noqa: E402
import page_sizing # noqa: E402
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1" SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1" GROUND_TRUTH_SCHEMA = "orisys-sensor-design/multiview-ground-truth-v1"
@@ -297,9 +298,20 @@ def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})" f"job 'print.base_fill': must be 'none', 'inked' or 'owned' (got {base_fill!r})"
) )
page_block = job.get("page") or {}
if not isinstance(page_block, dict):
raise ConfigError("job 'page' must be a mapping")
page_mode = str(page_block.get("mode", "auto"))
if page_mode not in ("auto", "fixed"):
raise ConfigError(f"job 'page.mode': must be 'auto' or 'fixed' (got {page_mode!r})")
px_per_mm = float(page_block.get("px_per_mm", page_sizing.DEFAULT_PX_PER_MM))
if px_per_mm <= 0.0:
raise ConfigError(f"job 'page.px_per_mm': must be > 0 (got {px_per_mm!r})")
return { return {
"mesh": str(objects.get("mesh") or DEFAULT_MESH), "mesh": str(objects.get("mesh") or DEFAULT_MESH),
"base_fill": base_fill, "base_fill": base_fill,
"page": {"mode": page_mode, "px_per_mm": px_per_mm},
"gap": { "gap": {
"enabled": gap_enabled, "enabled": gap_enabled,
"apply": gap_apply, "apply": gap_apply,
@@ -525,6 +537,34 @@ def _triangle_seam_edges(
return out return out
def _derive_pair_page(obj, camera, uv_name, depsgraph, px_per_mm):
"""Derive and verify one pair's page from the geometry its layout projects.
Uses the same scale-normalised view matrix `patch_projection` clips with, so the page
agrees with the geometry that is actually textured. Dividing out the camera's object scale
is not optional: `finger-xynova`'s cameras carry 0.014988, which would otherwise report a
1414 mm extent for a 19 mm part.
"""
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
layer = mesh.uv_layers.get(uv_name)
if layer is None:
raise SceneError(f"UV layer {uv_name!r} is missing from the evaluated mesh")
view = camera.matrix_world.normalized().inverted()
to_world = evaluated.matrix_world
uvs: list[tuple[float, float]] = []
planes: list[tuple[float, float]] = []
for loop in mesh.loops:
u, v = layer.data[loop.index].uv
local = view @ (to_world @ mesh.vertices[loop.vertex_index].co)
uvs.append((float(u), float(v)))
planes.append((float(local.x), float(local.y)))
return page_sizing.derive_page(uvs, planes, px_per_mm=px_per_mm)
finally:
evaluated.to_mesh_clear()
def _mesh_centroid_world(obj, depsgraph): def _mesh_centroid_world(obj, depsgraph):
"""Area-weighted centroid of the evaluated surface, in world mm. """Area-weighted centroid of the evaluated surface, in world mm.
@@ -682,6 +722,20 @@ def main() -> int:
f"per view " f"per view "
+ ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items())) + ", ".join(f"{view} {area:.3f}" for view, area in sorted(result['per_view_mm2'].items()))
) )
if result["hole_fraction"] > policy.max_hole_fraction:
# An unowned region prints nothing, so it is a visible defect, not a statistic. This is
# the gate that turns "the surface has a bald patch" into a message naming the cause.
print(
f"ERROR: hole fraction {result['hole_fraction']:.4f} exceeds max_hole_fraction "
f"{policy.max_hole_fraction:g}: {result['hole_mm2']:.3f} mm^2 of the surface "
f"({meta['triangle_count'] - len(owner)} triangles) has no owning view, so it "
"would print nothing. The usual cause is the incidence limit: a view whose surface "
"is steeper than `incidence_limit_deg` cannot own it. Raise that limit (or re-pose "
f"the region cameras), or set `max_hole_fraction` above {result['hole_fraction']:.4f} "
"to accept the gap deliberately.",
file=sys.stderr,
)
return 1
# ---- 4. Pattern geometry, one pass per distinct pattern camera ------------------ # ---- 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 # The seam is passed here (not to the region pass) because only the emitted geometry needs
@@ -758,6 +812,44 @@ def main() -> int:
detail(f" colour seed {pair['seed']}") detail(f" colour seed {pair['seed']}")
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"]) patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
# ---- page: derived per view, or inherited from texture_patch ----------------
pair_page_mode = str(pair.get("page") or config["page"]["mode"])
if pair_page_mode == "auto":
try:
derived = _derive_pair_page(
obj, region_cam, uv_map, depsgraph, config["page"]["px_per_mm"]
)
except (page_sizing.PageSizingError, SceneError) as exc:
# Never fall back silently: keeping the shared page is what produced a 23%
# inverted aspect on finger-xynova.
print(
f"ERROR: cannot auto-size the page for view {pair_id!r}: {exc}",
file=sys.stderr,
)
return 1
texture_width = derived.raster_width_px
texture_height = derived.raster_height_px
page_width_mm = derived.page_width_mm
page_height_mm = derived.page_height_mm
page_info = {
"page_source": "auto",
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
"raster_px": [texture_width, texture_height],
"page_residual": [derived.residual_u, derived.residual_v],
"page_slope": [derived.slope_u, derived.slope_v],
}
detail(f"[{pair_id}] {derived.describe()} (derived from {pair['camera']})")
else:
texture_width = config["width"]
texture_height = config["height"]
page_width_mm = config["vector_width_mm"]
page_height_mm = config["vector_height_mm"]
page_info = {
"page_source": "fixed",
"page_mm": [round(page_width_mm, 6), round(page_height_mm, 6)],
"raster_px": [texture_width, texture_height],
}
patches = pattern_geometry[uv_map]["patches"] patches = pattern_geometry[uv_map]["patches"]
mesh_layer = pattern_geometry[uv_map]["mesh"] mesh_layer = pattern_geometry[uv_map]["mesh"]
fragment_seam = pattern_meta.get("fragment_seam_distance_mm") fragment_seam = pattern_meta.get("fragment_seam_distance_mm")
@@ -855,6 +947,7 @@ def main() -> int:
"uv_map": uv_map, "uv_map": uv_map,
"region_camera": pair["camera"], "region_camera": pair["camera"],
"pattern_camera": pair["pattern_camera"], "pattern_camera": pair["pattern_camera"],
**page_info,
"owned_triangles": len(owned_triangles), "owned_triangles": len(owned_triangles),
"owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles), "owned_mm2": sum(meta["triangle_area_mm2"][t] for t in owned_triangles),
"inked_triangles": len(kept_triangles), "inked_triangles": len(kept_triangles),
@@ -901,13 +994,13 @@ def main() -> int:
nx=config["nx"], nx=config["nx"],
ny=config["ny"], ny=config["ny"],
r=config["color_r"], r=config["color_r"],
texture_width=config["width"], texture_width=texture_width,
texture_height=config["height"], texture_height=texture_height,
output_path=pair["artifacts"]["png"], output_path=pair["artifacts"]["png"],
svg_path=pair["artifacts"]["svg"], svg_path=pair["artifacts"]["svg"],
pdf_path=pair["artifacts"]["pdf"], pdf_path=pair["artifacts"]["pdf"],
vector_width_mm=config["vector_width_mm"], vector_width_mm=page_width_mm,
vector_height_mm=config["vector_height_mm"], vector_height_mm=page_height_mm,
uv_centre=uv_centre, uv_centre=uv_centre,
cross_length=config["cross_length"], cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"], cross_thickness=config["cross_thickness"],
@@ -968,8 +1061,11 @@ def main() -> int:
"settings": { "settings": {
"nx": config["nx"], "nx": config["nx"],
"ny": config["ny"], "ny": config["ny"],
"raster": [config["width"], config["height"]], # The fallback page/raster for pairs with `page: fixed`; with `page: auto` the
"page_mm": [config["vector_width_mm"], config["vector_height_mm"]], # per-view values below are authoritative.
"default_raster": [config["width"], config["height"]],
"default_page_mm": [config["vector_width_mm"], config["vector_height_mm"]],
"page": dict(config["page"]),
"seed": config["seed"], "seed": config["seed"],
}, },
"ownership": { "ownership": {
+190
View File
@@ -0,0 +1,190 @@
"""Run with python -m pytest blender/scripts/test_page_sizing.py (no Blender needed).
The derivation is only usable because its own residual proves the layout is a
bounds-projection. These tests pin both halves: that a real projection recovers the measured
page, and that an unwrap is refused rather than silently sized.
"""
import unittest
from page_sizing import (
DEFAULT_PX_PER_MM,
DerivedPage,
PageSizingError,
derive_page,
)
#: Measured on the shipped blends (scale-normalised view matrix, so the camera object scale
#: is divided out). These are the numbers the feature exists to reproduce.
FINGER_UV_1_MM = (17.5116, 15.9772)
FINGER_UV_2_MM = (17.5114, 15.9820)
XYNOVA_UV_1_MM = (21.1989, 23.2003)
XYNOVA_UV_2_MM = (21.2001, 23.2003)
def projection_samples(width_mm, height_mm, steps=40, v_flip=False):
"""Samples of a bounds-projection: UV fills [0,1]^2 over a `width x height` view plane."""
uvs = []
planes = []
for i in range(steps + 1):
for j in range(steps + 1):
x = width_mm * i / steps
y = height_mm * j / steps
u = i / steps
v = (1.0 - j / steps) if v_flip else (j / steps)
uvs.append((u, v))
planes.append((x, y))
return uvs, planes
class DerivationTests(unittest.TestCase):
def test_recovers_the_finger_r01_uv_1_page(self):
page = derive_page(*projection_samples(*FINGER_UV_1_MM))
self.assertAlmostEqual(page.page_width_mm, FINGER_UV_1_MM[0], places=2)
self.assertAlmostEqual(page.page_height_mm, FINGER_UV_1_MM[1], places=2)
def test_recovers_every_measured_page(self):
for width_mm, height_mm in (
FINGER_UV_1_MM, FINGER_UV_2_MM, XYNOVA_UV_1_MM, XYNOVA_UV_2_MM
):
with self.subTest(size=(width_mm, height_mm)):
page = derive_page(*projection_samples(width_mm, height_mm))
self.assertAlmostEqual(page.page_width_mm, width_mm, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, height_mm, delta=0.01)
def test_xynova_page_is_portrait_where_the_configured_page_was_landscape(self):
"""The defect in one assertion: 0.914 vs the configured 1.128."""
page = derive_page(*projection_samples(*XYNOVA_UV_1_MM))
self.assertLess(page.page_aspect, 1.0)
self.assertAlmostEqual(page.page_aspect, 0.9137, places=3)
configured = 18.5 / 16.4
self.assertGreater(abs(configured - page.page_aspect), 0.2)
def test_real_slopes_round_trip(self):
"""Feeding the slopes AND ranges measured off the blend recovers the measured extent.
The measured `uv_1` affine is u = 0.057105098 x + 0.472297765 over x in
[-8.270676, 9.240895]; the intercept is only consistent with that range, which is why
a synthetic range would fail the [0,1]^2 bounds check.
"""
x_min, x_max = -8.270676, 9.240895
y_min, y_max = -7.890431, 8.086782
slope_u, intercept_u = 0.057105098, 0.472297765
slope_v, intercept_v = 0.062589139, 0.493855294
uvs, planes = [], []
for i in range(101):
x = x_min + (x_max - x_min) * i / 100.0
y = y_min + (y_max - y_min) * i / 100.0
uvs.append((slope_u * x + intercept_u, slope_v * y + intercept_v))
planes.append((x, y))
page = derive_page(uvs, planes)
self.assertAlmostEqual(page.page_width_mm, 17.5116, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, 15.9772, delta=0.01)
def test_residual_is_essentially_zero_for_a_projection(self):
page = derive_page(*projection_samples(20.0, 10.0))
self.assertLess(page.residual_u, 1e-12)
self.assertLess(page.residual_v, 1e-12)
def test_mirrored_v_axis_is_still_sized(self):
page = derive_page(*projection_samples(20.0, 10.0, v_flip=True))
self.assertAlmostEqual(page.page_width_mm, 20.0, delta=0.01)
self.assertAlmostEqual(page.page_height_mm, 10.0, delta=0.01)
class VerificationTests(unittest.TestCase):
def test_unwrap_is_refused_and_names_the_escape_hatch(self):
"""A non-affine u-vs-x map is the `compute` case in miniature."""
uvs, planes = [], []
for i in range(101):
x = i * 0.1
uvs.append((x / 10.0 + 0.05 * (x / 10.0) ** 2, i / 100.0))
planes.append((x, i * 0.1))
with self.assertRaises(PageSizingError) as caught:
derive_page(uvs, planes)
message = str(caught.exception)
self.assertIn("not a bounds-projection", message)
self.assertIn("page: fixed", message)
def test_residual_tolerance_boundary(self):
uvs, planes = projection_samples(20.0, 20.0, steps=20)
# Nudge a single sample just inside, then just outside, the tolerance.
for offset, should_pass in ((0.5e-5, True), (5e-5, False)):
nudged = list(uvs)
nudged[7] = (nudged[7][0] + offset, nudged[7][1])
if should_pass:
derive_page(nudged, planes)
else:
with self.assertRaises(PageSizingError):
derive_page(nudged, planes)
def test_uv_box_not_unit_square_is_refused(self):
"""A layout fitted to something other than the view bounds has no derivable page."""
uvs, planes = projection_samples(20.0, 10.0)
squashed = [(u * 0.5, v) for u, v in uvs]
with self.assertRaises(PageSizingError) as caught:
derive_page(squashed, planes)
self.assertIn("[0,1]^2", str(caught.exception))
def test_flat_axis_is_refused(self):
planes = [(x, 0.0) for x, _ in ((i * 0.1, 0.0) for i in range(11))]
uvs = [(i / 10.0, i / 10.0) for i in range(11)]
with self.assertRaises(PageSizingError) as caught:
derive_page(uvs, planes)
self.assertIn("no extent", str(caught.exception))
def test_too_few_samples_is_refused(self):
with self.assertRaises(PageSizingError):
derive_page([(0.0, 0.0)], [(0.0, 0.0)])
def test_mismatched_lengths_are_refused(self):
with self.assertRaises(PageSizingError) as caught:
derive_page([(0.0, 0.0), (1.0, 1.0)], [(0.0, 0.0)])
self.assertIn("parallel", str(caught.exception))
def test_bad_px_per_mm_is_refused(self):
uvs, planes = projection_samples(20.0, 10.0)
for bad in (0, -1.0, "many"):
with self.assertRaises(PageSizingError):
derive_page(uvs, planes, px_per_mm=bad)
class InvariantTests(unittest.TestCase):
def test_page_and_raster_aspects_are_equal_by_construction(self):
"""This is what stops `_warn_if_page_aspect_differs` firing."""
for width_mm, height_mm in (
(17.5116, 15.9772), (21.1989, 23.2003), (1.0, 1.0), (100.0, 3.0), (3.0, 100.0)
):
with self.subTest(size=(width_mm, height_mm)):
page = derive_page(*projection_samples(width_mm, height_mm))
self.assertAlmostEqual(page.page_aspect, page.raster_aspect, places=12)
def test_page_is_the_rounded_raster_over_the_density(self):
page = derive_page(*projection_samples(17.5116, 15.9772))
self.assertAlmostEqual(
page.page_width_mm, page.raster_width_px / DEFAULT_PX_PER_MM, places=12
)
self.assertAlmostEqual(
page.page_height_mm, page.raster_height_px / DEFAULT_PX_PER_MM, places=12
)
def test_default_density_reproduces_the_shipped_rasters(self):
"""200 px/mm is what 3280x16.4 and 3700x18.5 already are, exactly."""
self.assertEqual(DEFAULT_PX_PER_MM, 200.0)
self.assertAlmostEqual(3280 / 16.4, 200.0, places=9)
self.assertAlmostEqual(3200 / 16.0, 200.0, places=9)
self.assertAlmostEqual(3700 / 18.5, 200.0, places=9)
def test_rounding_error_stays_far_below_the_alignment_tolerance(self):
"""At 200 px/mm the page can be off by at most 0.0025 mm; tolerance is 0.1 mm."""
page = derive_page(*projection_samples(*XYNOVA_UV_1_MM))
self.assertLessEqual(abs(page.page_width_mm - XYNOVA_UV_1_MM[0]), 0.5 / 200.0)
self.assertLessEqual(abs(page.page_height_mm - XYNOVA_UV_1_MM[1]), 0.5 / 200.0)
def test_describe_is_readable(self):
page = derive_page(*projection_samples(20.0, 10.0))
self.assertIn("page 20.000 x 10.000 mm", page.describe())
self.assertIn("200 px/mm", page.describe())
if __name__ == "__main__":
unittest.main()
File diff suppressed because one or more lines are too long
@@ -28,29 +28,33 @@
"settings": { "settings": {
"nx": 80, "nx": 80,
"ny": 80, "ny": 80,
"raster": [ "default_raster": [
3700, 3700,
3280 3280
], ],
"page_mm": [ "default_page_mm": [
18.5, 18.5,
16.4 16.4
], ],
"page": {
"mode": "auto",
"px_per_mm": 200.0
},
"seed": 7 "seed": 7
}, },
"ownership": { "ownership": {
"mode": "authored", "mode": "authored",
"incidence_limit_deg": 62.0, "incidence_limit_deg": 70.0,
"min_footprint_mm2": 0.002, "min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6, "min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02, "max_hole_fraction": 0.02,
"triangles": 5007, "triangles": 5007,
"owned_mm2": 616.525186, "owned_mm2": 616.533224,
"hole_mm2": 0.099824, "hole_mm2": 0.091787,
"total_mm2": 616.625011, "total_mm2": 616.625011,
"hole_fraction": 0.00016189, "hole_fraction": 0.00014885,
"inked_mm2": 616.525186, "inked_mm2": 616.533224,
"inked_fraction": 0.99983811 "inked_fraction": 0.99985115
}, },
"seam_margin": { "seam_margin": {
"enabled": false, "enabled": false,
@@ -66,41 +70,41 @@
"type": "ORTHO", "type": "ORTHO",
"world": { "world": {
"position_mm": [ "position_mm": [
20.6625, 18.384392,
-35.865852, -41.181198,
-31.754499 -19.463104
], ],
"view_direction": [ "view_direction": [
0.548991024, 0.563601971,
-0.478212804, -0.714734852,
-0.685508192 -0.414133906
], ],
"distance_to_centroid_mm": 37.635785, "distance_to_centroid_mm": 32.618052,
"azimuth_deg": 318.9417, "azimuth_deg": 308.2574,
"elevation_deg": -43.2756, "elevation_deg": -24.4648,
"polar_from_z_deg": 133.2756 "polar_from_z_deg": 114.4648
}, },
"object_local": { "object_local": {
"position_mm": [ "position_mm": [
20.6625, 18.384392,
-17.572624, -4.204537,
44.563557 45.354443
], ],
"view_direction": [ "view_direction": [
0.548991024, 0.563601971,
-0.480608493, -0.144704893,
0.683830678 0.813273251
], ],
"distance_to_centroid_mm": 37.635785, "distance_to_centroid_mm": 32.618053,
"azimuth_deg": 318.7998, "azimuth_deg": 345.6003,
"elevation_deg": 43.1437, "elevation_deg": 54.417,
"polar_from_z_deg": 46.8563 "polar_from_z_deg": 35.583
}, },
"camera_transform": { "camera_transform": {
"rotation_euler_deg": [ "rotation_euler_deg": [
-0.0, 45.0,
135.0, 135.0,
-45.0 -0.0
], ],
"scale": [ "scale": [
0.014988, 0.014988,
@@ -121,35 +125,52 @@
1.0 1.0
] ]
}, },
"ortho_scale_mm": 24.199997, "ortho_scale_mm": 27.0,
"lens_mm": null, "lens_mm": null,
"sensor_width_mm": 36.0, "sensor_width_mm": 36.0,
"clip_start_mm": 0.1, "clip_start_mm": 0.1,
"clip_end_mm": 1000.0, "clip_end_mm": 1000.0,
"position_mm": [ "position_mm": [
20.6625, 18.384392,
-35.865852, -41.181198,
-31.754499 -19.463104
], ],
"view_direction": [ "view_direction": [
0.548991024, 0.563601971,
-0.478212804, -0.714734852,
-0.685508192 -0.414133906
], ],
"distance_to_centroid_mm": 37.635785, "distance_to_centroid_mm": 32.618052,
"azimuth_deg": 318.9417, "azimuth_deg": 308.2574,
"elevation_deg": -43.2756, "elevation_deg": -24.4648,
"polar_from_z_deg": 133.2756, "polar_from_z_deg": 114.4648,
"azimuth_deg_frame": "world", "azimuth_deg_frame": "world",
"id": "uv_1", "id": "uv_1",
"uv_map": "uv_1", "uv_map": "uv_1",
"region_camera": "cam_uv_1", "region_camera": "cam_uv_1",
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"owned_triangles": 2472, "page_source": "auto",
"owned_mm2": 308.26505216601106, "page_mm": [
"inked_triangles": 2472, 17.67,
"inked_mm2": 308.26505216601106, 23.565
"emitted_fragments": 15794, ],
"raster_px": [
3534,
4713
],
"page_residual": [
1.2885921574223858e-07,
1.0443071318810837e-07
],
"page_slope": [
0.05659285670700212,
0.04243635765680492
],
"owned_triangles": 2471,
"owned_mm2": 308.2663995595292,
"inked_triangles": 2471,
"inked_mm2": 308.2663995595292,
"emitted_fragments": 15808,
"total_fragments": 32593, "total_fragments": 32593,
"gap_enabled": false, "gap_enabled": false,
"gap_apply": null, "gap_apply": null,
@@ -158,7 +179,7 @@
"gap_removed_fragments": 0, "gap_removed_fragments": 0,
"gap_clipped_polygons": 0, "gap_clipped_polygons": 0,
"gap_trimmed_polygons": 0, "gap_trimmed_polygons": 0,
"base_triangles": 2472, "base_triangles": 2471,
"seed": 7, "seed": 7,
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
@@ -171,41 +192,41 @@
"type": "ORTHO", "type": "ORTHO",
"world": { "world": {
"position_mm": [ "position_mm": [
-20.6625, -18.384399,
-35.865852, -41.181198,
-31.754499 -19.463104
], ],
"view_direction": [ "view_direction": [
-0.549020469, -0.563635349,
-0.478201747, -0.714715183,
-0.685492337 -0.414122522
], ],
"distance_to_centroid_mm": 37.636656, "distance_to_centroid_mm": 32.618951,
"azimuth_deg": 221.0562, "azimuth_deg": 231.7401,
"elevation_deg": -43.2743, "elevation_deg": -24.4641,
"polar_from_z_deg": 133.2743 "polar_from_z_deg": 114.4641
}, },
"object_local": { "object_local": {
"position_mm": [ "position_mm": [
-20.6625, -18.384399,
-17.572624, -4.204537,
44.563557 45.354443
], ],
"view_direction": [ "view_direction": [
-0.549020469, -0.56363529,
-0.480597347, -0.1447009,
0.683814824 0.81325078
], ],
"distance_to_centroid_mm": 37.636656, "distance_to_centroid_mm": 32.618952,
"azimuth_deg": 221.198, "azimuth_deg": 194.3985,
"elevation_deg": 43.1425, "elevation_deg": 54.4148,
"polar_from_z_deg": 46.8575 "polar_from_z_deg": 35.5852
}, },
"camera_transform": { "camera_transform": {
"rotation_euler_deg": [ "rotation_euler_deg": [
-0.0, 45.0,
-135.0, -135.0,
45.0 -0.0
], ],
"scale": [ "scale": [
0.014988, 0.014988,
@@ -226,35 +247,52 @@
1.0 1.0
] ]
}, },
"ortho_scale_mm": 24.199997, "ortho_scale_mm": 27.0,
"lens_mm": null, "lens_mm": null,
"sensor_width_mm": 36.0, "sensor_width_mm": 36.0,
"clip_start_mm": 0.1, "clip_start_mm": 0.1,
"clip_end_mm": 1000.0, "clip_end_mm": 1000.0,
"position_mm": [ "position_mm": [
-20.6625, -18.384399,
-35.865852, -41.181198,
-31.754499 -19.463104
], ],
"view_direction": [ "view_direction": [
-0.549020469, -0.563635349,
-0.478201747, -0.714715183,
-0.685492337 -0.414122522
], ],
"distance_to_centroid_mm": 37.636656, "distance_to_centroid_mm": 32.618951,
"azimuth_deg": 221.0562, "azimuth_deg": 231.7401,
"elevation_deg": -43.2743, "elevation_deg": -24.4641,
"polar_from_z_deg": 133.2743, "polar_from_z_deg": 114.4641,
"azimuth_deg_frame": "world", "azimuth_deg_frame": "world",
"id": "uv_2", "id": "uv_2",
"uv_map": "uv_2", "uv_map": "uv_2",
"region_camera": "cam_uv_2", "region_camera": "cam_uv_2",
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"page_source": "auto",
"page_mm": [
17.67,
23.565
],
"raster_px": [
3534,
4713
],
"page_residual": [
1.0079875600865762e-07,
9.84046944485506e-08
],
"page_slope": [
0.056592803427866885,
0.04243927082965147
],
"owned_triangles": 2488, "owned_triangles": 2488,
"owned_mm2": 308.26013401700123, "owned_mm2": 308.2668241740881,
"inked_triangles": 2488, "inked_triangles": 2488,
"inked_mm2": 308.26013401700123, "inked_mm2": 308.2668241740881,
"emitted_fragments": 16682, "emitted_fragments": 16696,
"total_fragments": 32593, "total_fragments": 32593,
"gap_enabled": false, "gap_enabled": false,
"gap_apply": null, "gap_apply": null,
@@ -9,7 +9,7 @@
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"merge": { "merge": {
"mode": "authored", "mode": "authored",
"incidence_limit_deg": 62.0, "incidence_limit_deg": 70.0,
"min_footprint_mm2": 0.002, "min_footprint_mm2": 0.002,
"min_triangle_coverage": 0.6, "min_triangle_coverage": 0.6,
"max_hole_fraction": 0.02, "max_hole_fraction": 0.02,
@@ -31,6 +31,10 @@
"print": { "print": {
"base_fill": "owned" "base_fill": "owned"
}, },
"page": {
"mode": "auto",
"px_per_mm": 200.0
},
"settings": { "settings": {
"width": 3700, "width": 3700,
"height": 3280, "height": 3280,
@@ -62,6 +66,7 @@
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"uv_map": "uv_1", "uv_map": "uv_1",
"seed": 7, "seed": 7,
"page": "auto",
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_1.svg",
@@ -74,6 +79,7 @@
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"uv_map": "uv_2", "uv_map": "uv_2",
"seed": 7, "seed": 7,
"page": "auto",
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger-xynova\\r01\\patch_texture_uv_2.svg",
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 594 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 6.0 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 682 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 5.9 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 795 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 828 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

+40 -2
View File
@@ -28,14 +28,18 @@
"settings": { "settings": {
"nx": 80, "nx": 80,
"ny": 45, "ny": 45,
"raster": [ "default_raster": [
3280, 3280,
3200 3200
], ],
"page_mm": [ "default_page_mm": [
16.4, 16.4,
16.0 16.0
], ],
"page": {
"mode": "auto",
"px_per_mm": 200.0
},
"seed": 7 "seed": 7
}, },
"ownership": { "ownership": {
@@ -145,6 +149,23 @@
"uv_map": "uv_1", "uv_map": "uv_1",
"region_camera": "cam_uv_1", "region_camera": "cam_uv_1",
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"page_source": "auto",
"page_mm": [
17.51,
15.975
],
"raster_px": [
3502,
3195
],
"page_residual": [
1.1554413981151868e-07,
1.1602603933180689e-07
],
"page_slope": [
0.05710510338426684,
0.06258913909464084
],
"owned_triangles": 957, "owned_triangles": 957,
"owned_mm2": 177.85392129180258, "owned_mm2": 177.85392129180258,
"inked_triangles": 956, "inked_triangles": 956,
@@ -250,6 +271,23 @@
"uv_map": "uv_2", "uv_map": "uv_2",
"region_camera": "cam_uv_2", "region_camera": "cam_uv_2",
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"page_source": "auto",
"page_mm": [
17.51,
15.98
],
"raster_px": [
3502,
3196
],
"page_residual": [
1.592531430061328e-07,
8.815460200706227e-08
],
"page_slope": [
0.0571057317783398,
0.06257031633768419
],
"owned_triangles": 990, "owned_triangles": 990,
"owned_mm2": 177.86360352881806, "owned_mm2": 177.86360352881806,
"inked_triangles": 990, "inked_triangles": 990,
@@ -31,6 +31,10 @@
"print": { "print": {
"base_fill": "owned" "base_fill": "owned"
}, },
"page": {
"mode": "auto",
"px_per_mm": 200.0
},
"settings": { "settings": {
"width": 3280, "width": 3280,
"height": 3200, "height": 3200,
@@ -62,6 +66,7 @@
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"uv_map": "uv_1", "uv_map": "uv_1",
"seed": 7, "seed": 7,
"page": "auto",
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_1.svg",
@@ -74,6 +79,7 @@
"pattern_camera": "cam_sensor", "pattern_camera": "cam_sensor",
"uv_map": "uv_2", "uv_map": "uv_2",
"seed": 7, "seed": 7,
"page": "auto",
"artifacts": { "artifacts": {
"png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png", "png": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.png",
"svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg", "svg": "D:\\orisys_ws\\orisys-sensor-design\\build\\textures\\finger\\r01\\patch_texture_uv_2.svg",
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 487 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 514 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+26 -2
View File
@@ -78,6 +78,22 @@ texture_patch:
texture_patch_views: texture_patch_views:
enabled: true enabled: true
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute` pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
# Per-view page sizing. Measured here: the uv_1/uv_2 layouts are 21.1989 x 23.2003 mm
# (aspect 0.9137) and 21.2001 x 23.2003 mm (aspect 0.9138) — PORTRAIT. `vector:` above says
# 18.5 x 16.4 (aspect 1.1280), which is LANDSCAPE, so the artwork was stretched 23% in the
# wrong direction. `auto` derives the page per view and proves the layout really is a
# bounds-projection from the named camera before using the result.
#
# `px_per_mm: 200` reproduces the existing density exactly (3700/18.5 = 200.0).
#
# `fixed` keeps `texture_patch.vector`, which unwrapped layouts (`produce`, `compute`)
# require — they are not projections.
#
# Overlay note: the scene render is 250x250 while these layouts are 0.9138 aspect, so set the
# render resolution to the emitted raster size and use "Stretch to fill".
page:
mode: auto
px_per_mm: 200
pairs: pairs:
- id: uv_1 - id: uv_1
camera: cam_uv_1 # REGION camera camera: cam_uv_1 # REGION camera
@@ -87,10 +103,18 @@ texture_patch_views:
uv_map: uv_2 uv_map: uv_2
merge: merge:
mode: authored # honour the 45-edge seam marked on the mesh mode: authored # honour the 45-edge seam marked on the mesh
incidence_limit_deg: 62.0 # measured on this part: hole fraction 0.0002 at 62 deg # RE-MEASURED 2026-09-29 after the region cameras moved (cam_uv_1 az 318.9 -> 308.3,
# elevation -43.3 -> -24.5, distance 37.6 -> 32.6 mm). Sweep at min_triangle_coverage 0.60:
# 62deg -> hole 0.0480 FAILS max_hole_fraction (a visible bald patch of 29.6 mm^2)
# 65deg -> hole 0.0130 passes, but barely
# 70deg -> hole 0.0001 passes with margin <-- current
# The limit is a property of the camera poses and the surface, so MOVING THE CAMERAS
# INVALIDATES IT. Re-run the sweep after any re-pose. min_triangle_coverage is not binding
# here: relaxing it to 0 changes nothing at 62 or 70 degrees.
incidence_limit_deg: 70.0
min_footprint_mm2: 2.0e-3 min_footprint_mm2: 2.0e-3
min_triangle_coverage: 0.60 min_triangle_coverage: 0.60
max_hole_fraction: 0.02 max_hole_fraction: 0.02 # enforced: the run fails above this rather than shipping a gap
component_policy: best_coverage component_policy: best_coverage
seam: seam:
boundary: use_seam boundary: use_seam
Binary file not shown.
+19
View File
@@ -70,6 +70,25 @@ texture_patch:
texture_patch_views: texture_patch_views:
enabled: true enabled: true
pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute` pattern_camera: cam_sensor # the pattern every layout carries, same source as `compute`
# Per-view page sizing. A layout authored by Project from View (Bounds) maps [0,1]^2 to the
# image-plane extent of the mesh through ITS camera, so the page is measurable and differs
# per view: measured 17.5116 x 15.9772 mm for uv_1 (aspect 1.0960) and 17.5114 x 15.9820 mm
# for uv_2 (aspect 1.0957). `vector:` above says 16.4 x 16.0 (aspect 1.0250), which stretched
# the artwork by 6.9% along u. `auto` derives it and proves the layout really is a
# bounds-projection from the named camera before using the result.
#
# `px_per_mm: 200` reproduces the existing density exactly (3280/16.4 = 3700/18.5 = 200.0),
# and the page is set from the rounded raster so page and raster always share an aspect.
#
# `fixed` keeps `texture_patch.vector`. Unwrapped layouts (`produce`, `compute`) are not
# projections and must use it — a camera-derived page would be meaningless there.
#
# To overlay a texture on a render from its camera: set the render resolution to the emitted
# raster size and the background image to "Stretch to fill". At any other resolution the
# scene aspect (1280x720 here) will distort the overlay regardless of the page.
page:
mode: auto
px_per_mm: 200
pairs: pairs:
- id: uv_1 # layout authored from cam_uv_1 - id: uv_1 # layout authored from cam_uv_1
camera: cam_uv_1 # REGION camera: what this pair may own camera: cam_uv_1 # REGION camera: what this pair may own
+10 -1
View File
@@ -579,8 +579,17 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
by_id = {view.get("id"): view for view in print_views.get("views", [])} by_id = {view.get("id"): view for view in print_views.get("views", [])}
for pair, _artifacts in targets: for pair, _artifacts in targets:
view = by_id.get(pair.id) or {} view = by_id.get(pair.id) or {}
page = view.get("page_mm") or []
source = view.get("page_source")
# The derived page is read back from what the run wrote, never recomputed here, so
# the console cannot describe a page the run did not use.
page_text = (
f" page {page[0]:.3f}x{page[1]:.3f}mm ({source})"
if len(page) == 2
else ""
)
console.report( console.report(
f" [{pair.id}] region={pair.camera} -> {pair.uv_map}" f" [{pair.id}] region={pair.camera} -> {pair.uv_map}{page_text}"
f" owns {view.get('owned_triangles', 0):,} triangles," f" owns {view.get('owned_triangles', 0):,} triangles,"
f" emits {view.get('emitted_fragments', 0):,} fragments" f" emits {view.get('emitted_fragments', 0):,} fragments"
) )
+66 -2
View File
@@ -64,8 +64,8 @@ MULTIVIEW_JOB_FILENAME = "_texture_views_job.json"
VIEWS_BLOCK_KEY = "texture_patch_views" VIEWS_BLOCK_KEY = "texture_patch_views"
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs", "merge", "seam", "registration", _BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs", "merge", "seam", "registration",
"print"}) "print", "page"})
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed", "pattern_camera"}) _PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed", "pattern_camera", "page"})
_MERGE_KEYS = frozenset({ _MERGE_KEYS = frozenset({
"mode", "incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage", "mode", "incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
"max_hole_fraction", "component_policy", "max_hole_fraction", "component_policy",
@@ -94,6 +94,20 @@ GAP_TREATMENTS = ("no_print", "reduced_density")
GAP_DEFAULT_TREATMENT = "no_print" GAP_DEFAULT_TREATMENT = "no_print"
GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM = 0.1 GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM = 0.1
_PRINT_KEYS = frozenset({"base_fill"}) _PRINT_KEYS = frozenset({"base_fill"})
_PAGE_KEYS = frozenset({"mode", "px_per_mm"})
#: How a pair's page is chosen.
#:
#: ``auto`` derive it from the geometry: a layout authored by *Project from View (Bounds)*
#: from its region camera maps [0,1]^2 to that camera's image-plane extent, so the
#: page is measurable. Only valid for such a layout, and the driver **proves** it is
#: one before using the result.
#: ``fixed`` keep ``texture_patch.vector``. Required for unwrapped layouts (`produce`,
#: `compute`), which are not projections and for which a camera-derived page would
#: be meaningless. Also the escape hatch when `auto` refuses.
PAGE_MODES = ("auto", "fixed")
PAGE_DEFAULT_PX_PER_MM = 200.0
PAGE_DEFAULTS = {"mode": "auto", "px_per_mm": PAGE_DEFAULT_PX_PER_MM}
#: What is drawn under the colour patches, i.e. the white ground the marker sits on. #: What is drawn under the colour patches, i.e. the white ground the marker sits on.
#: #:
@@ -151,6 +165,7 @@ class ViewPair:
uv_map: str uv_map: str
target: TextureOutputTarget target: TextureOutputTarget
seed: int | None seed: int | None
page_mode: str = PAGE_DEFAULTS["mode"]
def to_job_entry(self, output_dir: Path) -> dict[str, Any]: def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
return { return {
@@ -159,6 +174,7 @@ class ViewPair:
"pattern_camera": self.pattern_camera, "pattern_camera": self.pattern_camera,
"uv_map": self.uv_map, "uv_map": self.uv_map,
"seed": self.seed, "seed": self.seed,
"page": self.page_mode,
"artifacts": { "artifacts": {
format_name: str(path) format_name: str(path)
for format_name, path in self.target.artifact_paths(output_dir).items() for format_name, path in self.target.artifact_paths(output_dir).items()
@@ -235,6 +251,12 @@ def validate_views_block(
value = pair.get("pattern_camera") value = pair.get("pattern_camera")
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
errors.append(f"{where}.pattern_camera: must be a non-empty object name") errors.append(f"{where}.pattern_camera: must be a non-empty object name")
if "page" in pair:
value = pair.get("page")
if value not in PAGE_MODES:
errors.append(
f"{where}.page: must be one of {list(PAGE_MODES)} (got {value!r})"
)
pair_id = pair.get("id") pair_id = pair.get("id")
if isinstance(pair_id, str) and pair_id.strip(): if isinstance(pair_id, str) and pair_id.strip():
if pair_id in seen_ids: if pair_id in seen_ids:
@@ -280,6 +302,7 @@ def validate_views_block(
) )
seen_camera_layout[key] = str(pair_id) seen_camera_layout[key] = str(pair_id)
errors.extend(validate_page_block(block.get("page")))
errors.extend(validate_merge_block(block.get("merge"))) errors.extend(validate_merge_block(block.get("merge")))
errors.extend(validate_seam_block(block.get("seam"))) errors.extend(validate_seam_block(block.get("seam")))
errors.extend(validate_print_block(block.get("print"))) errors.extend(validate_print_block(block.get("print")))
@@ -303,6 +326,28 @@ def validate_print_block(print_block: Any) -> list[str]:
return errors return errors
def validate_page_block(page: Any) -> list[str]:
"""Validate the optional ``page`` sub-block (how each pair's page is sized)."""
if page is None:
return []
where = f"{VIEWS_BLOCK_KEY}.page"
if not isinstance(page, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(page) - _PAGE_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_PAGE_KEYS)}")
mode = page.get("mode", PAGE_DEFAULTS["mode"])
if mode not in PAGE_MODES:
errors.append(f"{where}.mode: must be one of {list(PAGE_MODES)} (got {mode!r})")
density = page.get("px_per_mm", PAGE_DEFAULTS["px_per_mm"])
if not isinstance(density, (int, float)) or isinstance(density, bool):
errors.append(f"{where}.px_per_mm: must be a number")
elif float(density) <= 0.0:
errors.append(f"{where}.px_per_mm: must be > 0 (got {density!r})")
return errors
def validate_merge_block(merge: Any) -> list[str]: def validate_merge_block(merge: Any) -> list[str]:
"""Validate the optional ``merge`` sub-block (ownership policy).""" """Validate the optional ``merge`` sub-block (ownership policy)."""
if merge is None: if merge is None:
@@ -453,6 +498,7 @@ def resolve_view_pairs(
# View) and define each pair's REGION for ownership; neither is the pattern source, so # View) and define each pair's REGION for ownership; neither is the pattern source, so
# the pattern default is the sensing camera that also defines `compute`. # the pattern default is the sensing camera that also defines `compute`.
pattern_camera = str(block.get("pattern_camera") or settings.camera) pattern_camera = str(block.get("pattern_camera") or settings.camera)
default_page_mode = str(resolve_page_settings(block)["mode"])
base_seed = settings.seed base_seed = settings.seed
pairs: list[ViewPair] = [] pairs: list[ViewPair] = []
for raw in block["pairs"]: for raw in block["pairs"]:
@@ -473,6 +519,7 @@ def resolve_view_pairs(
filename=str(raw.get("filename") or _default_filename(pair_id)), filename=str(raw.get("filename") or _default_filename(pair_id)),
), ),
seed=None if seed is None else int(seed), seed=None if seed is None else int(seed),
page_mode=str(raw.get("page") or default_page_mode),
) )
) )
return tuple(pairs) return tuple(pairs)
@@ -554,6 +601,22 @@ def resolve_print_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
return settings return settings
def resolve_page_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Resolve the optional ``page`` sub-block into the job's page settings.
The page itself is not computed here: deriving it needs the mesh and the camera, so the
driver does it and the host only carries the mode and the raster density.
"""
settings = dict(PAGE_DEFAULTS)
sub = (block or {}).get("page") or {}
if isinstance(sub, Mapping):
for key in _PAGE_KEYS:
if key in sub:
settings[key] = sub[key]
settings["px_per_mm"] = float(settings["px_per_mm"])
return settings
def build_multiview_job( def build_multiview_job(
paths: RevisionPaths, paths: RevisionPaths,
settings: TexturePatchSettings, settings: TexturePatchSettings,
@@ -579,6 +642,7 @@ def build_multiview_job(
"merge": resolve_merge_policy(block), "merge": resolve_merge_policy(block),
"seam": resolve_seam_settings(block), "seam": resolve_seam_settings(block),
"print": resolve_print_settings(block), "print": resolve_print_settings(block),
"page": resolve_page_settings(block),
"settings": settings.to_job_settings(), "settings": settings.to_job_settings(),
"pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs], "pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs],
} }
+99
View File
@@ -430,6 +430,105 @@ def test_real_revisions_declare_the_seam_margin(real_revision: Path) -> None:
assert gap["total_mm"] == pytest.approx(0.2) assert gap["total_mm"] == pytest.approx(0.2)
# ---------------------------------------------------------------- page sizing
def test_page_defaults_to_auto_at_the_shipped_density(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
page = texture_views.resolve_page_settings(texture_views.load_views_block(paths))
assert page == {"mode": "auto", "px_per_mm": 200.0}
def test_page_density_reproduces_the_existing_rasters() -> None:
"""200 px/mm is what both revisions already use exactly, so only the proportion changes."""
assert texture_views.PAGE_DEFAULT_PX_PER_MM == 200.0
for raster, page_mm in ((3280, 16.4), (3200, 16.0), (3700, 18.5)):
assert raster / page_mm == pytest.approx(200.0)
def test_page_fixed_keeps_the_texture_patch_page(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " page:\n mode: fixed\n")
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["page"]["mode"] == "fixed"
assert all(pair["page"] == "fixed" for pair in job["pairs"])
def test_per_pair_page_override_wins(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n page: fixed\n"
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
) + " page:\n mode: auto\n")
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
assert [pair.page_mode for pair in pairs] == ["fixed", "auto"]
@pytest.mark.parametrize(
("page_yaml", "expected"),
[
(" page:\n mode: guessed\n", "mode"),
(" page:\n px_per_mm: 0\n", "px_per_mm"),
(" page:\n px_per_mm: -5\n", "px_per_mm"),
(" page:\n px_per_mm: thick\n", "px_per_mm"),
(" page:\n nope: 1\n", "unknown key"),
(" page: nope\n", "must be a mapping"),
(
" pairs:\n - {id: a, camera: c, uv_map: u, page: sideways}\n",
"page",
),
],
)
def test_page_block_rejects_bad_values(tmp_path: Path, page_yaml: str, expected: str) -> None:
if page_yaml.startswith(" pairs:"):
# This case replaces the pairs block rather than appending to it.
revision = _write_revision(
tmp_path,
"texture_patch_views:\n enabled: true\n" + page_yaml,
)
else:
revision = _write_revision(tmp_path, TWO_PAIRS + page_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
def test_real_revisions_size_the_page_per_view(real_revision: Path) -> None:
"""Both shipped revisions derive the page, at the density that preserves their raster."""
paths = RevisionPaths.from_revision_dir(real_revision)
block = texture_views.load_views_block(paths)
page = texture_views.resolve_page_settings(block)
assert page["mode"] == "auto"
assert page["px_per_mm"] == 200.0
assert all(pair.page_mode == "auto" for pair in texture_views.resolve_view_pairs(
paths, resolve_texture_settings(paths), block
))
def test_page_derivation_is_not_attempted_host_side(real_revision: Path) -> None:
"""The page needs the mesh and the camera, so the host carries the mode, not a number.
If the host ever starts printing a page it computed itself, this fails — which is the
point: the console must describe the page the run used, not one it guessed.
"""
paths = RevisionPaths.from_revision_dir(real_revision)
block = texture_views.load_views_block(paths)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, paths.root / "o", block)
assert "page_mm" not in job
assert isinstance(job["page"]["mode"], str)
for pair in job["pairs"]:
assert pair["page"] in texture_views.PAGE_MODES
def test_authored_merge_block_resolves_into_the_job(tmp_path: Path) -> None: def test_authored_merge_block_resolves_into_the_job(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE) revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision) paths = RevisionPaths.from_revision_dir(revision)