reverse3d 0.8.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reverse3d-0.8.0/reverse3d.egg-info → reverse3d-0.9.0}/PKG-INFO +5 -4
- {reverse3d-0.8.0 → reverse3d-0.9.0}/README.md +4 -3
- reverse3d-0.9.0/reverse3d/_version.py +1 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/recipes.py +94 -41
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/silhouette.py +115 -1
- {reverse3d-0.8.0 → reverse3d-0.9.0/reverse3d.egg-info}/PKG-INFO +5 -4
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/SOURCES.txt +1 -0
- reverse3d-0.9.0/tests/test_color.py +49 -0
- reverse3d-0.9.0/tests/test_silhouette.py +284 -0
- reverse3d-0.8.0/reverse3d/_version.py +0 -1
- reverse3d-0.8.0/tests/test_silhouette.py +0 -158
- {reverse3d-0.8.0 → reverse3d-0.9.0}/LICENSE +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/NOTICE +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/pyproject.toml +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/__main__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/_about.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/cli.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/config.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/doctor.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/errors.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/hardware.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/imaging.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/offline.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/profiles.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/py.typed +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/scenes.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/security.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d/watch.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/setup.cfg +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_authorship.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_automation.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_cli.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_config.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_heic.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_mesh.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_render.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_scenes.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_security.py +0 -0
- {reverse3d-0.8.0 → reverse3d-0.9.0}/tests/test_vision.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
215
215
|
|
|
216
216
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
217
217
|
|---------------------|----------------------------|--------|
|
|
218
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
219
|
-
| `--lite` | `-Lite` | Todo
|
|
220
|
-
| `--minimal` | `-Minimal` |
|
|
218
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
219
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
220
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
221
221
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
222
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
222
223
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
223
224
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
224
225
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -147,10 +147,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
147
147
|
|
|
148
148
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
149
149
|
|---------------------|----------------------------|--------|
|
|
150
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
151
|
-
| `--lite` | `-Lite` | Todo
|
|
152
|
-
| `--minimal` | `-Minimal` |
|
|
150
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
151
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
152
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
153
153
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
154
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
154
155
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
155
156
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
156
157
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.0"
|
|
@@ -30,6 +30,8 @@ from dataclasses import dataclass, field
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Literal, TypeVar
|
|
32
32
|
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
33
35
|
from reverse3d.config import Settings
|
|
34
36
|
from reverse3d.errors import (
|
|
35
37
|
CaptureQualityError,
|
|
@@ -135,29 +137,70 @@ def _clean(
|
|
|
135
137
|
parts, and tiny debris is already dropped by the area filter."""
|
|
136
138
|
from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
|
|
137
139
|
|
|
140
|
+
options = CleanOptions(
|
|
141
|
+
keep_largest_only=keep_largest,
|
|
142
|
+
fill_holes=fill_holes,
|
|
143
|
+
align=align,
|
|
144
|
+
ground=align != "none",
|
|
145
|
+
scale=scale,
|
|
146
|
+
max_hole_edges=2000 if repair == "strong" else 200,
|
|
147
|
+
)
|
|
138
148
|
report = run.step(
|
|
139
149
|
"clean",
|
|
140
|
-
lambda: clean_mesh_file(
|
|
141
|
-
src,
|
|
142
|
-
dst,
|
|
143
|
-
run.settings,
|
|
144
|
-
CleanOptions(
|
|
145
|
-
keep_largest_only=keep_largest,
|
|
146
|
-
fill_holes=fill_holes,
|
|
147
|
-
align=align,
|
|
148
|
-
ground=align != "none",
|
|
149
|
-
scale=scale,
|
|
150
|
-
max_hole_edges=2000 if repair == "strong" else 200,
|
|
151
|
-
),
|
|
152
|
-
overwrite=overwrite,
|
|
153
|
-
),
|
|
150
|
+
lambda: clean_mesh_file(src, dst, run.settings, options, overwrite=overwrite),
|
|
154
151
|
)
|
|
155
152
|
run.warnings.extend(report.warnings)
|
|
156
153
|
run.outputs["model"] = str(dst)
|
|
157
154
|
run.details["clean"] = report.to_dict()
|
|
155
|
+
if _has_vertex_colors(src):
|
|
156
|
+
# Same cleaning on the coloured mesh (deterministic, colours follow the
|
|
157
|
+
# vertices): model_color.ply for renders and model.glb for games and viewers.
|
|
158
|
+
try:
|
|
159
|
+
color_ply = dst.with_name("model_color.ply")
|
|
160
|
+
clean_mesh_file(src, color_ply, run.settings, options, overwrite=True)
|
|
161
|
+
import trimesh
|
|
162
|
+
|
|
163
|
+
glb = dst.with_name("model.glb")
|
|
164
|
+
trimesh.load(color_ply, process=False, force="mesh").export(glb)
|
|
165
|
+
run.outputs["model_color"] = str(color_ply)
|
|
166
|
+
run.outputs["model_glb"] = str(glb)
|
|
167
|
+
except Exception as exc: # colour is a bonus: never fail the model for it
|
|
168
|
+
logger.info("Coloured model skipped: %s", exc)
|
|
158
169
|
return report.after
|
|
159
170
|
|
|
160
171
|
|
|
172
|
+
def _has_vertex_colors(path: Path) -> bool:
|
|
173
|
+
import trimesh
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
mesh: Any = trimesh.load(path, process=False, force="mesh")
|
|
177
|
+
except Exception:
|
|
178
|
+
return False
|
|
179
|
+
colors = getattr(mesh.visual, "vertex_colors", None)
|
|
180
|
+
if colors is None or len(colors) != len(mesh.vertices):
|
|
181
|
+
return False
|
|
182
|
+
rgb = np.asarray(colors)[:, :3]
|
|
183
|
+
return float(np.max(np.std(rgb, axis=0))) > 1.0 # not just a uniform default colour
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _color_from_cloud(mesh_path: Path, cloud_path: Path, out: Path) -> Path | None:
|
|
187
|
+
"""Paint a mesh with the colours of the point cloud it was built from."""
|
|
188
|
+
import trimesh
|
|
189
|
+
from scipy.spatial import cKDTree
|
|
190
|
+
|
|
191
|
+
cloud: Any = trimesh.load(cloud_path, process=False)
|
|
192
|
+
colors = getattr(cloud, "colors", None)
|
|
193
|
+
if colors is None or len(colors) != len(getattr(cloud, "vertices", [])):
|
|
194
|
+
return None
|
|
195
|
+
mesh: Any = trimesh.load(mesh_path, process=False, force="mesh")
|
|
196
|
+
_, idx = cKDTree(np.asarray(cloud.vertices)).query(np.asarray(mesh.vertices), k=4)
|
|
197
|
+
rgb = np.asarray(colors)[idx][..., :3].astype(np.float64).mean(axis=1)
|
|
198
|
+
rgba = np.column_stack([rgb, np.full(len(rgb), 255.0)]).clip(0, 255).astype(np.uint8)
|
|
199
|
+
mesh.visual = trimesh.visual.ColorVisuals(mesh, vertex_colors=rgba)
|
|
200
|
+
mesh.export(out)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
161
204
|
def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
|
|
162
205
|
from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
|
|
163
206
|
|
|
@@ -210,28 +253,6 @@ _DEFAULT_LOOK = {
|
|
|
210
253
|
}
|
|
211
254
|
|
|
212
255
|
|
|
213
|
-
def _color_outputs(run: _Run, colored: Path, model: Path, scale: float) -> None:
|
|
214
|
-
"""Give the final model the photo colours of the carved hull: model.glb (games,
|
|
215
|
-
web, viewers) and model_color.ply (renders)."""
|
|
216
|
-
import numpy as np
|
|
217
|
-
import trimesh
|
|
218
|
-
from scipy.spatial import cKDTree
|
|
219
|
-
|
|
220
|
-
source: Any = trimesh.load(colored, process=False, force="mesh")
|
|
221
|
-
target: Any = trimesh.load(model, process=False, force="mesh")
|
|
222
|
-
colors = getattr(source.visual, "vertex_colors", None)
|
|
223
|
-
if colors is None or len(colors) != len(source.vertices):
|
|
224
|
-
return
|
|
225
|
-
_, nearest = cKDTree(np.asarray(source.vertices) * scale).query(np.asarray(target.vertices))
|
|
226
|
-
target.visual = trimesh.visual.ColorVisuals(target, vertex_colors=np.asarray(colors)[nearest])
|
|
227
|
-
glb = run.out / "model.glb"
|
|
228
|
-
ply = run.out / "model_color.ply"
|
|
229
|
-
target.export(glb)
|
|
230
|
-
target.export(ply)
|
|
231
|
-
run.outputs["model_glb"] = str(glb)
|
|
232
|
-
run.outputs["model_color"] = str(ply)
|
|
233
|
-
|
|
234
|
-
|
|
235
256
|
def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
236
257
|
from reverse3d.reconstruction.photogrammetry import (
|
|
237
258
|
PhotogrammetryOptions,
|
|
@@ -319,6 +340,18 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
319
340
|
)
|
|
320
341
|
return None
|
|
321
342
|
|
|
343
|
+
def load_depth() -> Callable[[np.ndarray], np.ndarray] | None:
|
|
344
|
+
# AI depth reveals concavities and holes (cup insides, handle loops).
|
|
345
|
+
from reverse3d.reconstruction.depth_estimation import predict_depth
|
|
346
|
+
|
|
347
|
+
device = torch_device(run.settings)
|
|
348
|
+
try:
|
|
349
|
+
predict_depth(np.zeros((32, 32, 3), np.uint8), device=device, settings=run.settings)
|
|
350
|
+
except Reverse3DError as exc:
|
|
351
|
+
logger.info("AI depth unavailable (%s); concavities stay filled", exc.message)
|
|
352
|
+
return None
|
|
353
|
+
return lambda rgb: predict_depth(rgb, device=device, settings=run.settings)
|
|
354
|
+
|
|
322
355
|
try:
|
|
323
356
|
hull = run.step(
|
|
324
357
|
"silhouette",
|
|
@@ -327,6 +360,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
327
360
|
Path(photo.sparse_text_dir or ""),
|
|
328
361
|
run.out / "reconstruction" / "object_hull.ply",
|
|
329
362
|
segmenter=load_segmenter,
|
|
363
|
+
depth=load_depth,
|
|
330
364
|
),
|
|
331
365
|
)
|
|
332
366
|
except ProcessingError as exc:
|
|
@@ -352,6 +386,30 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
352
386
|
"Point cloud created but no mesh could be built.",
|
|
353
387
|
hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
|
|
354
388
|
)
|
|
389
|
+
cloud = photo.dense_points_path or photo.sparse_points_path
|
|
390
|
+
if not upright:
|
|
391
|
+
# Dense scenes: colour from the photos themselves (sharp, independent of how
|
|
392
|
+
# dense the cloud is); the cloud's colours are the fallback.
|
|
393
|
+
colored_path = run.out / "reconstruction" / "mesh_color.ply"
|
|
394
|
+
colored: Path | None = None
|
|
395
|
+
try:
|
|
396
|
+
from reverse3d.reconstruction.silhouette import colorize_from_photos
|
|
397
|
+
|
|
398
|
+
if photo.images_dir and photo.sparse_text_dir:
|
|
399
|
+
colored = colorize_from_photos(
|
|
400
|
+
Path(mesh_path),
|
|
401
|
+
Path(photo.images_dir),
|
|
402
|
+
Path(photo.sparse_text_dir),
|
|
403
|
+
colored_path,
|
|
404
|
+
)
|
|
405
|
+
except Exception as exc: # colour is a bonus
|
|
406
|
+
logger.info("Colouring from the photos skipped: %s", exc)
|
|
407
|
+
if colored is None and cloud:
|
|
408
|
+
try:
|
|
409
|
+
colored = _color_from_cloud(Path(mesh_path), Path(cloud), colored_path)
|
|
410
|
+
except Exception as exc: # colour is a bonus
|
|
411
|
+
logger.info("Colouring from the point cloud skipped: %s", exc)
|
|
412
|
+
mesh_path = str(colored) if colored else mesh_path
|
|
355
413
|
model = run.out / "model.stl"
|
|
356
414
|
_clean(
|
|
357
415
|
run,
|
|
@@ -364,11 +422,6 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
364
422
|
keep_largest=preset.keep_largest,
|
|
365
423
|
repair=opt.repair,
|
|
366
424
|
)
|
|
367
|
-
if upright:
|
|
368
|
-
try:
|
|
369
|
-
_color_outputs(run, Path(mesh_path), model, opt.scale)
|
|
370
|
-
except Exception as exc: # colour is a bonus: never fail the model for it
|
|
371
|
-
logger.info("Colour transfer skipped: %s", exc)
|
|
372
425
|
return _measure(run, model)
|
|
373
426
|
|
|
374
427
|
|
|
@@ -69,6 +69,7 @@ class HullResult:
|
|
|
69
69
|
faces: int
|
|
70
70
|
support_plane: bool
|
|
71
71
|
ai_segmentation: bool = False
|
|
72
|
+
depth_carved_voxels: int = 0
|
|
72
73
|
|
|
73
74
|
def to_dict(self) -> dict[str, object]:
|
|
74
75
|
return asdict(self)
|
|
@@ -250,6 +251,102 @@ def _vertex_colors(
|
|
|
250
251
|
return colors
|
|
251
252
|
|
|
252
253
|
|
|
254
|
+
def _fit_inverse_depth(disparity: np.ndarray, depth: np.ndarray) -> tuple[float, float] | None:
|
|
255
|
+
"""Robust fit 1/depth ≈ s·disparity + t (relative AI disparity → metric-like depth)."""
|
|
256
|
+
inv = 1.0 / depth
|
|
257
|
+
keep = np.ones(len(inv), bool)
|
|
258
|
+
for _ in range(3):
|
|
259
|
+
if keep.sum() < 50:
|
|
260
|
+
return None
|
|
261
|
+
a = np.column_stack([disparity[keep], np.ones(int(keep.sum()))])
|
|
262
|
+
(s, t), *_ = np.linalg.lstsq(a, inv[keep], rcond=None)
|
|
263
|
+
residual = np.abs(s * disparity + t - inv)
|
|
264
|
+
mad = float(np.median(residual[keep])) or 1e-9
|
|
265
|
+
keep = residual < 3 * 1.4826 * mad
|
|
266
|
+
return (float(s), float(t)) if s > 0 else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _depth_carve(
|
|
270
|
+
keep: np.ndarray,
|
|
271
|
+
grid: np.ndarray,
|
|
272
|
+
views: list[_View],
|
|
273
|
+
images: list[tuple[np.ndarray, float]],
|
|
274
|
+
projections: list[tuple[np.ndarray, np.ndarray, np.ndarray]],
|
|
275
|
+
masks: list[np.ndarray],
|
|
276
|
+
estimator: Callable[[np.ndarray], np.ndarray],
|
|
277
|
+
*,
|
|
278
|
+
tolerance: float = 0.05,
|
|
279
|
+
min_votes: int = 3,
|
|
280
|
+
min_share: float = 0.5,
|
|
281
|
+
) -> np.ndarray:
|
|
282
|
+
"""Empty the voxels that sit clearly in front of the surface the AI depth sees,
|
|
283
|
+
in at least ``min_votes`` photos *and* in most (``min_share``) of the photos
|
|
284
|
+
that actually see them. A photo where the voxel lies on the surface votes
|
|
285
|
+
solid; one where it is hidden behind the surface abstains. A few wrong depth
|
|
286
|
+
maps therefore cannot carve real material.
|
|
287
|
+
|
|
288
|
+
The relative AI depth is calibrated per photo against the carved hull itself
|
|
289
|
+
(a robust fit on the object's pixels), so only real disagreements carve.
|
|
290
|
+
"""
|
|
291
|
+
votes = np.zeros(len(grid), np.int32)
|
|
292
|
+
seen = np.zeros(len(grid), np.int32)
|
|
293
|
+
for (small, _), view, (u, v, inside), mask in zip(
|
|
294
|
+
images, views, projections, masks, strict=True
|
|
295
|
+
):
|
|
296
|
+
sel = keep & inside
|
|
297
|
+
if sel.sum() < 100:
|
|
298
|
+
continue
|
|
299
|
+
h, w = small.shape[:2]
|
|
300
|
+
z = (grid[sel] @ view.rotation.T + view.translation)[:, 2]
|
|
301
|
+
us, vs = u[sel], v[sel]
|
|
302
|
+
# Depth of the hull's visible surface per pixel (z-buffer of kept voxels).
|
|
303
|
+
hull_depth = np.full((h, w), np.inf)
|
|
304
|
+
np.minimum.at(hull_depth, (vs, us), z)
|
|
305
|
+
object_px = (mask > 0) & np.isfinite(hull_depth)
|
|
306
|
+
if object_px.sum() < 100:
|
|
307
|
+
continue
|
|
308
|
+
disparity = np.asarray(estimator(cv2.cvtColor(small, cv2.COLOR_BGR2RGB)), np.float64)
|
|
309
|
+
if disparity.shape != (h, w):
|
|
310
|
+
disparity = cv2.resize(disparity, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
311
|
+
fit = _fit_inverse_depth(disparity[object_px], hull_depth[object_px])
|
|
312
|
+
if fit is None:
|
|
313
|
+
continue
|
|
314
|
+
s, t = fit
|
|
315
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
316
|
+
observed = 1.0 / np.maximum(s * disparity + t, 1e-9)
|
|
317
|
+
# A voxel strictly in front of the observed surface, on an object pixel, is air.
|
|
318
|
+
in_mask = mask[vs, us] > 0
|
|
319
|
+
front = z < observed[vs, us] * (1.0 - tolerance)
|
|
320
|
+
on_surface = np.abs(z - observed[vs, us]) <= tolerance * observed[vs, us]
|
|
321
|
+
idx = np.flatnonzero(sel)
|
|
322
|
+
seen[idx[in_mask & (front | on_surface)]] += 1 # hidden voxels abstain
|
|
323
|
+
votes[idx[in_mask & front]] += 1
|
|
324
|
+
air = (votes >= min_votes) & (votes >= min_share * np.maximum(seen, 1))
|
|
325
|
+
return keep & ~air
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def colorize_from_photos(
|
|
329
|
+
mesh_path: Path, image_dir: Path, model_text_dir: Path, output_path: Path
|
|
330
|
+
) -> Path:
|
|
331
|
+
"""Paint any mesh in COLMAP coordinates with the photos that see it (dense scenes)."""
|
|
332
|
+
views, _ = read_colmap_text(model_text_dir)
|
|
333
|
+
images: list[tuple[np.ndarray, float]] = []
|
|
334
|
+
for view in views:
|
|
335
|
+
bgr = cv2.imread(str(image_dir / view.name))
|
|
336
|
+
if bgr is None:
|
|
337
|
+
raise ProcessingError(f"Could not read {view.name} for colouring.")
|
|
338
|
+
h, w = bgr.shape[:2]
|
|
339
|
+
scale = 1280 / max(h, w)
|
|
340
|
+
size = (round(w * scale), round(h * scale))
|
|
341
|
+
images.append((cv2.resize(bgr, size, interpolation=cv2.INTER_AREA), scale))
|
|
342
|
+
mesh: trimesh.Trimesh = trimesh.load(mesh_path, process=False, force="mesh") # type: ignore[assignment]
|
|
343
|
+
mesh.visual = trimesh.visual.ColorVisuals(
|
|
344
|
+
mesh, vertex_colors=_vertex_colors(mesh, views, images)
|
|
345
|
+
)
|
|
346
|
+
mesh.export(output_path)
|
|
347
|
+
return output_path
|
|
348
|
+
|
|
349
|
+
|
|
253
350
|
def visual_hull(
|
|
254
351
|
image_dir: Path,
|
|
255
352
|
model_text_dir: Path,
|
|
@@ -258,6 +355,7 @@ def visual_hull(
|
|
|
258
355
|
resolution: int = 176,
|
|
259
356
|
refinements: int = 3,
|
|
260
357
|
segmenter: Callable[[], ObjectSegmenter | None] | None = None,
|
|
358
|
+
depth: Callable[[], Callable[[np.ndarray], np.ndarray] | None] | None = None,
|
|
261
359
|
) -> HullResult:
|
|
262
360
|
"""Carve the object seen by every photo; writes a watertight, upright mesh.
|
|
263
361
|
|
|
@@ -385,6 +483,16 @@ def visual_hull(
|
|
|
385
483
|
masks = refined
|
|
386
484
|
keep = carve(masks, 1)
|
|
387
485
|
|
|
486
|
+
# Concavities and through-holes (a handle loop, the inside of a cup) never
|
|
487
|
+
# show in a silhouette: the AI depth of each photo reveals them.
|
|
488
|
+
estimator = depth() if depth is not None else None
|
|
489
|
+
carved_by_depth = 0
|
|
490
|
+
if estimator is not None:
|
|
491
|
+
before = int(keep.sum())
|
|
492
|
+
keep = _depth_carve(keep, grid, views, images, projections, masks, estimator)
|
|
493
|
+
carved_by_depth = before - int(keep.sum())
|
|
494
|
+
logger.info("AI depth carved %d voxel(s) of concavities", carved_by_depth)
|
|
495
|
+
|
|
388
496
|
volume = keep.reshape(n, n, n).astype(np.float32)
|
|
389
497
|
plane = _support_plane(points, center, radius, cameras)
|
|
390
498
|
if plane is not None:
|
|
@@ -438,5 +546,11 @@ def visual_hull(
|
|
|
438
546
|
mesh.export(output_path)
|
|
439
547
|
logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
|
|
440
548
|
return HullResult(
|
|
441
|
-
str(output_path),
|
|
549
|
+
str(output_path),
|
|
550
|
+
len(views),
|
|
551
|
+
voxels,
|
|
552
|
+
len(mesh.faces),
|
|
553
|
+
plane is not None,
|
|
554
|
+
ai is not None,
|
|
555
|
+
carved_by_depth,
|
|
442
556
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
215
215
|
|
|
216
216
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
217
217
|
|---------------------|----------------------------|--------|
|
|
218
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
219
|
-
| `--lite` | `-Lite` | Todo
|
|
220
|
-
| `--minimal` | `-Minimal` |
|
|
218
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
219
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
220
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
221
221
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
222
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
222
223
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
223
224
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
224
225
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import trimesh
|
|
7
|
+
|
|
8
|
+
from reverse3d.config import Settings
|
|
9
|
+
from reverse3d.recipes import _clean, _color_from_cloud, _has_vertex_colors, _Run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _two_tone_cloud(path: Path) -> Path:
|
|
13
|
+
"""Points on a sphere: red on top, blue at the bottom (like a COLMAP fused.ply)."""
|
|
14
|
+
pts = trimesh.creation.icosphere(subdivisions=5).vertices
|
|
15
|
+
rgb = np.where(pts[:, 2:3] > 0, [220, 30, 30], [30, 30, 220])
|
|
16
|
+
trimesh.PointCloud(pts, colors=np.column_stack([rgb, np.full(len(pts), 255)])).export(path)
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_mesh_takes_the_colours_of_its_point_cloud(tmp_path: Path) -> None:
|
|
21
|
+
plain = tmp_path / "mesh.ply"
|
|
22
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
23
|
+
assert not _has_vertex_colors(plain)
|
|
24
|
+
colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
|
|
25
|
+
assert colored is not None and _has_vertex_colors(colored)
|
|
26
|
+
mesh = trimesh.load(colored, process=False)
|
|
27
|
+
top = mesh.vertices[:, 2] > 0.3
|
|
28
|
+
assert mesh.visual.vertex_colors[top, 0].mean() > 150 # red on top
|
|
29
|
+
assert mesh.visual.vertex_colors[~top & (mesh.vertices[:, 2] < -0.3), 2].mean() > 150
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cleaning_a_coloured_mesh_also_writes_glb(tmp_path: Path, settings: Settings) -> None:
|
|
33
|
+
plain = tmp_path / "mesh.ply"
|
|
34
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
35
|
+
colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
|
|
36
|
+
assert colored is not None
|
|
37
|
+
run = _Run(recipe="photos_to_model", settings=settings, out=tmp_path)
|
|
38
|
+
_clean(run, colored, tmp_path / "model.stl", scale=1.0, overwrite=True)
|
|
39
|
+
assert {"model", "model_color", "model_glb"} <= set(run.outputs)
|
|
40
|
+
glb = trimesh.load(run.outputs["model_glb"], force="mesh")
|
|
41
|
+
assert len(np.unique(np.asarray(glb.visual.vertex_colors)[:, :3], axis=0)) > 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_plain_meshes_get_no_colour_outputs(tmp_path: Path, settings: Settings) -> None:
|
|
45
|
+
plain = tmp_path / "mesh.ply"
|
|
46
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
47
|
+
run = _Run(recipe="mesh_report", settings=settings, out=tmp_path)
|
|
48
|
+
_clean(run, plain, tmp_path / "model.stl", scale=1.0, overwrite=True)
|
|
49
|
+
assert "model_glb" not in run.outputs
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
import trimesh
|
|
9
|
+
|
|
10
|
+
pytest.importorskip("skimage")
|
|
11
|
+
|
|
12
|
+
from reverse3d.errors import ProcessingError
|
|
13
|
+
from reverse3d.reconstruction.silhouette import convergence_point, read_colmap_text, visual_hull
|
|
14
|
+
|
|
15
|
+
SIZE = 400
|
|
16
|
+
F = 420.0
|
|
17
|
+
RADIUS, HEIGHT = 0.35, 1.2 # the object: an upright cylinder standing on z = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _look_at(eye: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
21
|
+
"""World→camera rotation and translation (COLMAP convention: +Z forward, +Y down)."""
|
|
22
|
+
forward = target - eye
|
|
23
|
+
forward /= np.linalg.norm(forward)
|
|
24
|
+
right = np.cross(forward, [0.0, 0.0, 1.0])
|
|
25
|
+
right /= np.linalg.norm(right)
|
|
26
|
+
down = np.cross(forward, right)
|
|
27
|
+
rot = np.stack([right, down, forward])
|
|
28
|
+
return rot, -rot @ eye
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _quat(rot: np.ndarray) -> list[float]:
|
|
32
|
+
return list(
|
|
33
|
+
trimesh.transformations.quaternion_from_matrix(
|
|
34
|
+
np.vstack([np.hstack([rot, [[0], [0], [0]]]), [0, 0, 0, 1]])
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _scene(folder: Path) -> tuple[Path, Path]:
|
|
40
|
+
images = folder / "images"
|
|
41
|
+
text = folder / "txt"
|
|
42
|
+
images.mkdir(parents=True)
|
|
43
|
+
text.mkdir()
|
|
44
|
+
rng = np.random.default_rng(0)
|
|
45
|
+
theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
|
|
46
|
+
rim = np.concatenate(
|
|
47
|
+
[
|
|
48
|
+
np.stack([RADIUS * np.cos(theta), RADIUS * np.sin(theta), np.full_like(theta, z)], 1)
|
|
49
|
+
for z in (0.0, HEIGHT)
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
lines = []
|
|
53
|
+
k = 0
|
|
54
|
+
for height, count in ((0.6, 12), (2.2, 12), (3.4, 6)):
|
|
55
|
+
for i in range(count):
|
|
56
|
+
a = 2 * np.pi * i / count
|
|
57
|
+
eye = np.array([3.2 * np.cos(a), 3.2 * np.sin(a), height])
|
|
58
|
+
rot, t = _look_at(eye, np.array([0.0, 0.0, 0.6]))
|
|
59
|
+
# Textured background, then the white cylinder's silhouette.
|
|
60
|
+
image = rng.integers(0, 120, (SIZE, SIZE, 3), dtype=np.uint8)
|
|
61
|
+
image = cv2.GaussianBlur(image, (5, 5), 0)
|
|
62
|
+
cam = rim @ rot.T + t
|
|
63
|
+
uv = (cam[:, :2] / cam[:, 2:3]) * F + SIZE / 2
|
|
64
|
+
cv2.fillConvexPoly(image, cv2.convexHull(uv.astype(np.int32)), (235, 235, 235))
|
|
65
|
+
name = f"img_{k:02d}.jpg"
|
|
66
|
+
cv2.imwrite(str(images / name), image)
|
|
67
|
+
q = _quat(rot)
|
|
68
|
+
lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
|
|
69
|
+
k += 1
|
|
70
|
+
(text / "cameras.txt").write_text(f"1 PINHOLE {SIZE} {SIZE} {F} {F} {SIZE / 2} {SIZE / 2}\n")
|
|
71
|
+
(text / "images.txt").write_text("".join(lines))
|
|
72
|
+
table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
|
|
73
|
+
table = table[np.hypot(table[:, 0], table[:, 1]) > RADIUS + 0.05]
|
|
74
|
+
(text / "points3D.txt").write_text(
|
|
75
|
+
"".join(f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
|
|
76
|
+
)
|
|
77
|
+
return images, text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_cameras_converge_on_the_object(tmp_path: Path) -> None:
|
|
81
|
+
_, text = _scene(tmp_path)
|
|
82
|
+
views, points = read_colmap_text(text)
|
|
83
|
+
center, distance = convergence_point(views)
|
|
84
|
+
assert len(views) == 30 and len(points) > 100
|
|
85
|
+
assert np.linalg.norm(center - [0, 0, 0.6]) < 0.05
|
|
86
|
+
assert 3.0 < distance < 3.8
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_visual_hull_rebuilds_an_upright_cylinder(tmp_path: Path) -> None:
|
|
90
|
+
images, text = _scene(tmp_path)
|
|
91
|
+
result = visual_hull(images, text, tmp_path / "hull.ply", resolution=80, refinements=1)
|
|
92
|
+
mesh = trimesh.load(result.mesh_path)
|
|
93
|
+
assert result.support_plane
|
|
94
|
+
assert mesh.is_watertight
|
|
95
|
+
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
96
|
+
assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
|
|
97
|
+
# Height / diameter of the real cylinder is 1.2 / 0.7 ≈ 1.7.
|
|
98
|
+
assert 1.3 < mesh.extents[2] / width < 2.2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_too_few_photos(tmp_path: Path) -> None:
|
|
102
|
+
images, text = _scene(tmp_path)
|
|
103
|
+
rows = (text / "images.txt").read_text().split("\n\n")
|
|
104
|
+
(text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
|
|
105
|
+
with pytest.raises(ProcessingError, match="at least"):
|
|
106
|
+
visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _FakeSegmenter:
|
|
110
|
+
"""Stands in for Segment Anything: the object is the bright part inside the box."""
|
|
111
|
+
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
self.calls = 0
|
|
114
|
+
|
|
115
|
+
def embed(self, rgb: np.ndarray) -> dict[str, np.ndarray]:
|
|
116
|
+
return {"rgb": rgb}
|
|
117
|
+
|
|
118
|
+
def segment(
|
|
119
|
+
self, state: dict[str, np.ndarray], box: tuple[int, int, int, int], point: tuple[int, int]
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
self.calls += 1
|
|
122
|
+
gray = state["rgb"].mean(axis=2)
|
|
123
|
+
mask = np.zeros(gray.shape, np.uint8)
|
|
124
|
+
x0, y0, x1, y1 = box
|
|
125
|
+
mask[y0 : y1 + 1, x0 : x1 + 1] = np.where(gray[y0 : y1 + 1, x0 : x1 + 1] > 200, 255, 0)
|
|
126
|
+
return mask
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_ai_segmenter_is_loaded_lazily_and_used(tmp_path: Path) -> None:
|
|
130
|
+
images, text = _scene(tmp_path)
|
|
131
|
+
fake = _FakeSegmenter()
|
|
132
|
+
loads: list[int] = []
|
|
133
|
+
|
|
134
|
+
def factory() -> _FakeSegmenter:
|
|
135
|
+
loads.append(1)
|
|
136
|
+
return fake
|
|
137
|
+
|
|
138
|
+
result = visual_hull(
|
|
139
|
+
images,
|
|
140
|
+
text,
|
|
141
|
+
tmp_path / "hull.ply",
|
|
142
|
+
resolution=64,
|
|
143
|
+
refinements=1,
|
|
144
|
+
segmenter=factory, # type: ignore[arg-type]
|
|
145
|
+
)
|
|
146
|
+
assert loads == [1] and fake.calls == 2 * 30 # two AI rounds over the 30 photos
|
|
147
|
+
assert result.ai_segmentation
|
|
148
|
+
mesh = trimesh.load(result.mesh_path)
|
|
149
|
+
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
150
|
+
assert mesh.is_watertight and 1.3 < mesh.extents[2] / width < 2.2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_without_ai_segmenter_grabcut_is_used(tmp_path: Path) -> None:
|
|
154
|
+
images, text = _scene(tmp_path)
|
|
155
|
+
result = visual_hull(
|
|
156
|
+
images, text, tmp_path / "hull.ply", resolution=48, refinements=1, segmenter=lambda: None
|
|
157
|
+
)
|
|
158
|
+
assert not result.ai_segmentation
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cup() -> trimesh.Trimesh:
|
|
162
|
+
"""An open-top box: its silhouette is a solid block from every side."""
|
|
163
|
+
parts = [
|
|
164
|
+
trimesh.creation.box(
|
|
165
|
+
extents=(0.8, 0.8, 0.12),
|
|
166
|
+
transform=trimesh.transformations.translation_matrix((0, 0, 0.06)),
|
|
167
|
+
),
|
|
168
|
+
trimesh.creation.box(
|
|
169
|
+
extents=(0.12, 0.8, 1.0),
|
|
170
|
+
transform=trimesh.transformations.translation_matrix((0.34, 0, 0.5)),
|
|
171
|
+
),
|
|
172
|
+
trimesh.creation.box(
|
|
173
|
+
extents=(0.12, 0.8, 1.0),
|
|
174
|
+
transform=trimesh.transformations.translation_matrix((-0.34, 0, 0.5)),
|
|
175
|
+
),
|
|
176
|
+
trimesh.creation.box(
|
|
177
|
+
extents=(0.8, 0.12, 1.0),
|
|
178
|
+
transform=trimesh.transformations.translation_matrix((0, 0.34, 0.5)),
|
|
179
|
+
),
|
|
180
|
+
trimesh.creation.box(
|
|
181
|
+
extents=(0.8, 0.12, 1.0),
|
|
182
|
+
transform=trimesh.transformations.translation_matrix((0, -0.34, 0.5)),
|
|
183
|
+
),
|
|
184
|
+
]
|
|
185
|
+
return trimesh.util.concatenate(parts)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ray_boxes(origin: np.ndarray, dirs: np.ndarray, boxes: list[np.ndarray]) -> np.ndarray:
|
|
189
|
+
"""Distance along each ray to the nearest axis-aligned box (inf when missed)."""
|
|
190
|
+
best = np.full(len(dirs), np.inf)
|
|
191
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
192
|
+
inv = 1.0 / dirs
|
|
193
|
+
for lo, hi in boxes:
|
|
194
|
+
t1, t2 = (lo - origin) * inv, (hi - origin) * inv
|
|
195
|
+
near = np.nanmax(np.minimum(t1, t2), axis=1)
|
|
196
|
+
far = np.nanmin(np.maximum(t1, t2), axis=1)
|
|
197
|
+
hit = (near <= far) & (far > 0)
|
|
198
|
+
best = np.where(hit & (near < best), np.maximum(near, 0), best)
|
|
199
|
+
return best
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cup_scene(
|
|
203
|
+
folder: Path, size: int = 120, focal: float = 130.0
|
|
204
|
+
) -> tuple[Path, Path, list[tuple[np.ndarray, np.ndarray]]]:
|
|
205
|
+
"""Photos of the cup with known cameras, plus the true AI-like disparity of each."""
|
|
206
|
+
images, text = folder / "images", folder / "txt"
|
|
207
|
+
images.mkdir(parents=True)
|
|
208
|
+
text.mkdir()
|
|
209
|
+
boxes = [part.bounds for part in _cup().split(only_watertight=False)]
|
|
210
|
+
rng = np.random.default_rng(1)
|
|
211
|
+
jj, ii = np.meshgrid(np.arange(size), np.arange(size))
|
|
212
|
+
rays_cam = np.stack(
|
|
213
|
+
[(jj - size / 2) / focal, (ii - size / 2) / focal, np.ones_like(jj, float)], -1
|
|
214
|
+
).reshape(-1, 3)
|
|
215
|
+
lines: list[str] = []
|
|
216
|
+
depth_by_image: list[tuple[np.ndarray, np.ndarray]] = []
|
|
217
|
+
k = 0
|
|
218
|
+
for height, count in ((0.7, 10), (2.6, 10), (3.6, 6)):
|
|
219
|
+
for i in range(count):
|
|
220
|
+
a = 2 * np.pi * i / count
|
|
221
|
+
eye = np.array([2.6 * np.cos(a), 2.6 * np.sin(a), height])
|
|
222
|
+
rot, t = _look_at(eye, np.array([0.0, 0.0, 0.5]))
|
|
223
|
+
dirs = rays_cam @ rot # camera → world
|
|
224
|
+
dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
|
|
225
|
+
depth = _ray_boxes(eye, dirs, boxes) * (dirs @ rot[2])
|
|
226
|
+
depth = depth.reshape(size, size)
|
|
227
|
+
image = cv2.GaussianBlur(
|
|
228
|
+
rng.integers(0, 110, (size, size, 3), dtype=np.uint8), (3, 3), 0
|
|
229
|
+
)
|
|
230
|
+
image[np.isfinite(depth)] = (235, 235, 235)
|
|
231
|
+
name = f"img_{k:02d}.png"
|
|
232
|
+
cv2.imwrite(str(images / name), image)
|
|
233
|
+
far = float(np.nanmax(np.where(np.isfinite(depth), depth, np.nan))) * 2
|
|
234
|
+
disparity = 1.0 / np.where(np.isfinite(depth), depth, far)
|
|
235
|
+
depth_by_image.append((cv2.imread(str(images / name)).astype(np.float32), disparity))
|
|
236
|
+
q = _quat(rot)
|
|
237
|
+
lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
|
|
238
|
+
k += 1
|
|
239
|
+
(text / "cameras.txt").write_text(
|
|
240
|
+
f"1 PINHOLE {size} {size} {focal} {focal} {size / 2} {size / 2}\n"
|
|
241
|
+
)
|
|
242
|
+
(text / "images.txt").write_text("".join(lines))
|
|
243
|
+
table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
|
|
244
|
+
table = table[(np.abs(table[:, 0]) > 0.45) | (np.abs(table[:, 1]) > 0.45)]
|
|
245
|
+
rows = (f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
|
|
246
|
+
(text / "points3D.txt").write_text("".join(rows))
|
|
247
|
+
return images, text, depth_by_image
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_ai_depth_carves_the_inside_of_a_cup(tmp_path: Path) -> None:
|
|
251
|
+
images, text, truth = _cup_scene(tmp_path)
|
|
252
|
+
|
|
253
|
+
def estimator(rgb: np.ndarray) -> np.ndarray:
|
|
254
|
+
# The hull hands over its (resized) copy of a photo: find which one it is
|
|
255
|
+
# and return that photo's true disparity, like a perfect depth AI would.
|
|
256
|
+
small = cv2.resize(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), truth[0][0].shape[1::-1])
|
|
257
|
+
best = min(truth, key=lambda item: float(np.abs(item[0] - small).mean()))
|
|
258
|
+
return cv2.resize(best[1], (rgb.shape[1], rgb.shape[0]), interpolation=cv2.INTER_NEAREST)
|
|
259
|
+
|
|
260
|
+
plain = visual_hull(images, text, tmp_path / "plain.ply", resolution=64, refinements=1)
|
|
261
|
+
carved = visual_hull(
|
|
262
|
+
images, text, tmp_path / "carved.ply", resolution=64, refinements=1, depth=lambda: estimator
|
|
263
|
+
)
|
|
264
|
+
assert carved.depth_carved_voxels > 0
|
|
265
|
+
solid = trimesh.load(plain.mesh_path)
|
|
266
|
+
cup = trimesh.load(carved.mesh_path)
|
|
267
|
+
# The silhouettes alone fill the cup; the AI depth empties a good part of it.
|
|
268
|
+
assert cup.volume < 0.8 * solid.volume
|
|
269
|
+
assert cup.is_watertight
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_any_mesh_is_coloured_from_the_photos(tmp_path: Path) -> None:
|
|
273
|
+
from reverse3d.reconstruction.silhouette import colorize_from_photos
|
|
274
|
+
|
|
275
|
+
images, text = _scene(tmp_path)
|
|
276
|
+
mesh = trimesh.creation.cylinder(radius=RADIUS * 0.98, height=HEIGHT * 0.98, sections=48)
|
|
277
|
+
mesh = mesh.subdivide().subdivide() # vertices along the sides, not only at the caps
|
|
278
|
+
mesh.apply_translation([0, 0, HEIGHT / 2])
|
|
279
|
+
plain = tmp_path / "cylinder.ply"
|
|
280
|
+
mesh.export(plain)
|
|
281
|
+
out = colorize_from_photos(plain, images, text, tmp_path / "colour.ply")
|
|
282
|
+
colours = np.asarray(trimesh.load(out, process=False).visual.vertex_colors)[:, :3]
|
|
283
|
+
side = np.abs(mesh.vertices[:, 2] - HEIGHT / 2) < HEIGHT * 0.4
|
|
284
|
+
assert colours[side].mean() > 200 # the photographed cylinder is white
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.0"
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import cv2
|
|
6
|
-
import numpy as np
|
|
7
|
-
import pytest
|
|
8
|
-
import trimesh
|
|
9
|
-
|
|
10
|
-
pytest.importorskip("skimage")
|
|
11
|
-
|
|
12
|
-
from reverse3d.errors import ProcessingError
|
|
13
|
-
from reverse3d.reconstruction.silhouette import convergence_point, read_colmap_text, visual_hull
|
|
14
|
-
|
|
15
|
-
SIZE = 400
|
|
16
|
-
F = 420.0
|
|
17
|
-
RADIUS, HEIGHT = 0.35, 1.2 # the object: an upright cylinder standing on z = 0
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _look_at(eye: np.ndarray, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
21
|
-
"""World→camera rotation and translation (COLMAP convention: +Z forward, +Y down)."""
|
|
22
|
-
forward = target - eye
|
|
23
|
-
forward /= np.linalg.norm(forward)
|
|
24
|
-
right = np.cross(forward, [0.0, 0.0, 1.0])
|
|
25
|
-
right /= np.linalg.norm(right)
|
|
26
|
-
down = np.cross(forward, right)
|
|
27
|
-
rot = np.stack([right, down, forward])
|
|
28
|
-
return rot, -rot @ eye
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _quat(rot: np.ndarray) -> list[float]:
|
|
32
|
-
return list(
|
|
33
|
-
trimesh.transformations.quaternion_from_matrix(
|
|
34
|
-
np.vstack([np.hstack([rot, [[0], [0], [0]]]), [0, 0, 0, 1]])
|
|
35
|
-
)
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _scene(folder: Path) -> tuple[Path, Path]:
|
|
40
|
-
images = folder / "images"
|
|
41
|
-
text = folder / "txt"
|
|
42
|
-
images.mkdir(parents=True)
|
|
43
|
-
text.mkdir()
|
|
44
|
-
rng = np.random.default_rng(0)
|
|
45
|
-
theta = np.linspace(0, 2 * np.pi, 64, endpoint=False)
|
|
46
|
-
rim = np.concatenate(
|
|
47
|
-
[
|
|
48
|
-
np.stack([RADIUS * np.cos(theta), RADIUS * np.sin(theta), np.full_like(theta, z)], 1)
|
|
49
|
-
for z in (0.0, HEIGHT)
|
|
50
|
-
]
|
|
51
|
-
)
|
|
52
|
-
lines = []
|
|
53
|
-
k = 0
|
|
54
|
-
for height, count in ((0.6, 12), (2.2, 12), (3.4, 6)):
|
|
55
|
-
for i in range(count):
|
|
56
|
-
a = 2 * np.pi * i / count
|
|
57
|
-
eye = np.array([3.2 * np.cos(a), 3.2 * np.sin(a), height])
|
|
58
|
-
rot, t = _look_at(eye, np.array([0.0, 0.0, 0.6]))
|
|
59
|
-
# Textured background, then the white cylinder's silhouette.
|
|
60
|
-
image = rng.integers(0, 120, (SIZE, SIZE, 3), dtype=np.uint8)
|
|
61
|
-
image = cv2.GaussianBlur(image, (5, 5), 0)
|
|
62
|
-
cam = rim @ rot.T + t
|
|
63
|
-
uv = (cam[:, :2] / cam[:, 2:3]) * F + SIZE / 2
|
|
64
|
-
cv2.fillConvexPoly(image, cv2.convexHull(uv.astype(np.int32)), (235, 235, 235))
|
|
65
|
-
name = f"img_{k:02d}.jpg"
|
|
66
|
-
cv2.imwrite(str(images / name), image)
|
|
67
|
-
q = _quat(rot)
|
|
68
|
-
lines.append(f"{k + 1} {q[0]} {q[1]} {q[2]} {q[3]} {t[0]} {t[1]} {t[2]} 1 {name}\n\n")
|
|
69
|
-
k += 1
|
|
70
|
-
(text / "cameras.txt").write_text(f"1 PINHOLE {SIZE} {SIZE} {F} {F} {SIZE / 2} {SIZE / 2}\n")
|
|
71
|
-
(text / "images.txt").write_text("".join(lines))
|
|
72
|
-
table = np.column_stack([rng.uniform(-1, 1, 400), rng.uniform(-1, 1, 400), np.zeros(400)])
|
|
73
|
-
table = table[np.hypot(table[:, 0], table[:, 1]) > RADIUS + 0.05]
|
|
74
|
-
(text / "points3D.txt").write_text(
|
|
75
|
-
"".join(f"{i + 1} {x} {y} {z} 0 0 0 0\n" for i, (x, y, z) in enumerate(table))
|
|
76
|
-
)
|
|
77
|
-
return images, text
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def test_cameras_converge_on_the_object(tmp_path: Path) -> None:
|
|
81
|
-
_, text = _scene(tmp_path)
|
|
82
|
-
views, points = read_colmap_text(text)
|
|
83
|
-
center, distance = convergence_point(views)
|
|
84
|
-
assert len(views) == 30 and len(points) > 100
|
|
85
|
-
assert np.linalg.norm(center - [0, 0, 0.6]) < 0.05
|
|
86
|
-
assert 3.0 < distance < 3.8
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_visual_hull_rebuilds_an_upright_cylinder(tmp_path: Path) -> None:
|
|
90
|
-
images, text = _scene(tmp_path)
|
|
91
|
-
result = visual_hull(images, text, tmp_path / "hull.ply", resolution=80, refinements=1)
|
|
92
|
-
mesh = trimesh.load(result.mesh_path)
|
|
93
|
-
assert result.support_plane
|
|
94
|
-
assert mesh.is_watertight
|
|
95
|
-
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
96
|
-
assert mesh.bounds[0][2] == pytest.approx(0.0, abs=1e-6)
|
|
97
|
-
# Height / diameter of the real cylinder is 1.2 / 0.7 ≈ 1.7.
|
|
98
|
-
assert 1.3 < mesh.extents[2] / width < 2.2
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def test_too_few_photos(tmp_path: Path) -> None:
|
|
102
|
-
images, text = _scene(tmp_path)
|
|
103
|
-
rows = (text / "images.txt").read_text().split("\n\n")
|
|
104
|
-
(text / "images.txt").write_text("\n\n".join(rows[:5]) + "\n\n")
|
|
105
|
-
with pytest.raises(ProcessingError, match="at least"):
|
|
106
|
-
visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=0)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class _FakeSegmenter:
|
|
110
|
-
"""Stands in for Segment Anything: the object is the bright part inside the box."""
|
|
111
|
-
|
|
112
|
-
def __init__(self) -> None:
|
|
113
|
-
self.calls = 0
|
|
114
|
-
|
|
115
|
-
def embed(self, rgb: np.ndarray) -> dict[str, np.ndarray]:
|
|
116
|
-
return {"rgb": rgb}
|
|
117
|
-
|
|
118
|
-
def segment(
|
|
119
|
-
self, state: dict[str, np.ndarray], box: tuple[int, int, int, int], point: tuple[int, int]
|
|
120
|
-
) -> np.ndarray:
|
|
121
|
-
self.calls += 1
|
|
122
|
-
gray = state["rgb"].mean(axis=2)
|
|
123
|
-
mask = np.zeros(gray.shape, np.uint8)
|
|
124
|
-
x0, y0, x1, y1 = box
|
|
125
|
-
mask[y0 : y1 + 1, x0 : x1 + 1] = np.where(gray[y0 : y1 + 1, x0 : x1 + 1] > 200, 255, 0)
|
|
126
|
-
return mask
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def test_ai_segmenter_is_loaded_lazily_and_used(tmp_path: Path) -> None:
|
|
130
|
-
images, text = _scene(tmp_path)
|
|
131
|
-
fake = _FakeSegmenter()
|
|
132
|
-
loads: list[int] = []
|
|
133
|
-
|
|
134
|
-
def factory() -> _FakeSegmenter:
|
|
135
|
-
loads.append(1)
|
|
136
|
-
return fake
|
|
137
|
-
|
|
138
|
-
result = visual_hull(
|
|
139
|
-
images,
|
|
140
|
-
text,
|
|
141
|
-
tmp_path / "hull.ply",
|
|
142
|
-
resolution=64,
|
|
143
|
-
refinements=1,
|
|
144
|
-
segmenter=factory, # type: ignore[arg-type]
|
|
145
|
-
)
|
|
146
|
-
assert loads == [1] and fake.calls == 2 * 30 # two AI rounds over the 30 photos
|
|
147
|
-
assert result.ai_segmentation
|
|
148
|
-
mesh = trimesh.load(result.mesh_path)
|
|
149
|
-
width = (mesh.extents[0] + mesh.extents[1]) / 2
|
|
150
|
-
assert mesh.is_watertight and 1.3 < mesh.extents[2] / width < 2.2
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def test_without_ai_segmenter_grabcut_is_used(tmp_path: Path) -> None:
|
|
154
|
-
images, text = _scene(tmp_path)
|
|
155
|
-
result = visual_hull(
|
|
156
|
-
images, text, tmp_path / "hull.ply", resolution=48, refinements=1, segmenter=lambda: None
|
|
157
|
-
)
|
|
158
|
-
assert not result.ai_segmentation
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|