Files
orisys-sensor-design/blender/scripts/page_sizing.py
T
2026-09-29 16:33:31 +08:00

210 lines
8.4 KiB
Python

"""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",
]