|
| 1 | +""" |
| 2 | +Integration tests — direct Python entry points for 3D reconstruction. |
| 3 | +
|
| 4 | +These tests run real image bytes through the new reconstruction tasks. They use |
| 5 | +low-cost mesh settings so the path is exercised without the full production |
| 6 | +point-count cost. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import json |
| 12 | +import os |
| 13 | +import time |
| 14 | +from io import BytesIO |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +import numpy as np |
| 18 | +import pytest |
| 19 | +from PIL import Image |
| 20 | + |
| 21 | +pytest.importorskip("open3d", reason="open3d required") |
| 22 | +pytest.importorskip("trimesh", reason="trimesh required") |
| 23 | +pytest.importorskip("rembg", reason="rembg required") |
| 24 | +pytest.importorskip("mcubes", reason="PyMCubes required") |
| 25 | +pytest.importorskip("omegaconf", reason="omegaconf required") |
| 26 | +pytest.importorskip("einops", reason="einops required") |
| 27 | + |
| 28 | +from vizion3d.lifting.utils import create_ply_binary # noqa: E402 |
| 29 | +from vizion3d.reconstruction import ( # noqa: E402 |
| 30 | + Object3DReconstruction, |
| 31 | + Object3DReconstructionCommand, |
| 32 | + Object3DReconstructionConfig, |
| 33 | + SceneComponents3DReconstruction, |
| 34 | + SceneComponents3DReconstructionCommand, |
| 35 | + SceneComponents3DReconstructionConfig, |
| 36 | +) |
| 37 | +from vizion3d.reconstruction.handlers import Object3DReconstructionHandler # noqa: E402 |
| 38 | + |
| 39 | +OBJECT_LIMIT = float(os.environ.get("VIZION3D_TEST_RECON_OBJECT_LIMIT", "240.0")) |
| 40 | +SCENE_LIMIT = float(os.environ.get("VIZION3D_TEST_RECON_SCENE_LIMIT", "420.0")) |
| 41 | +TEST_IMAGE_MAX_DIMENSION = 480 |
| 42 | +RECONSTRUCTION_IMAGE = "reconstruction_scene_480.jpg" |
| 43 | + |
| 44 | + |
| 45 | +@pytest.fixture(scope="session") |
| 46 | +def reconstruction_model_bundle() -> str: |
| 47 | + bundle = Path(__file__).resolve().parents[2] / "scene-components-3d-models.zip" |
| 48 | + if not bundle.is_file(): |
| 49 | + pytest.skip(f"Reconstruction model bundle not found: {bundle}") |
| 50 | + return str(bundle) |
| 51 | + |
| 52 | + |
| 53 | +@pytest.fixture(scope="session", autouse=True) |
| 54 | +def triposr_source_available(): |
| 55 | + source = ( |
| 56 | + Path(__file__).resolve().parents[2] |
| 57 | + / "research" |
| 58 | + / "3D_Object-Reconstruction" |
| 59 | + / "TripoSR" |
| 60 | + ) |
| 61 | + if not (source / "tsr" / "system.py").is_file(): |
| 62 | + pytest.skip(f"TripoSR source not found: {source}") |
| 63 | + os.environ["VIZION3D_TRIPOSR_SOURCE"] = str(source) |
| 64 | + |
| 65 | + |
| 66 | +@pytest.fixture(scope="session") |
| 67 | +def reconstruction_image_bytes() -> bytes: |
| 68 | + path = Path(__file__).parent.parent / "assets" / RECONSTRUCTION_IMAGE |
| 69 | + if not path.is_file(): |
| 70 | + pytest.skip(f"Reconstruction integration image not found: {path}") |
| 71 | + image = Image.open(path) |
| 72 | + assert max(image.size) <= TEST_IMAGE_MAX_DIMENSION |
| 73 | + return path.read_bytes() |
| 74 | + |
| 75 | + |
| 76 | +def _save_mesh_and_cloud(result, run_dir: Path, stem: str) -> None: |
| 77 | + run_dir.mkdir(parents=True, exist_ok=True) |
| 78 | + mesh_path = run_dir / f"{stem}_mesh.ply" |
| 79 | + cloud_path = run_dir / f"{stem}_point_cloud.ply" |
| 80 | + mesh_path.write_bytes(result.mesh.export(file_type="ply", encoding="binary_little_endian")) |
| 81 | + points = np.asarray(result.point_cloud.points).astype(np.float32) |
| 82 | + colors = (np.asarray(result.point_cloud.colors) * 255).astype(np.uint8) |
| 83 | + cloud_path.write_bytes(create_ply_binary(points, colors)) |
| 84 | + |
| 85 | + |
| 86 | +def _assert_object_result(result) -> None: |
| 87 | + assert result.vertex_count > 0 |
| 88 | + assert result.face_count > 0 |
| 89 | + assert result.point_count > 0 |
| 90 | + assert result.mesh.vertices.shape[0] == result.vertex_count |
| 91 | + assert result.mesh.faces.shape[0] == result.face_count |
| 92 | + assert result.point_cloud.has_points() |
| 93 | + assert len(np.asarray(result.point_cloud.points)) == result.point_count |
| 94 | + assert np.all(np.asarray(result.mesh.visual.vertex_colors)[:, :3] == 211) |
| 95 | + assert np.allclose(np.asarray(result.point_cloud.colors), 211 / 255) |
| 96 | + |
| 97 | + |
| 98 | +def test_object_3d_reconstruction_runs_real_image( |
| 99 | + reconstruction_image_bytes, |
| 100 | + reconstruction_model_bundle, |
| 101 | + tmp_path, |
| 102 | + timing_collector, |
| 103 | +): |
| 104 | + image = Image.open(BytesIO(reconstruction_image_bytes)) |
| 105 | + assert max(image.size) <= TEST_IMAGE_MAX_DIMENSION |
| 106 | + Object3DReconstructionHandler._models.clear() |
| 107 | + config = Object3DReconstructionConfig( |
| 108 | + max_input_dimension=512, |
| 109 | + marching_cubes_resolution=64, |
| 110 | + point_count=2_048, |
| 111 | + smoothing_iterations=0, |
| 112 | + device=os.environ.get("VIZION3D_TEST_RECON_DEVICE", "cpu"), |
| 113 | + ) |
| 114 | + |
| 115 | + t0 = time.perf_counter() |
| 116 | + result = Object3DReconstruction().run( |
| 117 | + Object3DReconstructionCommand( |
| 118 | + image_input=reconstruction_image_bytes, |
| 119 | + model_bundle=reconstruction_model_bundle, |
| 120 | + advanced_config=config, |
| 121 | + ) |
| 122 | + ) |
| 123 | + elapsed = time.perf_counter() - t0 |
| 124 | + |
| 125 | + _assert_object_result(result) |
| 126 | + _save_mesh_and_cloud(result, tmp_path / "object_3d_reconstruction", "object") |
| 127 | + timing_collector.add( |
| 128 | + "Direct", |
| 129 | + "TripoSR object", |
| 130 | + 1, |
| 131 | + elapsed, |
| 132 | + str(tmp_path / "object_3d_reconstruction"), |
| 133 | + task="Object 3D Reconstruction", |
| 134 | + model="TripoSR", |
| 135 | + device=config.device, |
| 136 | + ) |
| 137 | + assert elapsed < OBJECT_LIMIT |
| 138 | + |
| 139 | + |
| 140 | +def test_scene_components_3d_reconstruction_runs_real_image( |
| 141 | + reconstruction_image_bytes, |
| 142 | + reconstruction_model_bundle, |
| 143 | + local_model_path, |
| 144 | + local_annotation_model_path, |
| 145 | + tmp_path, |
| 146 | + timing_collector, |
| 147 | +): |
| 148 | + image = Image.open(BytesIO(reconstruction_image_bytes)) |
| 149 | + assert max(image.size) <= TEST_IMAGE_MAX_DIMENSION |
| 150 | + Object3DReconstructionHandler._models.clear() |
| 151 | + object_config = Object3DReconstructionConfig( |
| 152 | + max_input_dimension=512, |
| 153 | + marching_cubes_resolution=64, |
| 154 | + point_count=1_024, |
| 155 | + smoothing_iterations=0, |
| 156 | + device=os.environ.get("VIZION3D_TEST_RECON_DEVICE", "cpu"), |
| 157 | + ) |
| 158 | + config = SceneComponents3DReconstructionConfig( |
| 159 | + max_input_dimension=640, |
| 160 | + max_objects=1, |
| 161 | + confidence_threshold=0.05, |
| 162 | + padding_ratio=0.1, |
| 163 | + object_config=object_config, |
| 164 | + ) |
| 165 | + |
| 166 | + t0 = time.perf_counter() |
| 167 | + result = SceneComponents3DReconstruction().run( |
| 168 | + SceneComponents3DReconstructionCommand( |
| 169 | + image_input=reconstruction_image_bytes, |
| 170 | + model_bundle=reconstruction_model_bundle, |
| 171 | + depth_model_backend=local_model_path, |
| 172 | + annotation_model_backend=local_annotation_model_path, |
| 173 | + advanced_config=config, |
| 174 | + ) |
| 175 | + ) |
| 176 | + elapsed = time.perf_counter() - t0 |
| 177 | + |
| 178 | + assert result.source_image_size[0] > 0 and result.source_image_size[1] > 0 |
| 179 | + assert max(result.source_image_size) <= TEST_IMAGE_MAX_DIMENSION |
| 180 | + assert max(result.analysis_image_size) <= config.max_input_dimension |
| 181 | + assert result.depth_backend_used |
| 182 | + assert result.annotation_backend_used |
| 183 | + assert result.reconstruction_backend_used |
| 184 | + assert len(result.components) >= 1 |
| 185 | + |
| 186 | + run_dir = tmp_path / "scene_components_3d_reconstruction" |
| 187 | + summary = { |
| 188 | + "source_image_size": result.source_image_size, |
| 189 | + "analysis_image_size": result.analysis_image_size, |
| 190 | + "components": [ |
| 191 | + { |
| 192 | + "label": component.label, |
| 193 | + "class_id": component.class_id, |
| 194 | + "confidence": component.confidence, |
| 195 | + "vertex_count": component.vertex_count, |
| 196 | + "face_count": component.face_count, |
| 197 | + "point_count": component.point_count, |
| 198 | + } |
| 199 | + for component in result.components |
| 200 | + ], |
| 201 | + } |
| 202 | + run_dir.mkdir(parents=True, exist_ok=True) |
| 203 | + (run_dir / "summary.json").write_text(json.dumps(summary, indent=2)) |
| 204 | + |
| 205 | + for index, component in enumerate(result.components, start=1): |
| 206 | + _assert_object_result(component) |
| 207 | + _save_mesh_and_cloud(component, run_dir, f"component_{index:02d}") |
| 208 | + |
| 209 | + timing_collector.add( |
| 210 | + "Direct", |
| 211 | + "Scene components", |
| 212 | + 1, |
| 213 | + elapsed, |
| 214 | + str(run_dir), |
| 215 | + task="Scene Components 3D Reconstruction", |
| 216 | + model="Depth + YOLO + RealESRGAN + TripoSR", |
| 217 | + device=object_config.device, |
| 218 | + ) |
| 219 | + assert elapsed < SCENE_LIMIT |
0 commit comments