fix algining xynova texture to uv printing fixture.
@@ -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",
|
||||||
|
]
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 606 KiB After Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 6.0 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 682 KiB |
|
Before Width: | Height: | Size: 5.9 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 795 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 466 KiB After Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 491 KiB After Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||