diff --git a/README.md b/README.md index 4af2cd1..a543dbf 100644 --- a/README.md +++ b/README.md @@ -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.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`. diff --git a/sensors/finger/r01/profiles/compute.yaml b/sensors/finger/r01/profiles/compute.yaml index c367302..b360320 100644 --- a/sensors/finger/r01/profiles/compute.yaml +++ b/sensors/finger/r01/profiles/compute.yaml @@ -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//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 ; 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 --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" diff --git a/sensors/finger/r01/source/geometry.blend b/sensors/finger/r01/source/geometry.blend index 7b997ae..5435ea5 100644 Binary files a/sensors/finger/r01/source/geometry.blend and b/sensors/finger/r01/source/geometry.blend differ diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 63b4a64..3c832da 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -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( diff --git a/src/orisys_sensor_design/uv_map.py b/src/orisys_sensor_design/uv_map.py new file mode 100644 index 0000000..4d1517a --- /dev/null +++ b/src/orisys_sensor_design/uv_map.py @@ -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 ".npz"' + ) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index cf156e1..31d237f 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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") diff --git a/tests/test_uv_map.py b/tests/test_uv_map.py new file mode 100644 index 0000000..cdbca9b --- /dev/null +++ b/tests/test_uv_map.py @@ -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 diff --git a/tools/build_uv_map.py b/tools/build_uv_map.py index d1be694..394d0e4 100644 --- a/tools/build_uv_map.py +++ b/tools/build_uv_map.py @@ -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 \".npz\"") + print(map_compare_hint(target)) return 0