Skip to content

Commit 7de340a

Browse files
committed
Add reconstruction tasks
1 parent 694f75e commit 7de340a

25 files changed

Lines changed: 2552 additions & 94 deletions

docs/api/reconstruction.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Reconstruction API
2+
3+
::: vizion3d.reconstruction

docs/hardware_acceleration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ uv add "vizion3d[cuda]"
124124

125125
Because `vizion3d[cuda]` declares no torch dependency, pip will not touch the CUDA wheel installed in step 1.
126126

127-
vizion3d detects CUDA via `torch.cuda.is_available()` at runtime and moves models and tensors to the GPU automatically.
127+
vizion3d detects CUDA via `torch.cuda.is_available()` at runtime and moves models and tensors to the GPU automatically. For reconstruction workloads, combine the extras as `vizion3d[cuda,reconstruction]`; the CUDA extra also installs `onnxruntime-gpu` so mandatory `rembg` background removal can use ONNX Runtime's CUDA provider when available.
128128

129129
---
130130

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Object 3D Reconstruction
2+
3+
`Object3DReconstruction` takes a close-range image of one object and produces:
4+
5+
- a cleaned, uniformly gray PLY mesh;
6+
- a uniformly gray point cloud sampled from the mesh surface.
7+
8+
Install the runtime dependencies with `pip install "vizion3d[reconstruction]"`.
9+
The task resolves `scene-components-3d-models.zip` from the repository root,
10+
`~/.cache/vizion3d/models`, or `VIZION3D_RECONSTRUCTION_MODEL_BUNDLE`.
11+
Set `VIZION3D_TRIPOSR_SOURCE` when the TripoSR Python source is outside the
12+
repository's `research/3D_Object-Reconstruction/TripoSR` directory.
13+
14+
```python
15+
from vizion3d.reconstruction import (
16+
Object3DReconstruction,
17+
Object3DReconstructionCommand,
18+
)
19+
20+
result = Object3DReconstruction().run(
21+
Object3DReconstructionCommand(image_input="object.png")
22+
)
23+
mesh = result.mesh
24+
point_cloud = result.point_cloud
25+
```
26+
27+
Inputs always use the bundled `rembg/u2net.onnx` model for background removal.
28+
29+
## Device
30+
31+
`Object3DReconstructionConfig(device="auto")` propagates the selected device to
32+
TripoSR and to `rembg` where the installed ONNX Runtime providers support it.
33+
`auto` prefers CUDA, then Apple/CoreML-compatible acceleration for `rembg`, then
34+
CPU. If an accelerated TripoSR or `rembg` run fails, the task retries that stage
35+
on CPU.
36+
37+
## Input Resolution
38+
39+
The task limits the longest input-image dimension to 1080 pixels before
40+
background removal. The resize preserves aspect ratio. This avoids spending
41+
memory and inference time on source pixels that cannot pass through TripoSR's
42+
final 512 by 512 conditioning input. The config may lower this limit, but
43+
values above 1080 are rejected.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Scene Components 3D Reconstruction
2+
3+
`SceneComponents3DReconstruction` accepts one scene image, estimates depth,
4+
detects and segments objects, maps each mask back to the original-resolution
5+
image, enhances each object crop with Real-ESRGAN, then runs
6+
`Object3DReconstruction` for each selected component.
7+
8+
Each selected crop always goes through `rembg` background removal inside
9+
`Object3DReconstruction`; there is no scene or object option to skip it.
10+
11+
Each component contains a uniformly gray mesh and point cloud, together with
12+
its label, confidence, source bounding box, and geometry counts.
13+
14+
```python
15+
from vizion3d.reconstruction import (
16+
SceneComponents3DReconstruction,
17+
SceneComponents3DReconstructionCommand,
18+
)
19+
20+
result = SceneComponents3DReconstruction().run(
21+
SceneComponents3DReconstructionCommand(image_input="scene.jpg")
22+
)
23+
```
24+
25+
## Device
26+
27+
The nested object config's `device` setting is propagated through the scene
28+
pipeline. TripoSR, `rembg`, and scene Real-ESRGAN use the requested accelerator
29+
when the installed runtime supports it, and retry on CPU if the accelerated
30+
stage fails. Mesh cleanup and point sampling remain CPU operations because they
31+
are handled by `trimesh`.
32+
33+
## Input Resolution
34+
35+
The scene-level `max_input_dimension=1080` applies to depth and segmentation
36+
analysis. Object crops are still taken from the original image, enhanced with
37+
Real-ESRGAN, then independently capped at 1080 pixels by
38+
`Object3DReconstruction` before foreground processing. TripoSR ultimately
39+
conditions every crop at its required 512 by 512 input size. Set the
40+
scene-level limit to `0` to disable only the depth and segmentation resize.

