fixed commetn from songxiao
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Derive the *(unit scale, rigid transform)* pair that compiles the **design export**.
|
||||
|
||||
``compile_scene.py`` splits two jobs that must not be confused (design document §8.1):
|
||||
|
||||
* ``--meters-per-source-unit`` changes *lengths*: one stated source unit, in metres;
|
||||
* ``--transform`` is **rigid** (orthonormal rotation + translation, no scale), and the
|
||||
importer validates that before it compiles anything.
|
||||
|
||||
The finger design export (``orisys-sensor-design/build/export/finger/r01/compute``) is the
|
||||
same surface as the SDK asset (``orisys-sdk/sensors/finger/assets/30fps-0703``) — identical
|
||||
``faces`` and ``loop_to_vertex``, so vertex *i* is vertex *i* — but it lives in the design
|
||||
repo's camera/world frame and in *design* units. ``align_housing.py`` fits that relationship
|
||||
as a similarity ``p_sensor_mm = s·R·p_design + t``; the matrix it writes
|
||||
(``housing/finger_r01_design_to_sensor.npy``) therefore **already carries the scale**: it is
|
||||
not a legal ``--transform``, and pairing it with ``--meters-per-source-unit 1.8388e-5`` would
|
||||
apply the scale twice.
|
||||
|
||||
This tool does the split, once and reproducibly:
|
||||
|
||||
design frame, design units --(R, t_m)--> sensor frame, metres
|
||||
p_sensor_m = (0.001·s)·(R·p_design) + 0.001·t
|
||||
|
||||
so the compiler can be given ``--meters-per-source-unit 0.001·s`` and a rigid 4x4 whose
|
||||
rotation block is exactly ``R`` and whose translation is ``0.001·t`` (metres). The metre
|
||||
translation matters: ``t`` from the fit is in the sensor frame's *millimetres*, because that is
|
||||
the frame the SDK asset is stated in.
|
||||
|
||||
It also checks, numerically, the wrong pairings — all three of which are live mistakes here:
|
||||
|
||||
* ``--transform`` = the scale-bearing matrix is **refused** by the rigid-transform validator
|
||||
(it is not a legal rigid transform at all, whatever the unit says), so the "scale applied
|
||||
twice" trap cannot reach the compiler through that door;
|
||||
* what the double scale *would* produce is computed directly from the importer's own
|
||||
``_apply_rigid``, so the number is on the record rather than asserted;
|
||||
* ``--meters-per-source-unit 0.001`` (calling a design unit a millimetre) with the correct
|
||||
rigid transform does compile, and is 54.38x too large — a package that looks entirely
|
||||
plausible.
|
||||
|
||||
python tools/scene_assets/fit_design_frame.py \\
|
||||
--design D:/orisys_ws/orisys-sensor-design/build/export/finger/r01/compute \\
|
||||
--sensor D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703 \\
|
||||
--rigid-output tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy \\
|
||||
--report tools/scene_assets/housing/finger_r01_design_frame.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _bootstrap_paths() -> None:
|
||||
here = Path(__file__).resolve()
|
||||
repo = here.parents[2]
|
||||
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
|
||||
if path.is_dir() and str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
if str(here.parent) not in sys.path:
|
||||
# The fit itself stays in one place: ``align_housing.fit_similarity``, which
|
||||
# checks the 1032 correspondences before it writes anything.
|
||||
sys.path.insert(0, str(here.parent))
|
||||
|
||||
|
||||
#: The SDK asset's frame is millimetres; the compiled package is metres.
|
||||
MM_PER_SENSOR_UNIT = 0.001
|
||||
|
||||
|
||||
def derive(design_dir: Path, sensor_dir: Path, *, tolerance: float) -> dict:
|
||||
from align_housing import apply_similarity, fit_similarity, load_faces, load_vertices
|
||||
|
||||
design_mesh = design_dir / "mesh.npz"
|
||||
sensor_mesh = sensor_dir / "mesh.npz"
|
||||
source = load_vertices(design_mesh)
|
||||
target = load_vertices(sensor_mesh)
|
||||
if source.shape != target.shape:
|
||||
raise SystemExit(
|
||||
f"the meshes disagree: {source.shape} vs {target.shape} — the correspondence the "
|
||||
"fit relies on does not exist"
|
||||
)
|
||||
if not np.array_equal(load_faces(design_mesh), load_faces(sensor_mesh)):
|
||||
raise SystemExit(
|
||||
"the faces arrays differ, so vertex i is not vertex i; refusing to fit a transform"
|
||||
)
|
||||
|
||||
rotation, translation_mm, scale_mm = fit_similarity(source, target)
|
||||
residual_mm = apply_similarity(source, rotation, translation_mm, scale_mm) - target
|
||||
rms_mm = float(np.sqrt((residual_mm**2).sum(axis=1).mean()))
|
||||
diagonal_mm = float(np.linalg.norm(target.max(axis=0) - target.min(axis=0)))
|
||||
relative = rms_mm / max(diagonal_mm, 1e-12)
|
||||
if relative > float(tolerance):
|
||||
raise SystemExit(
|
||||
f"the fit is off by {relative:.3g} of the mesh diagonal (budget {tolerance:g}): "
|
||||
"these two meshes are not the same shape"
|
||||
)
|
||||
|
||||
# The split. ``scale_mm`` maps design units -> sensor millimetres, so the unit
|
||||
# statement is that many millimetres, and the translation is carried into metres
|
||||
# with the same factor the rotation gets.
|
||||
#
|
||||
# **Matrix convention.** ``align_housing.fit_similarity`` fits the *row-vector* form
|
||||
# ``p_sensor = s·(p_design @ R) + t``, while ``compile_scene.py`` hands ``--transform``
|
||||
# to ``legacy._apply_rigid``, which is the *column* form ``p_sensor = T @ [p_design; 1]``.
|
||||
# So the block the compiler needs is ``R.T``, not ``R``: pasting ``R`` in would rotate the
|
||||
# surface the wrong way by twice the fitted angle while leaving every extent plausible
|
||||
# (a 90-degree rotation's transpose is its inverse). ``verify()`` is what proves this.
|
||||
meters_per_source_unit = MM_PER_SENSOR_UNIT * scale_mm
|
||||
rigid = np.eye(4, dtype=np.float64)
|
||||
rigid[:3, :3] = rotation.T
|
||||
rigid[:3, 3] = MM_PER_SENSOR_UNIT * translation_mm
|
||||
|
||||
scale_bearing = np.eye(4, dtype=np.float64)
|
||||
scale_bearing[:3, :3] = scale_mm * rotation
|
||||
scale_bearing[:3, 3] = translation_mm
|
||||
|
||||
extent_design = np.ptp(source, axis=0)
|
||||
extent_sensor_mm = np.ptp(target, axis=0)
|
||||
return {
|
||||
"design_dir": str(design_dir),
|
||||
"sensor_dir": str(sensor_dir),
|
||||
"vertices": int(len(source)),
|
||||
"fit": {
|
||||
"scale_design_unit_to_sensor_mm": scale_mm,
|
||||
"rotation_row_convention": rotation.tolist(),
|
||||
"rotation_transposed_column_convention": rotation.T.tolist(),
|
||||
"translation_sensor_mm": translation_mm.tolist(),
|
||||
"rms_sensor_mm": rms_mm,
|
||||
"rms_sensor_m": rms_mm * MM_PER_SENSOR_UNIT,
|
||||
"diagonal_sensor_mm": diagonal_mm,
|
||||
"relative_of_diagonal": relative,
|
||||
"tolerance": float(tolerance),
|
||||
},
|
||||
"unit_statement": {
|
||||
"meters_per_source_unit": meters_per_source_unit,
|
||||
"one_design_unit_is_mm": scale_mm,
|
||||
"one_sensor_mm_is_design_units": 1.0 / scale_mm,
|
||||
},
|
||||
"rigid_transform_sensor_from_design_m": rigid.tolist(),
|
||||
"scale_bearing_matrix_sensor_mm_from_design": scale_bearing.tolist(),
|
||||
"extent_design_units": extent_design.tolist(),
|
||||
"extent_sensor_mm": extent_sensor_mm.tolist(),
|
||||
"extent_compiled_m": (extent_sensor_mm * MM_PER_SENSOR_UNIT).tolist(),
|
||||
"rotation_orthonormality_error": float(
|
||||
np.abs(rotation @ rotation.T - np.eye(3)).max()
|
||||
),
|
||||
"rotation_determinant": float(np.linalg.det(rotation)),
|
||||
}
|
||||
|
||||
|
||||
def verify(design_dir: Path, sensor_dir: Path, rigid: np.ndarray, units: float) -> dict:
|
||||
"""Compile in memory through the real importer and compare with the SDK-frame route.
|
||||
|
||||
The reference is the *shipped* route: the SDK asset with ``0.001`` and no transform.
|
||||
If the design route is right, both land on the same sensor-metre positions — order
|
||||
included, because identical ``faces``/``loop_to_vertex`` and no UV seam means the
|
||||
render vertices come out in the same order.
|
||||
"""
|
||||
from orisys_app_shared.scene3d.contracts import ThreeDError
|
||||
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter, validate_rigid_transform
|
||||
|
||||
design_map = design_dir / "unroll_map_uv_s0.42.npz"
|
||||
sensor_map = sensor_dir / "unroll_map_uv_s0.4.npz"
|
||||
importer = LegacyFingerImporter()
|
||||
|
||||
reference = importer.convert(
|
||||
sensor_dir, sensor_map,
|
||||
transform_sensor_from_source=np.eye(4),
|
||||
meters_per_source_unit=MM_PER_SENSOR_UNIT,
|
||||
).sensing_part().positions_sensor_m
|
||||
|
||||
result: dict = {}
|
||||
compiled = importer.convert(
|
||||
design_dir, design_map,
|
||||
transform_sensor_from_source=rigid,
|
||||
meters_per_source_unit=units,
|
||||
).sensing_part().positions_sensor_m
|
||||
difference = np.asarray(compiled, dtype=np.float64) - np.asarray(reference, dtype=np.float64)
|
||||
result["design_route"] = {
|
||||
"extent_m": np.ptp(compiled, axis=0).tolist(),
|
||||
"low_m": compiled.min(axis=0).tolist(),
|
||||
"high_m": compiled.max(axis=0).tolist(),
|
||||
"max_abs_difference_from_sdk_route_m": float(np.abs(difference).max()),
|
||||
"rms_difference_from_sdk_route_m": float(np.sqrt((difference**2).sum(axis=1).mean())),
|
||||
"bitwise_identical_to_sdk_route": bool(
|
||||
np.array_equal(np.asarray(compiled, dtype=np.float32),
|
||||
np.asarray(reference, dtype=np.float32))
|
||||
),
|
||||
}
|
||||
result["sdk_route_extent_m"] = np.ptp(reference, axis=0).tolist()
|
||||
|
||||
from orisys_app_shared.scene3d.legacy import _apply_rigid, split_at_uv_seams
|
||||
|
||||
stored = np.load(
|
||||
Path(__file__).resolve().parent / "housing" / "finger_r01_design_to_sensor.npy"
|
||||
)
|
||||
|
||||
# Trap 1: the scale-bearing matrix as ``--transform``. Refused, whatever the unit is.
|
||||
try:
|
||||
validate_rigid_transform(stored)
|
||||
result["trap_stored_matrix_as_transform"] = "ACCEPTED (the validator missed it)"
|
||||
except ThreeDError as exc:
|
||||
result["trap_stored_matrix_as_transform"] = (
|
||||
f"refused: {exc.diagnostic.code}: {exc.diagnostic.message}"
|
||||
)
|
||||
|
||||
# Trap 2: what the double scale *would* do, computed through the importer's own
|
||||
# rigid application — the stored matrix already holds 1/54.38, and the derived unit
|
||||
# holds it too, so the rotation block scales twice and ``t`` (sensor-mm) is added to
|
||||
# a metre-scale result.
|
||||
with np.load(design_dir / "mesh.npz", allow_pickle=False) as handle:
|
||||
positions, _, _, _ = split_at_uv_seams(
|
||||
handle["vertices"], handle["faces"], handle["uv_data"], handle["loop_to_vertex"]
|
||||
)
|
||||
doubled = _apply_rigid(positions * units, stored)
|
||||
result["trap_double_scale_if_forced"] = {
|
||||
"extent_m": np.ptp(doubled, axis=0).tolist(),
|
||||
"low_m": doubled.min(axis=0).tolist(),
|
||||
"high_m": doubled.max(axis=0).tolist(),
|
||||
"note": (
|
||||
"the stated unit already carries the 1/54.38 fit scale and the matrix carries it "
|
||||
"again; the translation is sensor-mm added to a metre-scale result, so the extent "
|
||||
"collapses to the fit residual and the surface sits ~4.5 m from the origin"
|
||||
),
|
||||
}
|
||||
|
||||
# Trap 3: a *legal* rigid transform with the wrong unit statement (a design unit is not
|
||||
# a millimetre). This one compiles; nothing downstream can tell it is 54.38x too big.
|
||||
milli = importer.convert(
|
||||
design_dir, design_map,
|
||||
transform_sensor_from_source=rigid,
|
||||
meters_per_source_unit=MM_PER_SENSOR_UNIT,
|
||||
).sensing_part().positions_sensor_m
|
||||
result["trap_design_unit_called_a_millimetre"] = {
|
||||
"extent_m": np.ptp(milli, axis=0).tolist(),
|
||||
"low_m": milli.min(axis=0).tolist(),
|
||||
"high_m": milli.max(axis=0).tolist(),
|
||||
"factor_too_large": float(
|
||||
np.ptp(milli, axis=0).max() / max(np.ptp(compiled, axis=0).max(), 1e-30)
|
||||
),
|
||||
"note": "compiles cleanly and is wrong by the fit scale; the validator cannot see it",
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--design", type=Path, required=True, help="design export dir")
|
||||
parser.add_argument("--sensor", type=Path, required=True, help="sensor-frame export dir")
|
||||
parser.add_argument("--rigid-output", type=Path, required=True, help="rigid 4x4 to write")
|
||||
parser.add_argument("--report", type=Path, default=None, help="JSON record of the derivation")
|
||||
parser.add_argument("--tolerance", type=float, default=1e-4)
|
||||
parser.add_argument(
|
||||
"--no-verify",
|
||||
action="store_true",
|
||||
help="skip the in-memory compile checks (they need the app's shared package)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
_bootstrap_paths()
|
||||
record = derive(args.design, args.sensor, tolerance=args.tolerance)
|
||||
rigid = np.asarray(record["rigid_transform_sensor_from_design_m"], dtype=np.float64)
|
||||
units = float(record["unit_statement"]["meters_per_source_unit"])
|
||||
|
||||
fit = record["fit"]
|
||||
print(f"fit over {record['vertices']} design->sensor correspondences "
|
||||
f"(identical faces, so vertex i is vertex i)")
|
||||
print(f" scale {fit['scale_design_unit_to_sensor_mm']:.12g} "
|
||||
f"sensor-mm per design unit (= 1/{(1.0 / fit['scale_design_unit_to_sensor_mm']):.8f})")
|
||||
print(f" residual rms {fit['rms_sensor_mm']:.3g} mm "
|
||||
f"({fit['rms_sensor_m']:.3g} m) = {fit['relative_of_diagonal']:.3g} of the "
|
||||
f"{fit['diagonal_sensor_mm']:.3f}-mm diagonal (budget {fit['tolerance']:g})")
|
||||
print(f" rotation orthonormality error {record['rotation_orthonormality_error']:.3g}, "
|
||||
f"det {record['rotation_determinant']:.12f}")
|
||||
print(f" translation {[round(v, 12) for v in fit['translation_sensor_mm']]} sensor-mm")
|
||||
print()
|
||||
print("the split the compiler needs:")
|
||||
print(f" --meters-per-source-unit {units:.12g} (one design unit is "
|
||||
f"{(1.0 / fit['scale_design_unit_to_sensor_mm']):.8f} sensor-mm)")
|
||||
print(f" --transform <rigid 4x4> rotation = R, translation = 0.001*t = "
|
||||
f"{[round(float(v), 12) for v in rigid[:3, 3]]} m")
|
||||
print(f" design extent {np.round(record['extent_design_units'], 4)} -> sensor "
|
||||
f"{np.round(record['extent_sensor_mm'], 4)} mm = "
|
||||
f"{np.round(record['extent_compiled_m'], 6)} m")
|
||||
|
||||
if not args.no_verify:
|
||||
print()
|
||||
print("in-memory compile checks through the real importer:")
|
||||
checks = verify(args.design, args.sensor, rigid, units)
|
||||
design = checks["design_route"]
|
||||
print(f" design route extent {np.round(design['extent_m'], 8)} m "
|
||||
f"low {np.round(design['low_m'], 8)} high {np.round(design['high_m'], 8)}")
|
||||
print(f" sdk route extent {np.round(checks['sdk_route_extent_m'], 8)} m")
|
||||
print(f" agreement max|diff| {design['max_abs_difference_from_sdk_route_m']:.3g} m, "
|
||||
f"rms {design['rms_difference_from_sdk_route_m']:.3g} m, "
|
||||
f"bitwise identical {design['bitwise_identical_to_sdk_route']}")
|
||||
print(f" trap (stored scale-bearing matrix as --transform): "
|
||||
f"{checks['trap_stored_matrix_as_transform']}")
|
||||
forced = checks["trap_double_scale_if_forced"]
|
||||
print(f" trap (derived unit + scale-bearing matrix, forced): extent "
|
||||
f"{np.round(forced['extent_m'], 9)} m, low {np.round(forced['low_m'], 6)}, "
|
||||
f"high {np.round(forced['high_m'], 6)}")
|
||||
milli = checks["trap_design_unit_called_a_millimetre"]
|
||||
print(f" trap (design unit called a millimetre): extent "
|
||||
f"{np.round(milli['extent_m'], 6)} m — "
|
||||
f"{milli['factor_too_large']:.5f}x too large, and accepted")
|
||||
record["verification"] = checks
|
||||
|
||||
args.rigid_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.save(args.rigid_output, rigid)
|
||||
print()
|
||||
print(f"wrote {args.rigid_output}")
|
||||
if args.report is not None:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(
|
||||
json.dumps(record, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(f"wrote {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user