added uv unwarp export

This commit is contained in:
ykcheungab
2026-09-25 17:10:05 +08:00
parent c377c14474
commit f91ac9d3db
8 changed files with 1065 additions and 220 deletions
+26 -5
View File
@@ -73,7 +73,7 @@ Four commands for v1:
# 1) Check revision metadata and (optionally) Blender scene conventions
orisys-design validate sensors/finger/r01
# 2) Export nominal geometry for a profile (compute → SDK layout)
# 2) Export nominal geometry plus the unwarp/valid-mask map (compute → SDK layout)
orisys-design export sensors/finger/r01 --profile compute
# 3) Generate the colored patch texture headlessly
@@ -109,9 +109,27 @@ build/export/finger/r01/
└── compute/ # clean SDK-shaped payload
├── mesh.npz
├── camera_intrinsics.npz
└── world_to_cam.npy
├── world_to_cam.npy
└── unroll_map_uv_s0.42.npz # unwarp/valid-mask domain (name carries source_scale)
```
The last one is the **UV domain** the application treats as sensing: everything outside
its `valid_mask` island renders grey. Blender writes no mask, so the export builds it
afterwards with the SDK's own builder (`orisys.unroll.precompute`), from the profile's
`uv_map:` block — no Blender involved, so the mask can also be rebuilt from an exported
payload alone:
```powershell
python tools/build_uv_map.py --export-dir build/export/finger/r01/compute `
--profile sensors/finger/r01/profiles/compute.yaml
```
The parameters are never guessed (a wrong output size or source scale still produces a
plausible-looking mask), and a failed map build exits non-zero *before* the artifact check,
so a bundle whose mask is missing or stale cannot be reported as complete. The SDK asset
copy is updated only when the revision is promoted — gated by
`orisys-app/tools/scene_assets/compare_uv_map.py`.
## Texture patches (headless)
`orisys-design texture` generates a PNG, editable SVG, and native vector PDF for
@@ -564,8 +582,9 @@ Compare patterns only with the radius, ROI and scale held fixed — which is why
- **`compute`** — `sensing_surface` + `cam_sensor` relative to `origin_sensor`. Writes
`mesh.npz`, `camera_intrinsics.npz`, `world_to_cam.npy` (here “world” means the
sensor frame). Studio and the wheel build consume this tree via `orisys-sdk` →
`assets_dir`.
sensor frame) and `unroll_map_uv_s<scale>.npz`, the unwarp/valid-mask domain built from
the profile's `uv_map:` block. Studio and the wheel build consume this tree via
`orisys-sdk` → `assets_dir`.
- **`studio`** — documents how to compile a `.oryscene` from the **compute** export
plus the measured unroll map in SDK. Observation cameras in Studio are derived from
mesh bounds; no Blender `cam_viz` is required.
@@ -579,7 +598,9 @@ After export:
(or publish as a release artifact).
3. Keep `build/export/finger/r01/manifest.json` beside the payload, and update
optional `design-lock.json` in `orisys-sdk` with commit + bundle SHA256.
4. Run `orisys-app` `compile_scene.py` using the locked compute export + SDK unroll map.
4. Run `orisys-app` `compile_scene.py` using the locked compute export + the exported
unroll map. The map now travels with the payload; the SDK asset copy is refreshed only
here, and only after `tools/scene_assets/compare_uv_map.py` agrees with what ships.
Example lock file: `examples/design-lock.json`.
+23 -11
View File
@@ -11,10 +11,18 @@ artifacts:
- housing.npz
- camera_intrinsics.npz
- world_to_cam.npy
# The unwarp/valid-mask domain, built by `orisys-design export` from the uv_map: block
# below (SDK precompute; no Blender, so it can be rebuilt without the .blend). Listing it
# here makes a missing mask fail the export instead of shipping a bundle that renders grey.
# The name carries source_scale: 0.42 -> `scaled_map_cache_path` names it _s0.42, and the
# runtime must be told the same scale (sensor.json camera.source_scale) or it looks for a
# differently-named cache and rebuilds one at a mismatch.
- unroll_map_uv_s0.42.npz
# Measured unroll maps stay in SDK (not overwritten by nominal export).
runtime_owned_by_sdk:
- unroll_map_uv_s0.4.npz
# Nothing: the unroll map used to be listed here as SDK-owned. It is now an export artifact,
# and the SDK asset copy (orisys-sdk/sensors/finger/assets/<rev>/unroll_map_uv_s0.42.npz) is
# updated only when this revision is promoted - gated by tools/scene_assets/compare_uv_map.py.
runtime_owned_by_sdk: []
# Parameters of the unroll/valid-mask domain. Recorded here because the mask decides which
# part of the sensing mesh the application treats as *sensing* (everything else renders
@@ -24,8 +32,10 @@ runtime_owned_by_sdk:
# source size produced 0.8 % island coverage instead of 40.8 %.
# * source_K must be scaled by source_scale (orisys.unroll.geometry.scale_camera_geometry).
# Passing the native K produced 3.5 % instead of 40.8 %.
# Rebuild with tools/build_uv_map.py --profile <this file>; it writes into output_dir first
# and is promoted to the SDK asset dir only after tools/scene_assets/compare_uv_map.py agrees.
# Rebuild with `orisys-design export <revision> --profile compute`, which writes the map
# into output_dir in the same run; tools/build_uv_map.py is the standalone equivalent for a
# rebuild without Blender (same module, same parameters). Either way the map is promoted to
# the SDK asset dir only after tools/scene_assets/compare_uv_map.py agrees.
uv_map:
out_width: 280 # = camera.resolution_stitch[0]; the field domain the app sees
out_height: 300 # = camera.resolution_stitch[1]
@@ -37,12 +47,14 @@ uv_map:
# * 1280x720 camera -> max(280/1280, 300/720) = 0.417 (source 533x300)
# * 1920x1080 camera -> max(280/1920, 300/1080) = 0.278 (source 533x300 — the same grid,
# because the rule follows the *output* requirement, not the camera)
# 0.4 is pinned because it is the value the running system and the existing recordings were
# measured at; at a 1280x720 camera it yields a 512x288 source, i.e. 4 % coarser than the
# 300-row output (mild upsampling). Changing it changes the flow magnitudes the app's
# thresholds were tuned against AND the map's field_domain_id, so it must move together with
# sensor.json's camera.source_scale and a recompiled scene package.
source_scale: 0.4
# 0.42 is the chosen value: the derived floor for a 1280x720 camera is 0.417 (source 533x300),
# and 0.42 gives 538x302 — just above the 280x300 output grid, so the remap *resamples* rather
# than upsamples and the final resolution is unchanged. That is the point: slightly coarser than
# native buys per-frame headroom in the resize/undistort/remap path without touching the result.
# Changing it still changes the map's field_domain_id and the flow magnitudes the app's
# thresholds were tuned at, so it moves together with sensor.json's camera.source_scale and a
# recompiled scene package — never on its own.
source_scale: 0.42
edge_band: 0.12
edge_expand: 1.0 # <= 1.0 means "resolve from the measured UV distortion"
Binary file not shown.
+83 -3
View File
@@ -193,6 +193,75 @@ def cmd_validate(revision_dir: Path, blender: bool) -> int:
return _run_blender_script("validate.py", paths.source_blend, [str(revision_dir.resolve())])
#: Exit code when the unwarp/valid-mask map cannot be built. Deliberately distinct from 3
#: ("Blender finished but a profile artifact is missing"): an incomplete export payload and
#: a failed mask step are different repairs.
UV_MAP_EXIT_CODE = 4
def _run_uv_map_stage(profile_data: dict, output_dir: Path) -> int:
"""Build the unwarp/valid-mask ("UV domain") map for the export that just finished.
This runs after Blender and before the artifact check, so a bundle whose mask is
missing or stale can never be reported as complete: everything outside the mask's
island renders grey, which is invisible in the exported payload itself.
The map needs the SDK and numpy but never Blender, which is why it lives here rather
than in ``blender/scripts/export.py``. A profile with no ``uv_map:`` block is an error
once it also requires the map as an artifact; a profile that does not (its runtime
bundle is still owned by the SDK) keeps its old behaviour and gets a note instead.
"""
artifacts = [str(name) for name in (profile_data.get("artifacts") or [])]
if "uv_map" not in profile_data:
if any("unroll_map" in name for name in artifacts):
print(
"ERROR: the profile lists an unroll map artifact but has no uv_map: block, "
"so the unwarp/valid-mask domain cannot be built. Add out_width/out_height/"
"flip_v/source_scale/edge_band/edge_expand; these are never guessed.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
print("NOTE: profile declares no uv_map: block; skipping the unwarp/valid-mask build")
return 0
try:
from orisys_sensor_design.uv_map import (
UvMapError,
build_uv_map,
load_uv_map_params,
)
except ImportError as exc:
print(
f"ERROR: the unwarp/valid-mask step needs numpy and the orisys SDK "
f"({type(exc).__name__}: {exc}).\n"
f"The export payload is in {output_dir} but carries no valid_mask, so it is not "
"a complete runtime bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
try:
params = load_uv_map_params(profile_data, source=f"profile {profile_data.get('profile')!r}")
build_uv_map(output_dir, params, output_dir)
except UvMapError as exc:
print(
f"ERROR: could not build the unwarp/valid-mask map: {exc}\n"
f"The exported geometry is in {output_dir}, but without the map the application "
"renders the whole sensing surface grey - do not promote this bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
except Exception as exc: # noqa: BLE001 - report, never leave a half-built bundle
print(
f"ERROR: the unwarp/valid-mask step failed ({type(exc).__name__}: {exc}).\n"
f"The exported geometry is in {output_dir} but has no usable map; do not "
"promote this bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
return 0
def cmd_export(
revision_dir: Path, profile: str, housing_collections: str | None = None
) -> int:
@@ -204,7 +273,9 @@ def cmd_export(
print(f"ERROR: {message}", file=sys.stderr)
return 1
load_profile(paths, profile)
# Loaded once: the export job, the map parameters and the artifact list all come from
# the same profile, and re-reading it could see a different file than the one checked.
profile_data = load_profile(paths, profile)
output_dir = export_output_dir(root, paths, profile)
output_dir.mkdir(parents=True, exist_ok=True)
collections = (
@@ -248,8 +319,14 @@ def cmd_export(
return code
print(f"Export target: {output_dir}")
# The mask decides what the application treats as sensing; it is built here (no
# Blender) and before the artifact check so an incomplete bundle cannot look complete.
code = _run_uv_map_stage(profile_data, output_dir)
if code != 0:
return code
missing = []
for name in load_profile(paths, profile).get("artifacts", []):
for name in profile_data.get("artifacts", []):
if not (output_dir / name).is_file():
missing.append(name)
if missing:
@@ -1673,7 +1750,10 @@ def main(argv: list[str] | None = None) -> None:
help="Run blender/scripts/validate.py headless",
)
export_p = sub.add_parser("export", help="Export nominal geometry for a profile")
export_p = sub.add_parser(
"export",
help="Export nominal geometry plus the unwarp/valid-mask map for a profile",
)
export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"])
export_p.add_argument(
+426
View File
@@ -0,0 +1,426 @@
"""Build the unwarp/valid-mask ("UV domain") map for a design export.
``unroll_map_uv_s0.4.npz`` — ``map_x``, ``map_y``, ``valid_mask``, ``world_xyz`` plus the
parameters that produced them — is what decides which part of the sensing mesh the
application treats as *sensing*; everything outside its island renders grey. The Blender
export writes **no** mask (``blender/scripts/export.py`` writes geometry, optics,
intrinsics and pose only), so ``orisys-design export`` calls :func:`build_uv_map` once
Blender has finished. Nothing here needs Blender: the mask can be rebuilt from an exported
payload alone, which is exactly why it does not live in the Blender script.
The map itself comes from the SDK's own builder (``orisys.unroll.precompute``): it
rasterises the mesh's authored UV faces through the camera with a depth buffer, records
the parameters it used, and ``build_or_load_domain`` already knows how to spot a stale
cache. This module is the thin, importable wrapper around that: resolve the parameters
(never guess them), derive the source frame, call the SDK, and report what came out.
Three details are load-bearing, and each one was paid for with a wrong measurement:
* ``source_K`` must be **scaled to the source resolution** by
``orisys.unroll.geometry.scale_camera_geometry``. Passing the native K produced 3.5 %
island coverage instead of 40.8 %.
* ``source_width``/``source_height`` must be **derived from the export's own
``camera_intrinsics.npz``** (native size x ``source_scale``), never inherited from
another camera's map. Inheriting one produced 0.8 % coverage.
* the file name must come from ``orisys.unroll.geometry.scaled_map_cache_path`` (which
appends ``_s0.4``), so maps built at different source scales cannot collide.
The parameters are never guessed either: they come from the profile's ``uv_map:`` block
(:func:`load_uv_map_params`) or from an existing map / explicit command-line overrides in
the standalone ``tools/build_uv_map.py``.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import numpy as np
#: What the Blender export must have written before a map can be built. This is the
#: export payload, not the source .blend: the mask is built from the exported geometry,
#: calibration and pose.
REQUIRED_EXPORT_FILES = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
#: Parameters the map must state for a faithful rebuild. ``source_width``/``source_height``
#: are deliberately *not* here: they are derived from the export's own calibration (K's
#: image size times ``source_scale``), so a map built from a 1920x1080 camera is not
#: rebuilt as if it were 1280x720.
PARAM_KEYS = ("out_width", "out_height", "flip_v", "source_scale", "edge_band", "edge_expand")
#: Base name handed to ``scaled_map_cache_path``; the SDK appends the scale suffix.
MAP_BASENAME = "unroll_map_uv.npz"
class UvMapError(RuntimeError):
"""The unwarp/valid-mask map cannot be built from what the export and profile give.
Raised for a missing/patchy ``uv_map:`` block, an unreadable export payload, an
unimportable SDK, and for a written map that turns out to have no usable
``valid_mask``. The exporter turns this into a non-zero exit rather than leaving a
bundle that looks complete: a missing mask is invisible until the operator sees grey.
"""
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _as_bool(value: Any) -> bool:
"""Coerce a YAML/JSON value to bool (a quoted "false" must not read as True)."""
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "on")
return bool(value)
def _as_int(key: str, value: Any, source: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise UvMapError(f"{source}: uv_map.{key} must be an integer, got {value!r}") from exc
def _as_float(key: str, value: Any, source: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise UvMapError(f"{source}: uv_map.{key} must be a number, got {value!r}") from exc
def load_uv_map_params(profile: dict[str, Any], *, source: str = "the profile") -> dict[str, Any]:
"""Resolve the ``uv_map:`` block of a loaded profile mapping.
Returns the six parameters with their types fixed (``out_width``/``out_height`` int,
``flip_v`` bool, the rest float). A missing block, a missing key, or an unknown key is
an error: these numbers decide the mask, and a wrong ``out_width`` or ``source_scale``
still produces a plausible-looking map, so guessing is worse than failing.
"""
if not isinstance(profile, dict):
raise UvMapError(f"{source} is not a profile mapping (got {type(profile).__name__})")
block = profile.get("uv_map")
if not isinstance(block, dict) or not block:
raise UvMapError(
f"{source} has no uv_map: block, so the unwarp/valid-mask domain cannot be "
"built (add out_width/out_height/flip_v/source_scale/edge_band/edge_expand). "
"These parameters are never guessed: a wrong output size or source scale "
"still produces a plausible-looking mask."
)
unknown = sorted(set(block) - set(PARAM_KEYS))
if unknown:
raise UvMapError(f"{source}: uv_map has keys this build does not understand: {unknown}")
absent = [key for key in PARAM_KEYS if key not in block]
if absent:
raise UvMapError(f"{source}: uv_map is missing {absent}")
params: dict[str, Any] = {
"out_width": _as_int("out_width", block["out_width"], source),
"out_height": _as_int("out_height", block["out_height"], source),
"flip_v": _as_bool(block["flip_v"]),
}
for key in ("source_scale", "edge_band", "edge_expand"):
params[key] = _as_float(key, block[key], source)
_validate_params(params, source)
return params
def params_from_map(path: Path) -> dict[str, Any]:
"""Read the parameters a built map recorded about itself."""
path = Path(path)
try:
with np.load(path, allow_pickle=False) as data:
found: dict[str, Any] = {}
for key in PARAM_KEYS:
if key in data.files:
found[key] = np.asarray(data[key]).reshape(-1)[0].item()
except OSError as exc:
raise UvMapError(f"could not read {path}: {exc}") from exc
missing = [key for key in PARAM_KEYS if key not in found]
if missing:
raise UvMapError(
f"{path.name} does not record {missing}; it predates the self-describing "
"metadata, so pass the parameters explicitly or from the profile instead"
)
found["edge_expand"] = float(found["edge_expand"])
found["edge_band"] = float(found["edge_band"])
found["source_scale"] = float(found["source_scale"])
found["flip_v"] = _as_bool(found["flip_v"])
for key in ("out_width", "out_height"):
found[key] = int(found[key])
_validate_params(found, str(path))
return found
def _validate_params(params: dict[str, Any], source: str) -> None:
if params["out_width"] < 1 or params["out_height"] < 1:
raise UvMapError(f"{source}: out_width/out_height must be positive")
if params.get("source_scale") is not None and params["source_scale"] <= 0:
raise UvMapError(f"{source}: source_scale must be positive")
def derive_source_scale(
native_width: int, native_height: int, out_width: int, out_height: int
) -> float:
"""The smallest scale whose source grid still covers the output grid 1:1.
The remap *gathers* from the source frame into the ``out_width x out_height`` domain,
so if the source is smaller than the output in either axis, several output pixels read
the same source pixel and the unrolled picture is blocky. Requiring
min(native_width * s, native_height * s) >= max(out_width, out_height) (per axis)
gives ``s = max(out_w / native_w, out_h / native_h)``: coverage without upsampling,
and — the useful property — a source size that follows the *output* requirement, so
two cameras of different resolution produce the same output quality.
Above this the scale only buys interpolation smoothness and costs per-frame pixel work
(the live frame is resized by ``s`` before undistort/remap), so the derived value is
the sensible floor rather than the optimum: a profile may pin a larger one
deliberately, and this is what the run reports it against.
"""
if native_width <= 0 or native_height <= 0:
raise UvMapError(
"the export's camera_intrinsics.npz has a non-positive image size "
f"({native_width} x {native_height})"
)
if out_width <= 0 or out_height <= 0:
raise UvMapError(f"the output grid must be positive (got {out_width} x {out_height})")
scale = max(out_width / float(native_width), out_height / float(native_height))
# Keep the cache name readable (it embeds the scale) and never exceed the native frame.
return min(1.0, round(scale + 1e-9, 3))
def _warn_if_upsampled(
native_width: int,
native_height: int,
source_width: int,
source_height: int,
out_width: int,
out_height: int,
) -> None:
"""Say it out loud when the output grid is finer than the source it samples.
The suggestion is expressed against the **native** frame, because that is what the
profile's ``source_scale`` is relative to; computing it from the source frame would
advise a scale of 1.0 for any downscale, which is wrong.
"""
if source_width >= out_width and source_height >= out_height:
return
needed = derive_source_scale(native_width, native_height, out_width, out_height)
print(
f"WARNING: the source frame {source_width} x {source_height} is smaller than the "
f"{out_width} x {out_height} output grid, so the unrolled picture is upsampled "
f"(several output pixels read one source pixel). source_scale >= {needed} avoids "
"it; below that the picture is softer but the per-frame cost is lower."
)
def _import_sdk():
"""Import the SDK pieces the map build needs, with a message that says what to do."""
try:
import orisys
from orisys.unroll.geometry import scale_camera_geometry, scaled_map_cache_path
from orisys.unroll.precompute import build_or_load_domain
except Exception as exc: # noqa: BLE001 - any import failure is the same story here
raise UvMapError(
f"the orisys SDK is not importable ({type(exc).__name__}: {exc}); install the "
"orisys checkout you intend to build with — the map must come from the same "
"implementation that built the shipped one"
) from exc
return orisys, scale_camera_geometry, scaled_map_cache_path, build_or_load_domain
def _read_native_camera(export_dir: Path) -> tuple[np.ndarray, int, int]:
intrinsics = export_dir / "camera_intrinsics.npz"
try:
with np.load(intrinsics, allow_pickle=False) as handle:
K = np.asarray(handle["K"], dtype=np.float64)
width = int(np.asarray(handle["width"]).reshape(-1)[0])
height = int(np.asarray(handle["height"]).reshape(-1)[0])
except OSError as exc:
raise UvMapError(f"could not read {intrinsics}: {exc}") from exc
except KeyError as exc:
raise UvMapError(f"{intrinsics} has no {exc.args[0]!r} array") from exc
if K.shape != (3, 3):
raise UvMapError(f"{intrinsics}: K must be 3x3, got {K.shape}")
return K, width, height
def build_uv_map(
export_dir: Path,
params: dict[str, Any],
output_dir: Path,
*,
force: bool = False,
) -> Path:
"""Build (or load) the unwarp/valid-mask map for an exported payload.
``export_dir`` must hold the payload ``blender/scripts/export.py`` writes.
``output_dir`` is where the map is written; the name comes from
``scaled_map_cache_path(output_dir / "unroll_map_uv.npz", source_scale)``.
``force`` deletes the target first so the SDK must rebuild rather than load. It is
only for "rebuild regardless": ``build_or_load_domain`` already regenerates a cache
whose recorded parameters no longer match.
Returns the path written. Raises :class:`UvMapError` — never a bare ``SystemExit`` —
so the export flow owns the exit code.
"""
export_dir = Path(export_dir)
output_dir = Path(output_dir)
missing = [name for name in REQUIRED_EXPORT_FILES if not (export_dir / name).is_file()]
if missing:
raise UvMapError(
f"{export_dir} is missing {missing}: this needs the payload "
"blender/scripts/export.py writes, not the source .blend"
)
absent = [
key
for key in ("out_width", "out_height", "flip_v", "edge_band", "edge_expand")
if key not in params
]
if absent:
raise UvMapError(f"no value for {absent}: they come from the profile's uv_map: block")
_validate_params(params, "uv_map parameters")
orisys, scale_camera_geometry, scaled_map_cache_path, build_or_load_domain = _import_sdk()
K_native, native_width, native_height = _read_native_camera(export_dir)
out_width = int(params["out_width"])
out_height = int(params["out_height"])
# The scale is a decision, so it is reported as one: the profile (or the CLI) may pin
# it, and this says what the output grid would otherwise require.
derived_scale = derive_source_scale(native_width, native_height, out_width, out_height)
pinned_scale = params.get("source_scale") is not None
effective_scale = float(params["source_scale"]) if pinned_scale else derived_scale
if effective_scale <= 0:
raise UvMapError(f"source_scale must be positive, got {effective_scale}")
source_K, source_width, source_height = scale_camera_geometry(
K_native, native_width, native_height, effective_scale
)
# Explicit overrides exist only for the standalone diagnostic (tools/build_uv_map.py).
if params.get("source_width") is not None:
source_width = int(params["source_width"])
if params.get("source_height") is not None:
source_height = int(params["source_height"])
source_width, source_height = int(source_width), int(source_height)
_warn_if_upsampled(
native_width, native_height, source_width, source_height, out_width, out_height
)
output_dir.mkdir(parents=True, exist_ok=True)
base = str(output_dir / MAP_BASENAME)
target = Path(scaled_map_cache_path(base, effective_scale))
cached = target.is_file()
if force and cached:
target.unlink()
cached = False
print(f"removed {target.name} (--force: rebuild rather than load)")
used = dict(params)
used["source_scale"] = effective_scale
print(f"export {export_dir}")
print(f"target {target}")
print(f"SDK orisys {getattr(orisys, '__version__', '?')}")
print(f"parameters {used}")
print(f"native camera {native_width} x {native_height}, f={K_native[0, 0]:.1f}")
print(
f"source frame {source_width} x {source_height} "
f"(scale {effective_scale}), f={source_K[0, 0]:.1f}"
)
print(
f"scale {'pinned' if pinned_scale else 'derived'} {effective_scale} "
f"(output {out_width} x {out_height} needs >= {derived_scale})"
)
print(
"cache "
+ (
"present - the SDK loads it unless its recorded parameters no longer match"
if cached
else "absent - the SDK builds it now"
)
)
# The SDK prints "Loaded cached UV domain" or "Generated UV domain valid ratio", which
# is the run's own statement of whether this was a read or a write.
build_or_load_domain(
str(export_dir / "mesh.npz"),
str(export_dir / "camera_intrinsics.npz"),
str(export_dir / "world_to_cam.npy"),
str(target),
source_K=source_K,
source_width=source_width,
source_height=source_height,
out_width=out_width,
out_height=out_height,
flip_v=bool(params["flip_v"]),
edge_band=float(params["edge_band"]),
edge_expand=float(params["edge_expand"]),
include_surface=True,
verbose=True,
source_scale=effective_scale,
)
if not target.is_file():
raise UvMapError(
f"the SDK reported success but wrote no map at {target}; the export is not "
"complete"
)
_report_written_map(target, out_width=out_width, out_height=out_height)
return target
def _report_written_map(target: Path, *, out_width: int, out_height: int) -> None:
"""Verify and report the map that is now on disk.
A missing ``valid_mask`` is fatal: everything outside it renders grey, and nothing
downstream can tell a missing mask from a mask that legitimately says "not sensing".
"""
try:
with np.load(target, allow_pickle=False) as written:
files = set(written.files)
if "valid_mask" not in files:
raise UvMapError(
f"{target} has no valid_mask array (has {sorted(files)}); without it "
"the application has no island to treat as sensing"
)
mask = np.asarray(written["valid_mask"]).astype(bool)
surface = "world_xyz" in files
except OSError as exc:
raise UvMapError(f"could not read back {target}: {exc}") from exc
if mask.shape != (out_height, out_width):
raise UvMapError(
f"{target}: valid_mask is {mask.shape[1]} x {mask.shape[0]} (W x H) but the "
f"profile asks for {out_width} x {out_height}; the map does not match the "
"domain the profile declares"
)
coverage = float(mask.mean()) * 100.0
if int(mask.sum()) == 0:
raise UvMapError(
f"{target}: the valid mask has no set pixel, so no part of the mesh maps into "
"the output domain - the application would render the whole sensor grey"
)
if not surface:
raise UvMapError(
f"{target} has no world_xyz array, so the 3D UV-to-surface mapping the SDK "
"unroll needs is missing; the export is not complete"
)
print(f"\nwrote {target}")
print(f" shape {mask.shape[1]} x {mask.shape[0]} (W x H)")
print(f" coverage {coverage:.3f} % of the domain ({int(mask.sum())} pixels)")
print(" surface map present")
print(f" sha256 {_sha256(target)}")
def map_compare_hint(target: Path) -> str:
"""The one-line cross-check against the shipped map, for both callers to print."""
return (
" python D:/orisys_ws/orisys-app/tools/scene_assets/compare_uv_map.py "
f'--map "{target}" --against "<shipped>.npz"'
)
+7 -1
View File
@@ -118,12 +118,18 @@ def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> N
assert loaded["sensor"] == "finger"
assert loaded["revision"] == "r01"
assert loaded["profile"] == "compute"
assert loaded["expected_artifacts"] == [
assert loaded["expected_artifacts"][:4] == [
"mesh.npz",
"housing.npz",
"camera_intrinsics.npz",
"world_to_cam.npy",
]
# The unwarp/valid-mask map is built by the export itself (SDK precompute); listing it
# makes a missing mask fail the export instead of shipping a bundle that renders grey.
# Its name embeds the profile's source_scale (`scaled_map_cache_path`), so match the
# family rather than one pinned value.
map_name = loaded["expected_artifacts"][4]
assert map_name.startswith("unroll_map_uv_s") and map_name.endswith(".npz")
assert loaded["source_blend"].endswith("geometry.blend")
+435
View File
@@ -0,0 +1,435 @@
"""Tests for the unwarp/valid-mask ("UV domain") map the export builds.
Two layers:
* the module itself (``orisys_sensor_design.uv_map``) — needs only numpy, so it runs with
the interpreter that builds the SDK-side artifacts;
* the export flow in ``cli.cmd_export`` — needs PyYAML for the profile loader, so those
tests skip when it is absent rather than failing for the wrong reason.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from orisys_sensor_design import uv_map as uv
REPO_ROOT = Path(__file__).resolve().parents[1]
REVISION = REPO_ROOT / "sensors" / "finger" / "r01"
COMPUTE_PROFILE = REVISION / "profiles" / "compute.yaml"
#: A 1280x720 export like sensors/finger/r01/.../compute/camera_intrinsics.npz.
NATIVE_K = np.array(
[[415.62082888, 0.0, 640.0], [0.0, 415.62082888, 360.0], [0.0, 0.0, 1.0]]
)
# ----------------------------------------------------------------------
# Derived source scale
# ----------------------------------------------------------------------
def test_derive_source_scale_follows_the_output_requirement() -> None:
# max(280/1280, 300/720) = 0.4166... -> 0.417; the floor that avoids upsampling.
assert uv.derive_source_scale(1280, 720, 280, 300) == 0.417
def test_derive_source_scale_is_camera_independent_for_one_output_grid() -> None:
# Both cameras land on the same 533x300 source grid because the rule follows the
# output requirement, not the native frame.
assert uv.derive_source_scale(1920, 1080, 280, 300) == 0.278
scale = uv.derive_source_scale(1920, 1080, 280, 300)
assert int(1920 * scale) >= 280 and int(1080 * scale) >= 300
def test_derive_source_scale_covers_the_output_one_to_one() -> None:
assert uv.derive_source_scale(1280, 720, 640, 360) == 0.5
# A downscale to half the frame still needs half the scale, not 1.0.
assert uv.derive_source_scale(1280, 720, 280, 300) < 0.5
def test_derive_source_scale_never_exceeds_the_native_frame() -> None:
# An output grid larger than the camera cannot be covered without upsampling, so the
# scale is clamped rather than raised above 1.0.
assert uv.derive_source_scale(320, 240, 640, 480) == 1.0
@pytest.mark.parametrize(
"native_width, native_height",
[(0, 720), (1280, 0), (-1, 720)],
)
def test_derive_source_scale_rejects_a_non_positive_camera(
native_width: int, native_height: int
) -> None:
with pytest.raises(uv.UvMapError, match="non-positive"):
uv.derive_source_scale(native_width, native_height, 280, 300)
# ----------------------------------------------------------------------
# Parameter resolution: never guess
# ----------------------------------------------------------------------
GOOD_BLOCK = {
"out_width": 280,
"out_height": 300,
"flip_v": True,
"source_scale": 0.4,
"edge_band": 0.12,
"edge_expand": 1.0,
}
def test_load_uv_map_params_reads_and_types_the_block() -> None:
block = dict(GOOD_BLOCK, out_width="280", source_scale="0.4")
params = uv.load_uv_map_params({"uv_map": block})
assert params == {
"out_width": 280,
"out_height": 300,
"flip_v": True,
"source_scale": 0.4,
"edge_band": 0.12,
"edge_expand": 1.0,
}
assert isinstance(params["out_width"], int)
assert isinstance(params["flip_v"], bool)
def test_load_uv_map_params_errors_without_a_uv_map_block() -> None:
# The whole point: a missing block must fail, not fall back to a default domain.
with pytest.raises(uv.UvMapError, match="no uv_map: block"):
uv.load_uv_map_params({"profile": "compute", "artifacts": ["mesh.npz"]})
def test_load_uv_map_params_errors_on_an_empty_block() -> None:
with pytest.raises(uv.UvMapError, match="no uv_map: block"):
uv.load_uv_map_params({"uv_map": {}})
def test_load_uv_map_params_errors_on_a_missing_key() -> None:
block = dict(GOOD_BLOCK)
del block["source_scale"]
with pytest.raises(uv.UvMapError, match="missing \\['source_scale'\\]"):
uv.load_uv_map_params({"uv_map": block})
def test_load_uv_map_params_errors_on_an_unknown_key() -> None:
block = dict(GOOD_BLOCK, out_depth=4)
with pytest.raises(uv.UvMapError, match="does not understand"):
uv.load_uv_map_params({"uv_map": block})
def test_quoted_false_does_not_read_as_true() -> None:
params = uv.load_uv_map_params({"uv_map": dict(GOOD_BLOCK, flip_v="false")})
assert params["flip_v"] is False
def test_compute_profile_pins_the_scale_the_map_is_built_at() -> None:
pytest.importorskip("yaml", reason="reading the profile needs PyYAML")
from orisys.unroll.geometry import scaled_map_cache_path
from orisys_sensor_design.manifest import load_yaml
profile = load_yaml(COMPUTE_PROFILE)
params = uv.load_uv_map_params(profile)
# Whatever the profile pins is what the build uses (the value is a deliberate choice,
# so this asserts the wiring, not one particular number).
assert params["source_scale"] == float(profile["uv_map"]["source_scale"])
assert (params["out_width"], params["out_height"]) == (280, 300)
# ... and the artifact the export must produce is named for that same scale, so a
# changed scale cannot leave a stale, differently-named map looking complete.
expected = Path(scaled_map_cache_path("unroll_map_uv.npz", params["source_scale"])).name
assert expected in profile["artifacts"]
# ----------------------------------------------------------------------
# build_uv_map: the SDK call, with a stubbed SDK
# ----------------------------------------------------------------------
@pytest.fixture()
def export_dir(tmp_path: Path) -> Path:
"""An export payload directory with only the calibration the map step reads."""
directory = tmp_path / "compute"
directory.mkdir()
np.savez(
directory / "camera_intrinsics.npz",
K=NATIVE_K,
width=np.array(1280),
height=np.array(720),
)
(directory / "mesh.npz").write_bytes(b"mesh")
(directory / "world_to_cam.npy").write_bytes(b"pose")
return directory
def _stub_sdk(monkeypatch: pytest.MonkeyPatch, calls: dict, mask_mode: str = "valid"):
"""Replace the two SDK entry points the map step calls, recording the arguments."""
import orisys.unroll.geometry as geometry
import orisys.unroll.precompute as precompute
def fake_scale_camera_geometry(K, width, height, scale):
calls["scale_args"] = (width, height, scale)
return np.asarray(K, dtype=np.float64) * scale, int(round(width * scale)), int(
round(height * scale)
)
def fake_build_or_load_domain(mesh, intrinsics, pose, map_path, **kwargs):
calls["build_kwargs"] = kwargs
calls["target"] = map_path
calls["target_existed"] = Path(map_path).is_file()
width = int(kwargs["out_width"])
height = int(kwargs["out_height"])
arrays = {
"map_x": np.zeros((height, width), np.float32),
"map_y": np.zeros((height, width), np.float32),
}
if mask_mode == "valid":
mask = np.zeros((height, width), bool)
mask[height // 2 :, :] = True
arrays["valid_mask"] = mask
arrays["world_xyz"] = np.zeros((height, width, 3), np.float32)
elif mask_mode == "empty":
arrays["valid_mask"] = np.zeros((height, width), bool)
arrays["world_xyz"] = np.zeros((height, width, 3), np.float32)
elif mask_mode == "no_surface":
arrays["valid_mask"] = np.ones((height, width), bool)
elif mask_mode == "wrong_shape":
arrays["valid_mask"] = np.ones((4, 4), bool)
arrays["world_xyz"] = np.zeros((4, 4, 3), np.float32)
np.savez(map_path, **arrays)
return None
monkeypatch.setattr(geometry, "scale_camera_geometry", fake_scale_camera_geometry)
monkeypatch.setattr(precompute, "build_or_load_domain", fake_build_or_load_domain)
def test_build_uv_map_scales_source_K_to_the_profile_scale(
export_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: dict = {}
_stub_sdk(monkeypatch, calls)
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
target = uv.build_uv_map(export_dir, params, export_dir)
assert target == export_dir / "unroll_map_uv_s0.4.npz"
assert target.is_file()
kwargs = calls["build_kwargs"]
# 1280 x 0.4 — the scaled K, not the native one (that measured 3.5 % instead of 40.8 %).
assert kwargs["source_width"] == 512
assert kwargs["source_height"] == 288
assert kwargs["source_scale"] == 0.4
assert kwargs["source_K"][0, 0] == pytest.approx(NATIVE_K[0, 0] * 0.4)
assert (kwargs["out_width"], kwargs["out_height"]) == (280, 300)
assert kwargs["include_surface"] is True
def test_build_uv_map_takes_the_source_frame_from_this_export(
export_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: dict = {}
_stub_sdk(monkeypatch, calls)
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
uv.build_uv_map(export_dir, params, export_dir)
# The source frame is derived from *this* export's camera_intrinsics.npz, never
# inherited from another camera's map (that measured 0.8 %).
assert calls["scale_args"] == (1280, 720, 0.4)
def test_build_uv_map_derives_the_scale_when_none_is_pinned(
export_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: dict = {}
_stub_sdk(monkeypatch, calls)
params = dict(GOOD_BLOCK)
del params["source_scale"]
target = uv.build_uv_map(export_dir, params, export_dir)
assert target.name == "unroll_map_uv_s0.417.npz"
assert calls["build_kwargs"]["source_scale"] == 0.417
def test_force_deletes_the_target_so_the_sdk_must_rebuild(
export_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: dict = {}
_stub_sdk(monkeypatch, calls)
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
stale = export_dir / "unroll_map_uv_s0.4.npz"
stale.write_bytes(b"stale map")
uv.build_uv_map(export_dir, params, export_dir)
assert calls["target_existed"] is True # without --force the SDK may load it
uv.build_uv_map(export_dir, params, export_dir, force=True)
assert calls["target_existed"] is False
def test_build_uv_map_refuses_a_payload_the_export_did_not_write(tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(uv.UvMapError, match="missing"):
uv.build_uv_map(empty, dict(GOOD_BLOCK), empty)
@pytest.mark.parametrize(
"mask_mode, message",
[
("no_surface", "world_xyz"),
("empty", "no set pixel"),
("wrong_shape", "does not match"),
],
)
def test_build_uv_map_rejects_an_incomplete_map(
export_dir: Path, monkeypatch: pytest.MonkeyPatch, mask_mode: str, message: str
) -> None:
calls: dict = {}
_stub_sdk(monkeypatch, calls, mask_mode=mask_mode)
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
with pytest.raises(uv.UvMapError, match=message):
uv.build_uv_map(export_dir, params, export_dir)
def test_a_map_without_a_valid_mask_is_fatal(
export_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# mask_mode="valid" always writes one; drop it by hand to model an old/incomplete cache.
calls: dict = {}
_stub_sdk(monkeypatch, calls)
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
import orisys.unroll.precompute as precompute
def build_without_mask(mesh, intrinsics, pose, map_path, **kwargs):
calls["build_kwargs"] = kwargs
height, width = int(kwargs["out_height"]), int(kwargs["out_width"])
np.savez(map_path, map_x=np.zeros((height, width), np.float32))
return None
monkeypatch.setattr(precompute, "build_or_load_domain", build_without_mask)
with pytest.raises(uv.UvMapError, match="valid_mask"):
uv.build_uv_map(export_dir, params, export_dir)
# ----------------------------------------------------------------------
# Export flow: cmd_export builds the map between Blender and the artifact check
# ----------------------------------------------------------------------
def _cli():
pytest.importorskip("yaml", reason="the CLI's profile loader needs PyYAML")
from orisys_sensor_design import cli
return cli
def _prepare_export(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, profile: dict | None = None):
"""Point cmd_export at a temp output dir with the Blender artifacts already written."""
cli = _cli()
output_dir = tmp_path / "compute"
output_dir.mkdir()
for name in ("mesh.npz", "housing.npz", "camera_intrinsics.npz", "world_to_cam.npy"):
(output_dir / name).write_bytes(b"artifact")
monkeypatch.setattr(cli, "export_output_dir", lambda *a, **k: output_dir)
monkeypatch.setattr(cli, "export_revision_dir", lambda *a, **k: tmp_path / "finger" / "r01")
blender_calls: list = []
monkeypatch.setattr(cli, "_run_blender_script", lambda *a, **k: blender_calls.append(a) or 0)
if profile is not None:
monkeypatch.setattr(cli, "load_profile", lambda paths, name: profile)
return cli, output_dir, blender_calls
def _record_builder(monkeypatch: pytest.MonkeyPatch, recorded: dict, *, fail: bool = False):
from orisys.unroll.geometry import scaled_map_cache_path
def fake_build(export_dir, params, output_dir, *, force=False):
if fail:
raise uv.UvMapError("the island did not land in the output domain")
recorded.update(
export_dir=Path(export_dir),
params=params,
output_dir=Path(output_dir),
force=force,
)
# Name it the way the real builder does, so the artifact check sees what the
# profile promises regardless of the scale the profile pins.
target = Path(
scaled_map_cache_path(
str(Path(output_dir) / "unroll_map_uv.npz"), float(params["source_scale"])
)
)
target.write_bytes(b"map")
return target
monkeypatch.setattr("orisys_sensor_design.uv_map.build_uv_map", fake_build)
def test_export_builds_the_map_in_the_same_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
cli, output_dir, blender_calls = _prepare_export(tmp_path, monkeypatch)
recorded: dict = {}
_record_builder(monkeypatch, recorded)
from orisys_sensor_design.manifest import load_yaml
expected = uv.load_uv_map_params(load_yaml(COMPUTE_PROFILE))
code = cli.cmd_export(REVISION, "compute")
assert code == 0
assert blender_calls, "the Blender export must still run first"
assert recorded["export_dir"] == output_dir
assert recorded["output_dir"] == output_dir
# Exactly the profile's uv_map: parameters - including the pinned source_scale.
assert recorded["params"] == expected
out = capsys.readouterr().out
assert "OK: all profile artifacts present" in out
def test_export_returns_a_distinct_code_when_the_map_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch)
_record_builder(monkeypatch, {}, fail=True)
code = cli.cmd_export(REVISION, "compute")
captured = capsys.readouterr()
assert code == cli.UV_MAP_EXIT_CODE == 4
assert "unwarp/valid-mask" in captured.err
# The bundle must not be reported as complete: the map is what stops it rendering grey.
assert "OK: all profile artifacts present" not in captured.out
def test_export_reports_a_profile_that_requires_a_map_it_cannot_build(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
profile = {"profile": "compute", "artifacts": ["mesh.npz", "unroll_map_uv_s0.4.npz"]}
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch, profile=profile)
recorded: dict = {}
_record_builder(monkeypatch, recorded)
code = cli.cmd_export(REVISION, "compute")
captured = capsys.readouterr()
assert code == cli.UV_MAP_EXIT_CODE
assert "no uv_map: block" in captured.err
assert recorded == {}, "the builder must not be called without parameters"
def test_export_skips_the_map_step_for_a_profile_that_declares_none(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
# Profiles whose runtime bundle is still SDK-owned keep their old behaviour.
profile = {"profile": "compute", "artifacts": ["mesh.npz"]}
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch, profile=profile)
recorded: dict = {}
_record_builder(monkeypatch, recorded)
code = cli.cmd_export(REVISION, "compute")
captured = capsys.readouterr()
assert code == 0
assert recorded == {}
assert "skipping the unwarp/valid-mask build" in captured.out
assert "OK: all profile artifacts present" in captured.out
+65 -200
View File
@@ -1,24 +1,26 @@
#!/usr/bin/env python3
"""Rebuild the UV/unroll map for a design export, using the SDK's own builder.
Why this exists: ``unroll_map_uv_s0.4.npz`` — ``map_x``, ``map_y``, ``valid_mask``, ``world_xyz`` —
is what decides which part of the sensing mesh the application treats as **sensing**; everything else
renders grey. Three facts make it worth rebuilding rather than trusting:
``orisys-design export`` now builds this map itself (see
``src/orisys_sensor_design/uv_map.py``); this tool is the standalone diagnostic for the
same core, and exists so a map can be rebuilt or cross-checked without re-running Blender:
* the Blender export writes **no mask** (``blender/scripts/export.py`` writes geometry, optics,
intrinsics and pose only);
* the **SDK builds it** — ``orisys.unroll.precompute.build_uv_domain`` rasterises the mesh's authored
UV faces through the camera with a depth buffer, and ``build_or_load_domain`` builds-then-caches and
already knows how to spot a stale cache;
* the map **records its own parameters** (``out_width``/``out_height``/``flip_v``/``source_scale``/
``edge_band``/``edge_expand``/``projection_K``), so a rebuild can be tied to the same inputs.
* the Blender export writes **no mask** (``blender/scripts/export.py`` writes geometry,
optics, intrinsics and pose only);
* the **SDK builds it** — ``orisys.unroll.precompute.build_or_load_domain`` rasterises the
mesh's authored UV faces through the camera with a depth buffer, and already knows how to
spot a stale cache;
* the map **records its own parameters** (``out_width``/``out_height``/``flip_v``/
``source_scale``/``edge_band``/``edge_expand``/``projection_K``), so a rebuild can be tied
to the same inputs.
So this is a thin wrapper: resolve the paths, take the parameters from an existing map or the
command line (never guess them), call the SDK, and report what came out.
So this is a thin wrapper: resolve the paths, take the parameters from a profile and/or an
existing map and/or the command line (never guess them), call the SDK, and report what came
out.
python tools/build_uv_map.py \
--export-dir build/export/finger/r01/compute \
--params-from D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \
--profile sensors/finger/r01/profiles/compute.yaml \
--output build/export/finger/r01/compute
Then compare it with what ships:
@@ -31,96 +33,42 @@ Then compare it with what ships:
from __future__ import annotations
import argparse
import hashlib
import sys
from pathlib import Path
import numpy as np
try: # an installed design package wins
from orisys_sensor_design.uv_map import (
PARAM_KEYS,
UvMapError,
build_uv_map,
load_uv_map_params,
map_compare_hint,
params_from_map,
)
except ModuleNotFoundError: # running from a checkout without `pip install -e .`
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from orisys_sensor_design.uv_map import (
PARAM_KEYS,
UvMapError,
build_uv_map,
load_uv_map_params,
map_compare_hint,
params_from_map,
)
REQUIRED_EXPORT_FILES = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
#: Parameters the map must state for a faithful rebuild. ``source_width``/``source_height`` are
#: deliberately *not* here: they are derived from the export's own calibration (`K`'s image size
#: times ``source_scale``), so a map built from a 1920x1080 camera is not rebuilt as if it were
#: 1280x720. Getting that wrong shrinks the island (measured: 3.5 % coverage instead of 40.8 %),
#: which is the whole reason this tool exists.
PARAM_KEYS = ("out_width", "out_height", "flip_v", "source_scale", "edge_band", "edge_expand")
#: Keys the map cannot be built without. ``source_scale`` may be absent: it is then derived
#: from the output grid and the export's own camera (the documented floor), and the run says
#: so. Everything else has to be stated.
REQUIRED_KEYS = tuple(key for key in PARAM_KEYS if key != "source_scale")
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def params_from_profile_file(path: Path) -> dict:
"""Read the ``uv_map:`` block out of a profile yaml, without PyYAML.
def params_from_map(path: Path) -> dict:
"""Read the parameters a built map recorded about itself."""
with np.load(path, allow_pickle=False) as data:
found: dict = {}
for key in PARAM_KEYS:
if key in data.files:
value = np.asarray(data[key]).reshape(-1)[0].item()
found[key] = value
missing = [key for key in PARAM_KEYS if key not in found]
if missing:
raise SystemExit(
f"{path.name} does not record {missing}; it predates the self-describing metadata, so "
"pass the parameters explicitly instead"
)
found["edge_expand"] = float(found["edge_expand"])
found["edge_band"] = float(found["edge_band"])
found["source_scale"] = float(found["source_scale"])
found["flip_v"] = bool(found["flip_v"])
for key in ("out_width", "out_height"):
found[key] = int(found[key])
return found
def _warn_if_upsampled(source_width: int, source_height: int,
out_width: int, out_height: int) -> None:
"""Say it out loud when the output grid is finer than the source it samples."""
if source_width < out_width or source_height < out_height:
print(
f"WARNING: the source frame {source_width} x {source_height} is smaller than the "
f"{out_width} x {out_height} output grid, so the unrolled picture is upsampled "
"(several output pixels read one source pixel). Raise source_scale to at least "
f"{derive_source_scale(source_width, source_height, out_width, out_height) * 1.0:.3f} "
"of the *native* frame, or accept the softer picture."
)
def derive_source_scale(native_width: int, native_height: int,
out_width: int, out_height: int) -> float:
"""The smallest scale whose source grid still covers the output grid 1:1.
The remap *gathers* from the source frame into the ``out_width x out_height`` domain, so if
the source is smaller than the output in either axis, several output pixels read the same
source pixel and the unrolled picture is blocky. Requiring
min(native_width * s, native_height * s) >= max(out_width, out_height) (per axis)
gives ``s = max(out_w / native_w, out_h / native_h)``: coverage without upsampling, and — the
useful property — a source size that follows the *output* requirement, so two cameras of
different resolution produce the same output quality.
Above this the scale only buys interpolation smoothness and costs per-frame pixel work
(the live frame is resized by ``s`` before undistort/remap), so the derived value is the
sensible floor rather than the optimum: a profile may pin a larger one deliberately.
"""
if native_width <= 0 or native_height <= 0:
raise SystemExit("the export's camera_intrinsics.npz has a non-positive image size")
scale = max(out_width / float(native_width), out_height / float(native_height))
# Keep the cache name readable (it embeds the scale) and never exceed the native frame.
return min(1.0, round(scale + 1e-9, 3))
def params_from_profile(path: Path) -> dict:
"""Read the ``uv_map:`` block from a profile yaml.
Hand-parsed on purpose: the block is six scalars, and this must keep working with the
interpreter that runs the design tools rather than depending on PyYAML being installed.
Unknown keys and a missing block are reported rather than ignored.
Hand-parsed on purpose: the block is six scalars, and this tool must keep working with
the interpreter that runs the SDK/map build rather than depending on PyYAML being
installed there. Validation (missing block, missing key, unknown key, types) lives in
``load_uv_map_params``, so there is one definition of a valid block.
"""
if not path.is_file():
raise SystemExit(f"no such profile: {path}")
@@ -144,17 +92,6 @@ def params_from_profile(path: Path) -> dict:
found[key] = float(value) if "." in value else int(value)
except ValueError:
found[key] = value
if not found:
raise SystemExit(f"{path} has no uv_map: block (add out_width/out_height/flip_v/"
"source_scale/edge_band/edge_expand)")
unknown = sorted(set(found) - set(PARAM_KEYS))
if unknown:
raise SystemExit(f"{path}: uv_map has keys this tool does not understand: {unknown}")
for key in ("out_width", "out_height"):
found[key] = int(found[key])
found["flip_v"] = bool(found["flip_v"])
for key in ("source_scale", "edge_band", "edge_expand"):
found[key] = float(found[key])
return found
@@ -181,22 +118,20 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
export_dir: Path = args.export_dir
missing = [name for name in REQUIRED_EXPORT_FILES if not (export_dir / name).is_file()]
if missing:
raise SystemExit(
f"{export_dir} is missing {missing}: this needs the payload "
"blender/scripts/export.py writes, not the source .blend"
)
params: dict = {}
if args.profile is not None:
params.update(params_from_profile(args.profile))
block = params_from_profile_file(args.profile)
try:
params.update(load_uv_map_params({"uv_map": block}, source=str(args.profile)))
except UvMapError as exc:
raise SystemExit(f"ERROR: {exc}") from exc
if args.params_from is not None:
if not args.params_from.is_file():
raise SystemExit(f"no such map to take parameters from: {args.params_from}")
recorded = params_from_map(args.params_from)
# A disagreement is the interesting case: the profile is the source of truth, but a map
# that was built with different numbers has to be visible, not resolved silently.
# A disagreement is the interesting case: the profile is the source of truth, but a
# map that was built with different numbers has to be visible, not resolved silently.
disagree = {
key: (recorded.get(key), params.get(key))
for key in params
@@ -210,98 +145,28 @@ def main(argv: list[str] | None = None) -> int:
"out_width": args.out_width, "out_height": args.out_height,
"flip_v": args.flip_v, "source_scale": args.source_scale, "edge_band": args.edge_band,
"edge_expand": args.edge_expand,
"source_width": args.source_width, "source_height": args.source_height,
}
params.update({key: value for key, value in overrides.items() if value is not None})
absent = [key for key in PARAM_KEYS if key not in params]
absent = [key for key in REQUIRED_KEYS if key not in params]
if absent:
raise SystemExit(
f"no value for {absent}: pass --params-from an existing map or give them on the command "
"line. The importer never guesses what a source unit or an output size is."
)
try:
import orisys
from orisys.unroll.geometry import scale_camera_geometry, scaled_map_cache_path
from orisys.unroll.precompute import build_or_load_domain
except Exception as exc: # noqa: BLE001
raise SystemExit(
f"the SDK is not importable ({type(exc).__name__}: {exc}); install the orisys checkout "
"you intend to build with — the map must come from the same implementation that "
"built the shipped one"
f"no value for {absent}: pass --profile, --params-from an existing map, or give "
"them on the command line. The importer never guesses what a source unit or an "
"output size is."
)
if "source_scale" not in params:
print("note: no source_scale given; deriving it from the output grid and the "
"export's own camera")
out_dir: Path = args.output or export_dir
out_dir.mkdir(parents=True, exist_ok=True)
with np.load(export_dir / "camera_intrinsics.npz", allow_pickle=False) as handle:
K_native = np.asarray(handle["K"], dtype=np.float64)
native_width = int(np.asarray(handle["width"]).reshape(-1)[0])
native_height = int(np.asarray(handle["height"]).reshape(-1)[0])
# The scale is a decision, so it is reported as one: the profile (or the CLI) may pin it, and
# this says what the output grid would otherwise require.
derived_scale = derive_source_scale(
native_width, native_height, int(params["out_width"]), int(params["out_height"])
)
pinned_scale = "source_scale" in params
if not pinned_scale:
params["source_scale"] = derived_scale
effective_scale = float(params["source_scale"])
source_K, source_width, source_height = scale_camera_geometry(
K_native, native_width, native_height, effective_scale
)
if args.source_width is not None:
source_width = int(args.source_width)
if args.source_height is not None:
source_height = int(args.source_height)
_warn_if_upsampled(source_width, source_height,
int(params["out_width"]), int(params["out_height"]))
base = str(out_dir / "unroll_map_uv.npz")
target = Path(scaled_map_cache_path(base, float(params["source_scale"])))
if args.force and target.is_file():
target.unlink()
print(f"removed {target.name} (--force: rebuild rather than load)")
print(f"export {export_dir}")
print(f"target {target}")
print(f"SDK orisys {getattr(orisys, '__version__', '?')}")
print(f"parameters {params}")
print(f"native camera {native_width} x {native_height}, f={K_native[0, 0]:.1f}")
print(f"source frame {source_width} x {source_height} "
f"(scale {effective_scale}), f={source_K[0, 0]:.1f}")
print(f"scale {'pinned' if pinned_scale else 'derived'} {effective_scale}"
f" (output {params['out_width']} x {params['out_height']} needs >= {derived_scale})")
domain = build_or_load_domain(
str(export_dir / "mesh.npz"),
str(export_dir / "camera_intrinsics.npz"),
str(export_dir / "world_to_cam.npy"),
str(target),
source_K=source_K,
source_width=int(source_width),
source_height=int(source_height),
out_width=int(params["out_width"]),
out_height=int(params["out_height"]),
flip_v=bool(params["flip_v"]),
edge_band=float(params["edge_band"]),
edge_expand=float(params["edge_expand"]),
include_surface=True,
verbose=True,
source_scale=float(params["source_scale"]),
)
with np.load(target, allow_pickle=False) as written:
mask = np.asarray(written["valid_mask"]).astype(bool)
surface = "world_xyz" in written.files
print(f"\nwrote {target}")
print(f" shape {mask.shape[1]} x {mask.shape[0]} (W x H)")
print(f" coverage {mask.mean() * 100:.3f} % of the domain ({int(mask.sum())} pixels)")
print(f" surface map {'present' if surface else 'MISSING (the 3D UV->surface mapping needs it)'}")
print(f" sha256 {_sha256(target)}")
try:
target = build_uv_map(export_dir, params, out_dir, force=args.force)
except UvMapError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
print("\ncompare it with the shipped map:")
print(" python D:/orisys_ws/orisys-app/tools/scene_assets/compare_uv_map.py "
f"--map \"{target}\" --against \"<shipped>.npz\"")
print(map_compare_hint(target))
return 0