Files
orisys-sensor-design/tests/test_preview.py
T
ykcheungab c377c14474 1. added blender deformation probe for marker size picking and optimization
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
2026-09-25 15:44:44 +08:00

513 lines
18 KiB
Python

"""Tests for the export preview: geometry, camera, appearance and the GL smoke.
The GL test is skipped when no real platform GL context can be created, copying
Studio's ``test_scene3d_gl_smoke.py``: the offscreen/minimal plugin cannot create
a platform GL context at all, so an offscreen run cannot stand in for a GPU.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
from orisys_sensor_design.manifest import (
DEFAULT_HOUSING_COLLECTIONS,
RevisionPaths,
build_export_job,
export_housing_collections,
repo_root,
)
from orisys_sensor_design.preview import appearance
from orisys_sensor_design.preview.camera import (
OrbitCamera,
frustum_segments,
pose_is_rigid,
)
from orisys_sensor_design.preview.geometry import (
DEFAULT_CREASE_ANGLE_DEG,
SMOOTH_CREASE_ANGLE_DEG,
PreviewGeometryError,
compute_normals,
load_housing,
load_parts,
load_sensing,
split_vertices,
)
REPO = repo_root()
STUDIO_BACKEND = (
REPO.parent
/ "orisys-app"
/ "apps"
/ "studio"
/ "src"
/ "orisys_studio"
/ "scene3d"
/ "gl_backend.py"
)
XYNOVA_EXPORT = REPO / "build" / "export" / "finger-xynova" / "r01" / "compute"
def _write_npz(path: Path, **arrays) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path, **arrays)
return path
def _housing_npz(path: Path) -> Path:
vertices = np.array(
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [5, 5, 5], [6, 5, 5], [5, 6, 5]],
dtype=np.float32,
)
faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32)
return _write_npz(
path,
vertices=vertices,
faces=faces,
part_index=np.array([0, 1], dtype=np.int32),
part_names=np.array(["frame_L", "nail"]),
)
# ------------------------------------------------------------- geometry
def test_compute_normals_of_a_flat_quad_points_along_plus_z() -> None:
positions = np.array(
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
)
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
normals = compute_normals(positions, triangles)
assert normals.shape == positions.shape
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
def test_compute_normals_falls_back_on_degenerate_faces() -> None:
positions = np.zeros((3, 3), dtype=np.float32)
triangles = np.array([[0, 1, 2]], dtype=np.int32)
normals = compute_normals(positions, triangles)
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32))
def test_load_sensing_reads_the_payload_mesh(tmp_path: Path) -> None:
_write_npz(
tmp_path / "mesh.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
part = load_sensing(tmp_path)
assert part.role == "sensing"
assert part.name == "sensing_surface"
assert part.triangle_count == 1
def test_load_sensing_rejects_a_missing_file(tmp_path: Path) -> None:
with pytest.raises(PreviewGeometryError, match="missing geometry file"):
load_sensing(tmp_path)
def test_load_housing_splits_parts_by_index(tmp_path: Path) -> None:
_housing_npz(tmp_path / "housing.npz")
parts = load_housing(tmp_path)
assert [part.name for part in parts] == ["frame_L", "nail"]
assert all(part.role == "housing" for part in parts)
assert parts[0].vertex_count == 3
assert parts[1].triangle_count == 1
def test_load_housing_is_empty_when_absent(tmp_path: Path) -> None:
assert load_housing(tmp_path) == []
def test_load_housing_is_empty_when_it_has_no_faces(tmp_path: Path) -> None:
_write_npz(
tmp_path / "housing.npz",
vertices=np.zeros((0, 3), dtype=np.float32),
faces=np.zeros((0, 3), dtype=np.int32),
part_index=np.zeros((0,), dtype=np.int32),
part_names=np.array([""], dtype=np.str_)[:0],
)
assert load_housing(tmp_path) == []
def test_load_housing_rejects_a_part_index_length_mismatch(tmp_path: Path) -> None:
_write_npz(
tmp_path / "housing.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
part_index=np.array([0, 1], dtype=np.int32),
part_names=np.array(["a", "b"]),
)
with pytest.raises(PreviewGeometryError, match="part_index"):
load_housing(tmp_path)
def test_load_parts_can_skip_housing(tmp_path: Path) -> None:
_write_npz(
tmp_path / "mesh.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
_housing_npz(tmp_path / "housing.npz")
assert len(load_parts(tmp_path)) == 3
assert len(load_parts(tmp_path, include_housing=False)) == 1
# --------------------------------------------------------------- camera
def test_fit_frames_the_bounds_and_scales_with_the_frame() -> None:
camera = OrbitCamera()
low = np.array([-1.0, -1.0, -1.0])
high = np.array([1.0, 1.0, 1.0])
camera.fit(low, high, 1.0)
assert np.allclose(camera.target, 0.0)
radius = np.linalg.norm(high - low) * 0.5
assert camera.fitted_radius == pytest.approx(radius)
# The distance must exceed the radius, and stay within its own ratios.
assert radius * 0.05 <= camera.distance <= radius * 50.0
assert camera.distance > radius
def test_fit_is_unit_agnostic_for_a_scaled_export() -> None:
"""The exported sensor frame is not metres, so framing must not assume it."""
camera = OrbitCamera()
camera.fit(np.array([-574.0, -576.0, -299.0]), np.array([574.0, 556.0, 1691.0]), 1.6)
assert camera.distance > 1000.0 # would be clamped to 5 m by Studio's limits
def test_zoom_is_clamped_relative_to_the_fitted_radius() -> None:
camera = OrbitCamera()
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
for _ in range(200):
camera.zoom(1.0)
assert camera.distance >= camera.fitted_radius * 0.05
for _ in range(400):
camera.zoom(-1.0)
assert camera.distance <= camera.fitted_radius * 50.0
def test_orbit_clamps_pitch() -> None:
camera = OrbitCamera()
camera.orbit(0.0, -100000.0)
assert abs(camera.pitch) <= 1.5534 + 1e-3
def test_view_and_projection_are_well_formed() -> None:
camera = OrbitCamera()
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
view = camera.view()
projection = camera.projection()
assert view.shape == (4, 4) and projection.shape == (4, 4)
# An orthonormal view: rotation rows are unit length.
assert np.allclose(np.linalg.norm(view[:3, :3], axis=1), 1.0, atol=1e-9)
# The target maps onto the camera's -Z axis.
eye = camera.eye()
target_in_camera = view @ np.array([*camera.target, 1.0])
assert target_in_camera[2] == pytest.approx(-camera.distance, rel=1e-6)
assert eye.shape == (3,)
def test_pose_is_rigid_detects_a_scaled_pose() -> None:
rigid = np.eye(4)
assert pose_is_rigid(rigid) is True
scaled = np.diag([0.003937, 0.003937, 0.003937, 1.0])
assert pose_is_rigid(scaled) is False
assert pose_is_rigid(np.zeros((3, 3))) is False
def test_frustum_segments_apex_is_the_camera_position() -> None:
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
world_to_cam = np.eye(4)
world_to_cam[2, 3] = 2.0 # camera sits 2 units in front of the origin
segments = frustum_segments(K, world_to_cam, image_size=(100, 100))
assert segments.shape == (8, 2, 3)
apex = segments[0, 0]
assert np.allclose(apex, [0.0, 0.0, -2.0], atol=1e-9)
def test_frustum_segments_rejects_a_bad_shape() -> None:
with pytest.raises(ValueError, match="world_to_cam"):
frustum_segments(np.eye(3), np.eye(3))
with pytest.raises(ValueError, match="K must be"):
frustum_segments(np.eye(2), np.eye(4))
# ------------------------------------------------------------- appearance
def test_shader_sources_are_the_core_profile() -> None:
vertex, fragment = appearance.shader_source("diffuse")
assert vertex.startswith(appearance.SHADER_HEADER)
assert fragment.startswith(appearance.SHADER_HEADER)
assert "u_light_direction" in fragment
line_vertex, line_fragment = appearance.shader_source("line")
assert "u_color" in line_fragment
with pytest.raises(ValueError, match="unknown shader"):
appearance.shader_source("nope")
def test_base_color_matches_studio_for_housing() -> None:
assert appearance.base_color("housing") == appearance.HOUSING_COLOR
assert appearance.base_color("anything") == appearance.PART_FALLBACK_COLOR
@pytest.mark.skipif(
not STUDIO_BACKEND.is_file(), reason="orisys-app checkout is not present"
)
def test_appearance_mirror_matches_studio() -> None:
"""Drift guard: the mirror must not silently diverge from Studio's backend."""
source = STUDIO_BACKEND.read_text(encoding="utf-8")
assert f"glClearColor({appearance.CLEAR_COLOR[0]}, {appearance.CLEAR_COLOR[1]}, " in source
assert "(0.3, -0.5, 0.8)" in source
assert "0.35 + 0.65 * lambert" in source
assert "0.32, 0.33, 0.35, 1.0" in source
assert "0.45, 0.45, 0.47, 1.0" in source
for fragment_line in (
"vec3 n = normalize(v_normal);",
"if (!gl_FrontFacing) {",
"n = -n;",
):
assert fragment_line in source, fragment_line
assert fragment_line in appearance.DIFFUSE_FRAGMENT_BODY, fragment_line
# ------------------------------------------------- export contract guards
def test_export_job_requests_housing_from_the_assembly_collection() -> None:
paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01")
job = build_export_job(paths, Path("out"), "compute")
assert job["schema"] == "orisys-sensor-design/export-job-v2"
assert job["housing_collections"] == export_housing_collections(paths)
assert job["housing_collections"] == ["ASSEMBLY"]
def test_housing_collections_default_when_design_yaml_is_silent(tmp_path: Path) -> None:
revision = tmp_path / "sensors" / "finger" / "r99"
(revision / "profiles").mkdir(parents=True)
(revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8")
paths = RevisionPaths.from_revision_dir(revision)
assert export_housing_collections(paths) == list(DEFAULT_HOUSING_COLLECTIONS)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "housing.npz").is_file(),
reason="run orisys-design export first",
)
def test_exported_housing_keeps_mesh_npz_sensing_only() -> None:
"""The SDK unroll reads mesh.npz vertices as the sensing surface."""
with np.load(XYNOVA_EXPORT / "mesh.npz", allow_pickle=False) as mesh:
assert set(mesh.files) == {"vertices", "faces", "uv_data", "loop_to_vertex"}
assert mesh["vertices"].shape[0] == 2508
# SMOOTH reads the artifact as stored: 21426 is what housing.npz contains, and
# crease splitting only ever enlarges the preview's in-memory copy.
parts = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
assert [part.name for part in parts] == ["frame_L", "nail", "frame_R"]
assert sum(part.vertex_count for part in parts) == 21426
# ------------------------------------------------------------- GL smoke
pytestmark_gl = pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
@pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None:
# Guard the submodule, not the top-level package: a partially installed
# PyQt5 can expose `PyQt5` (with `PyQt5.__file__ is None`) while every
# binding submodule is missing, which would otherwise fail instead of skip.
pytest.importorskip("PyQt5.QtWidgets")
from PyQt5.QtWidgets import QApplication
from orisys_sensor_design.preview.gl_view import (
PreviewGLWidget,
configure_surface_format,
)
from orisys_sensor_design.preview.window import prepare
state = prepare(XYNOVA_EXPORT)
configure_surface_format()
app = QApplication.instance() or QApplication([])
widget = PreviewGLWidget(
state["parts"], state["camera"], frustum=state["frustum"]
)
widget.show_frustum = False
widget.resize(480, 320)
widget.show()
app.processEvents()
background = np.array([15, 18, 23])
def drawn() -> int:
widget.repaint()
app.processEvents()
frame = widget.grab_rgb()
return int(
np.count_nonzero(np.any(frame[:, :, :3] != background, axis=2))
)
widget.show_housing = True
with_housing = drawn()
widget.show_housing = False
without_housing = drawn()
assert widget.gl_ok, widget.gl_error
assert with_housing > 0, "nothing was drawn at all"
assert 0 < without_housing < with_housing, (
"hiding the housing did not reduce what is on screen "
f"({without_housing} vs {with_housing})"
)
# --------------------------------------------------------- crease normals
def _cube() -> tuple[np.ndarray, np.ndarray]:
positions = np.array(
[
[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1],
],
dtype=np.float32,
)
triangles = np.array(
[
[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6],
[0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2],
[2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0],
],
dtype=np.int32,
)
return positions, triangles
def _two_faces(angle_deg: float) -> tuple[np.ndarray, np.ndarray]:
"""Two triangles sharing an edge, tilted from each other by ``angle_deg``."""
radians = np.radians(angle_deg)
positions = np.array(
[
[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0],
[1.0 + np.cos(radians), 0.0, np.sin(radians)],
],
dtype=np.float32,
)
triangles = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32)
return positions, triangles
def test_split_vertices_makes_a_cube_corner_three_clusters() -> None:
positions, triangles = _cube()
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
assert len(split_positions) == 24 # 8 corners x 3 axis-aligned faces
axes = {tuple(np.round(row, 6)) for row in normals}
assert axes == {
(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0),
(0.0, 1.0, 0.0), (0.0, -1.0, 0.0),
(0.0, 0.0, 1.0), (0.0, 0.0, -1.0),
}
def test_split_vertices_smooth_sentinel_keeps_every_vertex() -> None:
positions, triangles = _cube()
split_positions, normals, split_triangles = split_vertices(
positions, triangles, SMOOTH_CREASE_ANGLE_DEG
)
assert len(split_positions) == 8
assert np.allclose(split_triangles, triangles)
assert np.allclose(normals, compute_normals(positions, triangles))
def test_split_vertices_respects_the_threshold() -> None:
shallow_positions, shallow_triangles = _two_faces(20.0)
_, _, shallow = split_vertices(shallow_positions, shallow_triangles, 35.0)
# The shared edge vertices keep one normal each, so the quad stays 4 vertices.
assert len(shallow) == 2
assert len(set(map(tuple, shallow.reshape(-1, 3)))) <= 4
steep_positions, steep_triangles = _two_faces(40.0)
steep_positions_out, _, _ = split_vertices(
steep_positions, steep_triangles, 35.0
)
assert len(steep_positions_out) > len(steep_positions)
def test_split_vertices_leaves_a_flat_quad_flat() -> None:
positions = np.array(
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
)
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
assert len(split_positions) == 4
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
def test_split_vertices_handles_an_empty_mesh() -> None:
positions = np.zeros((0, 3), dtype=np.float32)
triangles = np.zeros((0, 3), dtype=np.int32)
split_positions, normals, split_triangles = split_vertices(
positions, triangles, 35.0
)
assert len(split_positions) == 0 and len(split_triangles) == 0
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_default_crease_leaves_the_smooth_sensing_surface_untouched() -> None:
"""The sensing surface has 0% sharp edges, so it must not be re-vertexed."""
smooth = load_sensing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
creased = load_sensing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
assert creased.vertex_count == smooth.vertex_count
assert np.allclose(creased.normals, smooth.normals, atol=1e-6)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "housing.npz").is_file(), reason="export not built"
)
def test_default_crease_splits_the_sharp_housing_edges() -> None:
smooth = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
creased = load_housing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
assert [p.name for p in smooth] == [p.name for p in creased]
for before, after in zip(smooth, creased):
assert after.triangle_count == before.triangle_count
assert after.vertex_count > before.vertex_count, before.name
# ------------------------------------------------------------ depth range
def test_projection_brackets_the_fitted_model() -> None:
camera = OrbitCamera()
low = np.array([-574.0, -576.0, -299.0])
high = np.array([574.0, 556.0, 1691.0])
camera.fit(low, high, 1.6)
view = camera.view()
projection = camera.projection()
eye = camera.eye()
for corner in (
np.array([x, y, z, 1.0])
for x in (low[0], high[0])
for y in (low[1], high[1])
for z in (low[2], high[2])
):
clip = projection @ view @ corner
ndc_z = clip[2] / clip[3]
assert -1.0 < ndc_z < 1.0, ndc_z
# The whole depth buffer is spent on the model, not a 1% slice of it.
depth = np.linalg.norm(view[:3, :3] @ (high - low))
assert depth > 0 and eye.shape == (3,)