docs/tasks/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@ has a direct Python facade and, where available, REST and gRPC adapters.
2222
| Task | Python import | REST | gRPC |
2323
|---|---|---|---|
2424
| [Scale Observation](../observation/scale_observation.md) | `vizion3d.observation.ScaleObservation` | `/observation/scale-observation` | `RunScaleObservation` |
25+
26+
## Reconstruction
27+
28+
| Task | Python import | REST | gRPC |
29+
|---|---|---|---|
30+
| [Object 3D Reconstruction](../reconstruction/object_3d_reconstruction.md) | `vizion3d.reconstruction.Object3DReconstruction` | `/reconstruction/object-3d-reconstruction` | `RunObject3DReconstruction` |
31+
| [Scene Components 3D Reconstruction](../reconstruction/scene_components_3d_reconstruction.md) | `vizion3d.reconstruction.SceneComponents3DReconstruction` | `/reconstruction/scene-components-3d-reconstruction` | `RunSceneComponents3DReconstruction` |

mkdocs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ nav:
4040
- YOLOE-26L Prompt-Free Classes: annotation/yoloe_26l_prompt_free_classes.md
4141
- Observation:
4242
- Scale Observation: observation/scale_observation.md
43+
- Reconstruction:
44+
- Object 3D Reconstruction: reconstruction/object_3d_reconstruction.md
45+
- Scene Components 3D Reconstruction: reconstruction/scene_components_3d_reconstruction.md
4346
- Concepts:
4447
- Camera Intrinsics Matrix: concepts/camera_intrinsics.md
4548
- API Reference:
4649
- Lifting (2D → 3D): api/lifting.md
4750
- Observation: api/observation.md
51+
- Reconstruction: api/reconstruction.md

pyproject.toml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ dependencies = [
3030
"fastapi>=0.115.0",
3131
"clean-ioc>=1.3.0",
3232
"pydantic>=2.0.0",
33-
"grpcio>=1.60.0",
34-
"grpcio-tools>=1.60.0",
33+
"grpcio>=1.80.0",
34+
"grpcio-tools>=1.80.0",
3535
"uvicorn>=0.23.0",
3636
"python-multipart>=0.0.6",
3737
"transformers>=5.6.2",
@@ -89,8 +89,21 @@ dependencies = [
8989
[project.optional-dependencies]
9090
cpu = ["torch>=2.4.0", "torchvision>=0.19.0"]
9191
mps = ["torch>=2.4.0", "torchvision>=0.19.0"]
92-
cuda = []
92+
cuda = ["onnxruntime-gpu>=1.20.0"]
9393
amd = []
94+
reconstruction = [
95+
"torch>=2.4.0",
96+
"torchvision>=0.19.0",
97+
"trimesh>=4.0.0",
98+
"einops>=0.7.0",
99+
"omegaconf>=2.3.0",
100+
"PyMCubes>=0.1.6",
101+
"rembg>=2.0.67",
102+
"onnxruntime>=1.20.0",
103+
"opencv-python>=4.8.0",
104+
"basicsr>=1.4.2",
105+
"realesrgan>=0.3.0",
106+
]
94107

95108
[project.urls]
96109
Homepage = "https://github.com/OlafenwaMoses/vizion3D"
48.7 KB
Loading
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)