added uv unwarp export

This commit is contained in:
ykcheungab
2026-09-25 17:10:05 +08:00
parent c377c14474
commit f91ac9d3db
8 changed files with 1065 additions and 220 deletions
+83 -3
View File
@@ -193,6 +193,75 @@ def cmd_validate(revision_dir: Path, blender: bool) -> int:
return _run_blender_script("validate.py", paths.source_blend, [str(revision_dir.resolve())])
#: Exit code when the unwarp/valid-mask map cannot be built. Deliberately distinct from 3
#: ("Blender finished but a profile artifact is missing"): an incomplete export payload and
#: a failed mask step are different repairs.
UV_MAP_EXIT_CODE = 4
def _run_uv_map_stage(profile_data: dict, output_dir: Path) -> int:
"""Build the unwarp/valid-mask ("UV domain") map for the export that just finished.
This runs after Blender and before the artifact check, so a bundle whose mask is
missing or stale can never be reported as complete: everything outside the mask's
island renders grey, which is invisible in the exported payload itself.
The map needs the SDK and numpy but never Blender, which is why it lives here rather
than in ``blender/scripts/export.py``. A profile with no ``uv_map:`` block is an error
once it also requires the map as an artifact; a profile that does not (its runtime
bundle is still owned by the SDK) keeps its old behaviour and gets a note instead.
"""
artifacts = [str(name) for name in (profile_data.get("artifacts") or [])]
if "uv_map" not in profile_data:
if any("unroll_map" in name for name in artifacts):
print(
"ERROR: the profile lists an unroll map artifact but has no uv_map: block, "
"so the unwarp/valid-mask domain cannot be built. Add out_width/out_height/"
"flip_v/source_scale/edge_band/edge_expand; these are never guessed.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
print("NOTE: profile declares no uv_map: block; skipping the unwarp/valid-mask build")
return 0
try:
from orisys_sensor_design.uv_map import (
UvMapError,
build_uv_map,
load_uv_map_params,
)
except ImportError as exc:
print(
f"ERROR: the unwarp/valid-mask step needs numpy and the orisys SDK "
f"({type(exc).__name__}: {exc}).\n"
f"The export payload is in {output_dir} but carries no valid_mask, so it is not "
"a complete runtime bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
try:
params = load_uv_map_params(profile_data, source=f"profile {profile_data.get('profile')!r}")
build_uv_map(output_dir, params, output_dir)
except UvMapError as exc:
print(
f"ERROR: could not build the unwarp/valid-mask map: {exc}\n"
f"The exported geometry is in {output_dir}, but without the map the application "
"renders the whole sensing surface grey - do not promote this bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
except Exception as exc: # noqa: BLE001 - report, never leave a half-built bundle
print(
f"ERROR: the unwarp/valid-mask step failed ({type(exc).__name__}: {exc}).\n"
f"The exported geometry is in {output_dir} but has no usable map; do not "
"promote this bundle.",
file=sys.stderr,
)
return UV_MAP_EXIT_CODE
return 0
def cmd_export(
revision_dir: Path, profile: str, housing_collections: str | None = None
) -> int:
@@ -204,7 +273,9 @@ def cmd_export(
print(f"ERROR: {message}", file=sys.stderr)
return 1
load_profile(paths, profile)
# Loaded once: the export job, the map parameters and the artifact list all come from
# the same profile, and re-reading it could see a different file than the one checked.
profile_data = load_profile(paths, profile)
output_dir = export_output_dir(root, paths, profile)
output_dir.mkdir(parents=True, exist_ok=True)
collections = (
@@ -248,8 +319,14 @@ def cmd_export(
return code
print(f"Export target: {output_dir}")
# The mask decides what the application treats as sensing; it is built here (no
# Blender) and before the artifact check so an incomplete bundle cannot look complete.
code = _run_uv_map_stage(profile_data, output_dir)
if code != 0:
return code
missing = []
for name in load_profile(paths, profile).get("artifacts", []):
for name in profile_data.get("artifacts", []):
if not (output_dir / name).is_file():
missing.append(name)
if missing:
@@ -1673,7 +1750,10 @@ def main(argv: list[str] | None = None) -> None:
help="Run blender/scripts/validate.py headless",
)
export_p = sub.add_parser("export", help="Export nominal geometry for a profile")
export_p = sub.add_parser(
"export",
help="Export nominal geometry plus the unwarp/valid-mask map for a profile",
)
export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"])
export_p.add_argument(
+426
View File
@@ -0,0 +1,426 @@
"""Build the unwarp/valid-mask ("UV domain") map for a design export.
``unroll_map_uv_s0.4.npz`` — ``map_x``, ``map_y``, ``valid_mask``, ``world_xyz`` plus the
parameters that produced them — is what decides which part of the sensing mesh the
application treats as *sensing*; everything outside its island renders grey. The Blender
export writes **no** mask (``blender/scripts/export.py`` writes geometry, optics,
intrinsics and pose only), so ``orisys-design export`` calls :func:`build_uv_map` once
Blender has finished. Nothing here needs Blender: the mask can be rebuilt from an exported
payload alone, which is exactly why it does not live in the Blender script.
The map itself comes from the SDK's own builder (``orisys.unroll.precompute``): it
rasterises the mesh's authored UV faces through the camera with a depth buffer, records
the parameters it used, and ``build_or_load_domain`` already knows how to spot a stale
cache. This module is the thin, importable wrapper around that: resolve the parameters
(never guess them), derive the source frame, call the SDK, and report what came out.
Three details are load-bearing, and each one was paid for with a wrong measurement:
* ``source_K`` must be **scaled to the source resolution** by
``orisys.unroll.geometry.scale_camera_geometry``. Passing the native K produced 3.5 %
island coverage instead of 40.8 %.
* ``source_width``/``source_height`` must be **derived from the export's own
``camera_intrinsics.npz``** (native size x ``source_scale``), never inherited from
another camera's map. Inheriting one produced 0.8 % coverage.
* the file name must come from ``orisys.unroll.geometry.scaled_map_cache_path`` (which
appends ``_s0.4``), so maps built at different source scales cannot collide.
The parameters are never guessed either: they come from the profile's ``uv_map:`` block
(:func:`load_uv_map_params`) or from an existing map / explicit command-line overrides in
the standalone ``tools/build_uv_map.py``.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import numpy as np
#: What the Blender export must have written before a map can be built. This is the
#: export payload, not the source .blend: the mask is built from the exported geometry,
#: calibration and pose.
REQUIRED_EXPORT_FILES = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
#: Parameters the map must state for a faithful rebuild. ``source_width``/``source_height``
#: are deliberately *not* here: they are derived from the export's own calibration (K's
#: image size times ``source_scale``), so a map built from a 1920x1080 camera is not
#: rebuilt as if it were 1280x720.
PARAM_KEYS = ("out_width", "out_height", "flip_v", "source_scale", "edge_band", "edge_expand")
#: Base name handed to ``scaled_map_cache_path``; the SDK appends the scale suffix.
MAP_BASENAME = "unroll_map_uv.npz"
class UvMapError(RuntimeError):
"""The unwarp/valid-mask map cannot be built from what the export and profile give.
Raised for a missing/patchy ``uv_map:`` block, an unreadable export payload, an
unimportable SDK, and for a written map that turns out to have no usable
``valid_mask``. The exporter turns this into a non-zero exit rather than leaving a
bundle that looks complete: a missing mask is invisible until the operator sees grey.
"""
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _as_bool(value: Any) -> bool:
"""Coerce a YAML/JSON value to bool (a quoted "false" must not read as True)."""
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "on")
return bool(value)
def _as_int(key: str, value: Any, source: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise UvMapError(f"{source}: uv_map.{key} must be an integer, got {value!r}") from exc
def _as_float(key: str, value: Any, source: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise UvMapError(f"{source}: uv_map.{key} must be a number, got {value!r}") from exc
def load_uv_map_params(profile: dict[str, Any], *, source: str = "the profile") -> dict[str, Any]:
"""Resolve the ``uv_map:`` block of a loaded profile mapping.
Returns the six parameters with their types fixed (``out_width``/``out_height`` int,
``flip_v`` bool, the rest float). A missing block, a missing key, or an unknown key is
an error: these numbers decide the mask, and a wrong ``out_width`` or ``source_scale``
still produces a plausible-looking map, so guessing is worse than failing.
"""
if not isinstance(profile, dict):
raise UvMapError(f"{source} is not a profile mapping (got {type(profile).__name__})")
block = profile.get("uv_map")
if not isinstance(block, dict) or not block:
raise UvMapError(
f"{source} has no uv_map: block, so the unwarp/valid-mask domain cannot be "
"built (add out_width/out_height/flip_v/source_scale/edge_band/edge_expand). "
"These parameters are never guessed: a wrong output size or source scale "
"still produces a plausible-looking mask."
)
unknown = sorted(set(block) - set(PARAM_KEYS))
if unknown:
raise UvMapError(f"{source}: uv_map has keys this build does not understand: {unknown}")
absent = [key for key in PARAM_KEYS if key not in block]
if absent:
raise UvMapError(f"{source}: uv_map is missing {absent}")
params: dict[str, Any] = {
"out_width": _as_int("out_width", block["out_width"], source),
"out_height": _as_int("out_height", block["out_height"], source),
"flip_v": _as_bool(block["flip_v"]),
}
for key in ("source_scale", "edge_band", "edge_expand"):
params[key] = _as_float(key, block[key], source)
_validate_params(params, source)
return params
def params_from_map(path: Path) -> dict[str, Any]:
"""Read the parameters a built map recorded about itself."""
path = Path(path)
try:
with np.load(path, allow_pickle=False) as data:
found: dict[str, Any] = {}
for key in PARAM_KEYS:
if key in data.files:
found[key] = np.asarray(data[key]).reshape(-1)[0].item()
except OSError as exc:
raise UvMapError(f"could not read {path}: {exc}") from exc
missing = [key for key in PARAM_KEYS if key not in found]
if missing:
raise UvMapError(
f"{path.name} does not record {missing}; it predates the self-describing "
"metadata, so pass the parameters explicitly or from the profile instead"
)
found["edge_expand"] = float(found["edge_expand"])
found["edge_band"] = float(found["edge_band"])
found["source_scale"] = float(found["source_scale"])
found["flip_v"] = _as_bool(found["flip_v"])
for key in ("out_width", "out_height"):
found[key] = int(found[key])
_validate_params(found, str(path))
return found
def _validate_params(params: dict[str, Any], source: str) -> None:
if params["out_width"] < 1 or params["out_height"] < 1:
raise UvMapError(f"{source}: out_width/out_height must be positive")
if params.get("source_scale") is not None and params["source_scale"] <= 0:
raise UvMapError(f"{source}: source_scale must be positive")
def derive_source_scale(
native_width: int, native_height: int, out_width: int, out_height: int
) -> float:
"""The smallest scale whose source grid still covers the output grid 1:1.
The remap *gathers* from the source frame into the ``out_width x out_height`` domain,
so if the source is smaller than the output in either axis, several output pixels read
the same source pixel and the unrolled picture is blocky. Requiring
min(native_width * s, native_height * s) >= max(out_width, out_height) (per axis)
gives ``s = max(out_w / native_w, out_h / native_h)``: coverage without upsampling,
and — the useful property — a source size that follows the *output* requirement, so
two cameras of different resolution produce the same output quality.
Above this the scale only buys interpolation smoothness and costs per-frame pixel work
(the live frame is resized by ``s`` before undistort/remap), so the derived value is
the sensible floor rather than the optimum: a profile may pin a larger one
deliberately, and this is what the run reports it against.
"""
if native_width <= 0 or native_height <= 0:
raise UvMapError(
"the export's camera_intrinsics.npz has a non-positive image size "
f"({native_width} x {native_height})"
)
if out_width <= 0 or out_height <= 0:
raise UvMapError(f"the output grid must be positive (got {out_width} x {out_height})")
scale = max(out_width / float(native_width), out_height / float(native_height))
# Keep the cache name readable (it embeds the scale) and never exceed the native frame.
return min(1.0, round(scale + 1e-9, 3))
def _warn_if_upsampled(
native_width: int,
native_height: int,
source_width: int,
source_height: int,
out_width: int,
out_height: int,
) -> None:
"""Say it out loud when the output grid is finer than the source it samples.
The suggestion is expressed against the **native** frame, because that is what the
profile's ``source_scale`` is relative to; computing it from the source frame would
advise a scale of 1.0 for any downscale, which is wrong.
"""
if source_width >= out_width and source_height >= out_height:
return
needed = derive_source_scale(native_width, native_height, out_width, out_height)
print(
f"WARNING: the source frame {source_width} x {source_height} is smaller than the "
f"{out_width} x {out_height} output grid, so the unrolled picture is upsampled "
f"(several output pixels read one source pixel). source_scale >= {needed} avoids "
"it; below that the picture is softer but the per-frame cost is lower."
)
def _import_sdk():
"""Import the SDK pieces the map build needs, with a message that says what to do."""
try:
import orisys
from orisys.unroll.geometry import scale_camera_geometry, scaled_map_cache_path
from orisys.unroll.precompute import build_or_load_domain
except Exception as exc: # noqa: BLE001 - any import failure is the same story here
raise UvMapError(
f"the orisys SDK is not importable ({type(exc).__name__}: {exc}); install the "
"orisys checkout you intend to build with — the map must come from the same "
"implementation that built the shipped one"
) from exc
return orisys, scale_camera_geometry, scaled_map_cache_path, build_or_load_domain
def _read_native_camera(export_dir: Path) -> tuple[np.ndarray, int, int]:
intrinsics = export_dir / "camera_intrinsics.npz"
try:
with np.load(intrinsics, allow_pickle=False) as handle:
K = np.asarray(handle["K"], dtype=np.float64)
width = int(np.asarray(handle["width"]).reshape(-1)[0])
height = int(np.asarray(handle["height"]).reshape(-1)[0])
except OSError as exc:
raise UvMapError(f"could not read {intrinsics}: {exc}") from exc
except KeyError as exc:
raise UvMapError(f"{intrinsics} has no {exc.args[0]!r} array") from exc
if K.shape != (3, 3):
raise UvMapError(f"{intrinsics}: K must be 3x3, got {K.shape}")
return K, width, height
def build_uv_map(
export_dir: Path,
params: dict[str, Any],
output_dir: Path,
*,
force: bool = False,
) -> Path:
"""Build (or load) the unwarp/valid-mask map for an exported payload.
``export_dir`` must hold the payload ``blender/scripts/export.py`` writes.
``output_dir`` is where the map is written; the name comes from
``scaled_map_cache_path(output_dir / "unroll_map_uv.npz", source_scale)``.
``force`` deletes the target first so the SDK must rebuild rather than load. It is
only for "rebuild regardless": ``build_or_load_domain`` already regenerates a cache
whose recorded parameters no longer match.
Returns the path written. Raises :class:`UvMapError` — never a bare ``SystemExit`` —
so the export flow owns the exit code.
"""
export_dir = Path(export_dir)
output_dir = Path(output_dir)
missing = [name for name in REQUIRED_EXPORT_FILES if not (export_dir / name).is_file()]
if missing:
raise UvMapError(
f"{export_dir} is missing {missing}: this needs the payload "
"blender/scripts/export.py writes, not the source .blend"
)
absent = [
key
for key in ("out_width", "out_height", "flip_v", "edge_band", "edge_expand")
if key not in params
]
if absent:
raise UvMapError(f"no value for {absent}: they come from the profile's uv_map: block")
_validate_params(params, "uv_map parameters")
orisys, scale_camera_geometry, scaled_map_cache_path, build_or_load_domain = _import_sdk()
K_native, native_width, native_height = _read_native_camera(export_dir)
out_width = int(params["out_width"])
out_height = int(params["out_height"])
# The scale is a decision, so it is reported as one: the profile (or the CLI) may pin
# it, and this says what the output grid would otherwise require.
derived_scale = derive_source_scale(native_width, native_height, out_width, out_height)
pinned_scale = params.get("source_scale") is not None
effective_scale = float(params["source_scale"]) if pinned_scale else derived_scale
if effective_scale <= 0:
raise UvMapError(f"source_scale must be positive, got {effective_scale}")
source_K, source_width, source_height = scale_camera_geometry(
K_native, native_width, native_height, effective_scale
)
# Explicit overrides exist only for the standalone diagnostic (tools/build_uv_map.py).
if params.get("source_width") is not None:
source_width = int(params["source_width"])
if params.get("source_height") is not None:
source_height = int(params["source_height"])
source_width, source_height = int(source_width), int(source_height)
_warn_if_upsampled(
native_width, native_height, source_width, source_height, out_width, out_height
)
output_dir.mkdir(parents=True, exist_ok=True)
base = str(output_dir / MAP_BASENAME)
target = Path(scaled_map_cache_path(base, effective_scale))
cached = target.is_file()
if force and cached:
target.unlink()
cached = False
print(f"removed {target.name} (--force: rebuild rather than load)")
used = dict(params)
used["source_scale"] = effective_scale
print(f"export {export_dir}")
print(f"target {target}")
print(f"SDK orisys {getattr(orisys, '__version__', '?')}")
print(f"parameters {used}")
print(f"native camera {native_width} x {native_height}, f={K_native[0, 0]:.1f}")
print(
f"source frame {source_width} x {source_height} "
f"(scale {effective_scale}), f={source_K[0, 0]:.1f}"
)
print(
f"scale {'pinned' if pinned_scale else 'derived'} {effective_scale} "
f"(output {out_width} x {out_height} needs >= {derived_scale})"
)
print(
"cache "
+ (
"present - the SDK loads it unless its recorded parameters no longer match"
if cached
else "absent - the SDK builds it now"
)
)
# The SDK prints "Loaded cached UV domain" or "Generated UV domain valid ratio", which
# is the run's own statement of whether this was a read or a write.
build_or_load_domain(
str(export_dir / "mesh.npz"),
str(export_dir / "camera_intrinsics.npz"),
str(export_dir / "world_to_cam.npy"),
str(target),
source_K=source_K,
source_width=source_width,
source_height=source_height,
out_width=out_width,
out_height=out_height,
flip_v=bool(params["flip_v"]),
edge_band=float(params["edge_band"]),
edge_expand=float(params["edge_expand"]),
include_surface=True,
verbose=True,
source_scale=effective_scale,
)
if not target.is_file():
raise UvMapError(
f"the SDK reported success but wrote no map at {target}; the export is not "
"complete"
)
_report_written_map(target, out_width=out_width, out_height=out_height)
return target
def _report_written_map(target: Path, *, out_width: int, out_height: int) -> None:
"""Verify and report the map that is now on disk.
A missing ``valid_mask`` is fatal: everything outside it renders grey, and nothing
downstream can tell a missing mask from a mask that legitimately says "not sensing".
"""
try:
with np.load(target, allow_pickle=False) as written:
files = set(written.files)
if "valid_mask" not in files:
raise UvMapError(
f"{target} has no valid_mask array (has {sorted(files)}); without it "
"the application has no island to treat as sensing"
)
mask = np.asarray(written["valid_mask"]).astype(bool)
surface = "world_xyz" in files
except OSError as exc:
raise UvMapError(f"could not read back {target}: {exc}") from exc
if mask.shape != (out_height, out_width):
raise UvMapError(
f"{target}: valid_mask is {mask.shape[1]} x {mask.shape[0]} (W x H) but the "
f"profile asks for {out_width} x {out_height}; the map does not match the "
"domain the profile declares"
)
coverage = float(mask.mean()) * 100.0
if int(mask.sum()) == 0:
raise UvMapError(
f"{target}: the valid mask has no set pixel, so no part of the mesh maps into "
"the output domain - the application would render the whole sensor grey"
)
if not surface:
raise UvMapError(
f"{target} has no world_xyz array, so the 3D UV-to-surface mapping the SDK "
"unroll needs is missing; the export is not complete"
)
print(f"\nwrote {target}")
print(f" shape {mask.shape[1]} x {mask.shape[0]} (W x H)")
print(f" coverage {coverage:.3f} % of the domain ({int(mask.sum())} pixels)")
print(" surface map present")
print(f" sha256 {_sha256(target)}")
def map_compare_hint(target: Path) -> str:
"""The one-line cross-check against the shipped map, for both callers to print."""
return (
" python D:/orisys_ws/orisys-app/tools/scene_assets/compare_uv_map.py "
f'--map "{target}" --against "<shipped>.npz"'
)