added uv unwarp export
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
@@ -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(
|
||||
|
||||
@@ -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"'
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user