reverse3d 0.5.2__tar.gz → 0.6.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.
Files changed (75) hide show
  1. {reverse3d-0.5.2/reverse3d.egg-info → reverse3d-0.6.0}/PKG-INFO +2 -1
  2. {reverse3d-0.5.2 → reverse3d-0.6.0}/pyproject.toml +1 -0
  3. reverse3d-0.6.0/reverse3d/_version.py +1 -0
  4. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/recipes.py +53 -25
  5. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/photogrammetry.py +29 -1
  6. reverse3d-0.6.0/reverse3d/reconstruction/silhouette.py +336 -0
  7. {reverse3d-0.5.2 → reverse3d-0.6.0/reverse3d.egg-info}/PKG-INFO +2 -1
  8. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/SOURCES.txt +2 -0
  9. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/requires.txt +1 -0
  10. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_automation.py +5 -1
  11. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_reconstruction.py +4 -1
  12. reverse3d-0.6.0/tests/test_silhouette.py +106 -0
  13. reverse3d-0.5.2/reverse3d/_version.py +0 -1
  14. {reverse3d-0.5.2 → reverse3d-0.6.0}/LICENSE +0 -0
  15. {reverse3d-0.5.2 → reverse3d-0.6.0}/NOTICE +0 -0
  16. {reverse3d-0.5.2 → reverse3d-0.6.0}/README.md +0 -0
  17. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/__init__.py +0 -0
  18. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/__main__.py +0 -0
  19. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/_about.py +0 -0
  20. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/__init__.py +0 -0
  21. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/compact.py +0 -0
  22. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/mcp_server.py +0 -0
  23. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/prompts.py +0 -0
  24. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/skill/SKILL.md +0 -0
  25. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/agent/tools.py +0 -0
  26. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/autopilot.py +0 -0
  27. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/cli.py +0 -0
  28. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/config.py +0 -0
  29. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/doctor.py +0 -0
  30. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/errors.py +0 -0
  31. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/hardware.py +0 -0
  32. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/imaging.py +0 -0
  33. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/logging_utils.py +0 -0
  34. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/mesh_io.py +0 -0
  35. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/offline.py +0 -0
  36. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/profiles.py +0 -0
  37. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/py.typed +0 -0
  38. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/__init__.py +0 -0
  39. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  40. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  41. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/__init__.py +0 -0
  42. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/_blender_job.py +0 -0
  43. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/blender_render.py +0 -0
  44. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/rendering/preview.py +0 -0
  45. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/__init__.py +0 -0
  46. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  47. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/measure.py +0 -0
  48. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  49. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/reverse_eng/parametric.py +0 -0
  50. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/scenes.py +0 -0
  51. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/security.py +0 -0
  52. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/setup_agents.py +0 -0
  53. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/userconfig.py +0 -0
  54. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/__init__.py +0 -0
  55. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/feature_matcher.py +0 -0
  56. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/vision/mask_extractor.py +0 -0
  57. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d/watch.py +0 -0
  58. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.5.2 → reverse3d-0.6.0}/reverse3d.egg-info/top_level.txt +0 -0
  61. {reverse3d-0.5.2 → reverse3d-0.6.0}/setup.cfg +0 -0
  62. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_agent_tools.py +0 -0
  63. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_authorship.py +0 -0
  64. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_cli.py +0 -0
  65. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_cli_automation.py +0 -0
  66. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_config.py +0 -0
  67. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_geometry_fit.py +0 -0
  68. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_heic.py +0 -0
  69. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_mesh.py +0 -0
  70. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_offline_autopilot.py +0 -0
  71. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_optional_extras.py +0 -0
  72. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_render.py +0 -0
  73. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_scenes.py +0 -0
  74. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_security.py +0 -0
  75. {reverse3d-0.5.2 → reverse3d-0.6.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.5.2
3
+ Version: 0.6.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>
@@ -31,6 +31,7 @@ License-File: LICENSE
31
31
  License-File: NOTICE
32
32
  Requires-Dist: numpy>=1.26
33
33
  Requires-Dist: scipy>=1.11
34
+ Requires-Dist: scikit-image>=0.22
34
35
  Requires-Dist: opencv-python-headless>=4.9
35
36
  Requires-Dist: pillow>=10.3
36
37
  Requires-Dist: pillow-heif>=1.1
@@ -39,6 +39,7 @@ classifiers = [
39
39
  dependencies = [
40
40
  "numpy>=1.26",
41
41
  "scipy>=1.11",
42
+ "scikit-image>=0.22",
42
43
  "opencv-python-headless>=4.9",
43
44
  "pillow>=10.3",
44
45
  "pillow-heif>=1.1",
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -24,7 +24,6 @@ from __future__ import annotations
24
24
  import json
25
25
  import logging
26
26
  import math
27
- import shutil
28
27
  import time
29
28
  from collections.abc import Callable
30
29
  from dataclasses import dataclass, field
@@ -195,7 +194,11 @@ _MIN_GROUP = 8
195
194
 
196
195
 
197
196
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
198
- from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
197
+ from reverse3d.reconstruction.photogrammetry import (
198
+ PhotogrammetryOptions,
199
+ PhotogrammetryResult,
200
+ run_photogrammetry,
201
+ )
199
202
  from reverse3d.scenes import SCENE_LABEL_ES, SCENE_PRESETS, detect_scene
200
203
  from reverse3d.vision.feature_matcher import analyze_image_set
201
204
 
@@ -219,21 +222,9 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
219
222
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
220
223
  run.details["analysis"] = analysis.to_dict()
221
224
  if not analysis.suitable_for_photogrammetry and analysis.connected_images >= _MIN_GROUP:
222
- # Part of the set overlaps well: reconstruct from that group only.
223
- dropped = set(analysis.weak_links)
224
- group_dir = run.out / "photos_used"
225
- group_dir.mkdir(parents=True, exist_ok=True)
226
- for image in sorted(src.iterdir()):
227
- if image.is_file() and image.name not in dropped and not image.name.startswith("."):
228
- target = group_dir / image.name
229
- if not target.exists():
230
- shutil.copy2(image, target)
231
- run.warnings.append(
232
- f"Used the {analysis.connected_images} photos that overlap; skipped "
233
- f"{len(dropped)} that do not match the rest."
234
- )
235
- run.details["photos_used"] = analysis.connected_images
236
- src = group_dir
225
+ # COLMAP places cameras far better than this quick check (it often uses
226
+ # the whole room), so every photo goes in; the check is only advice.
227
+ run.warnings.extend(analysis.recommendations)
237
228
  elif not analysis.suitable_for_photogrammetry:
238
229
  if not opt.force_analysis:
239
230
  raise CaptureQualityError(
@@ -244,26 +235,62 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
244
235
  run.warnings.extend(analysis.recommendations)
245
236
 
246
237
  quality = opt.quality or preset.quality or profile_values(run.settings).photo_quality
247
- photo = run.step(
248
- "photogrammetry",
249
- lambda: run_photogrammetry(
238
+ # Objects and characters: COLMAP only places the cameras (fast, no dense stage)
239
+ # and the shape is carved from the silhouettes, which also works on plain,
240
+ # white or glossy surfaces. Open scenes keep classic dense reconstruction.
241
+ silhouette = scene in ("object", "character")
242
+
243
+ def photogrammetry(classic: bool) -> PhotogrammetryResult:
244
+ return run_photogrammetry(
250
245
  src,
251
246
  run.out / "reconstruction",
252
247
  run.settings,
253
248
  PhotogrammetryOptions(
254
249
  quality=quality, # type: ignore[arg-type]
255
250
  matcher=preset.matcher,
251
+ dense="auto" if classic else "off",
256
252
  use_gpu=wants_gpu(run.settings),
253
+ build_mesh=classic,
257
254
  poisson_depth=preset.poisson_depth,
258
255
  density_quantile=preset.density_quantile,
259
256
  ),
260
- overwrite=opt.overwrite,
261
- ),
262
- )
257
+ overwrite=opt.overwrite or not classic,
258
+ )
259
+
260
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=not silhouette))
261
+ mesh_path: str | None = photo.mesh_path
262
+ upright = False
263
+ if silhouette:
264
+ from reverse3d.reconstruction.silhouette import visual_hull
265
+
266
+ try:
267
+ hull = run.step(
268
+ "silhouette",
269
+ lambda: visual_hull(
270
+ Path(photo.images_dir or ""),
271
+ Path(photo.sparse_text_dir or ""),
272
+ run.out / "reconstruction" / "object_hull.ply",
273
+ ),
274
+ )
275
+ except ProcessingError as exc:
276
+ logger.info("Silhouette carving failed (%s); using dense reconstruction", exc.message)
277
+ run.warnings.append(
278
+ f"Silhouette carving failed ({exc.message}); used dense reconstruction."
279
+ )
280
+ photo = run.step("photogrammetry", lambda: photogrammetry(classic=True))
281
+ mesh_path = photo.mesh_path
282
+ else:
283
+ run.details["silhouette"] = hull.to_dict()
284
+ mesh_path = hull.mesh_path
285
+ upright = True
286
+ if photo.registered_images >= 0.9 * max(photo.total_images, 1):
287
+ # COLMAP placed (almost) every photo: the quick pre-check's capture advice
288
+ # would only confuse.
289
+ run.warnings[:] = [w for w in run.warnings if w not in analysis.recommendations]
263
290
  run.warnings.extend(photo.warnings)
264
291
  run.details["photogrammetry"] = photo.to_dict()
265
292
  run.outputs["point_cloud"] = photo.dense_points_path or photo.sparse_points_path
266
- if not photo.mesh_path:
293
+ if not mesh_path:
267
294
  raise ProcessingError(
268
295
  "Point cloud created but no mesh could be built.",
269
296
  hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
@@ -271,11 +298,12 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
271
298
  model = run.out / "model.stl"
272
299
  _clean(
273
300
  run,
274
- Path(photo.mesh_path),
301
+ Path(mesh_path),
275
302
  model,
276
303
  scale=opt.scale,
277
304
  overwrite=opt.overwrite,
278
305
  fill_holes=preset.fill_holes,
306
+ align="none" if upright else "obb",
279
307
  keep_largest=preset.keep_largest,
280
308
  repair=opt.repair,
281
309
  )
@@ -68,7 +68,15 @@ _MIN_SPARSE_POINTS = 1000
68
68
  _EXHAUSTIVE_LIMIT = 150
69
69
 
70
70
  # Names of the artifacts this pipeline creates; only these are removed on overwrite.
71
- _ARTIFACTS = ("database.db", "images", "sparse", "dense", "sparse_points.ply", "mesh.ply")
71
+ _ARTIFACTS = (
72
+ "database.db",
73
+ "images",
74
+ "sparse",
75
+ "sparse_txt",
76
+ "dense",
77
+ "sparse_points.ply",
78
+ "mesh.ply",
79
+ )
72
80
 
73
81
 
74
82
  @dataclass
@@ -122,6 +130,9 @@ class PhotogrammetryResult:
122
130
  sparse_points: int
123
131
  stages: list[StageTiming] = field(default_factory=list)
124
132
  warnings: list[str] = field(default_factory=list)
133
+ #: the photos as COLMAP saw them, and the chosen model as text (silhouette carving).
134
+ images_dir: str | None = None
135
+ sparse_text_dir: str | None = None
125
136
 
126
137
  def to_dict(self) -> dict[str, object]:
127
138
  return asdict(self)
@@ -394,6 +405,23 @@ class ColmapPipeline:
394
405
  ],
395
406
  )
396
407
  result.sparse_points_path = str(sparse_ply)
408
+ result.images_dir = str(staged)
409
+ text_dir = out / "sparse_txt"
410
+ text_dir.mkdir(parents=True, exist_ok=True)
411
+ self._stage(
412
+ result,
413
+ "model_converter_txt",
414
+ [
415
+ "model_converter",
416
+ "--input_path",
417
+ str(best),
418
+ "--output_path",
419
+ str(text_dir),
420
+ "--output_type",
421
+ "TXT",
422
+ ],
423
+ )
424
+ result.sparse_text_dir = str(text_dir)
397
425
 
398
426
  # 4. Dense reconstruction (MVS) — CUDA only
399
427
  run_dense = options.dense == "on" or (options.dense == "auto" and options.use_gpu)
@@ -0,0 +1,336 @@
1
+ """Shape from silhouettes (visual hull) for objects photographed all around.
2
+
3
+ Classic multi-view stereo needs texture on the object itself, so plain,
4
+ white, glossy or dark objects (a thermos, a bottle, a vase) come out as holes
5
+ or blobs, while the textured room around them reconstructs fine. A visual
6
+ hull does not need texture on the object: COLMAP only has to place the
7
+ cameras (the room helps with that), then the object is carved as the volume
8
+ that falls inside its silhouette in every photo.
9
+
10
+ Pipeline:
11
+
12
+ 1. read the COLMAP model (cameras, poses, sparse points) exported as text;
13
+ 2. the object is where the cameras' optical axes converge;
14
+ 3. per photo, segment the object (GrabCut around its projected position);
15
+ 4. carve a voxel grid; re-segment each photo guided by the projected hull
16
+ and carve again (a few rounds tighten the silhouettes);
17
+ 5. cut the support (table) plane, keep the main part, mesh it with marching
18
+ cubes, smooth it and stand it upright on its base.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import asdict, dataclass
25
+ from pathlib import Path
26
+
27
+ import cv2
28
+ import numpy as np
29
+ import trimesh
30
+
31
+ from reverse3d.errors import DependencyMissingError, ProcessingError
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _WORK_SIDE = 640
36
+ _MIN_VIEWS = 8
37
+
38
+
39
+ @dataclass
40
+ class _Camera:
41
+ width: int
42
+ height: int
43
+ fx: float
44
+ fy: float
45
+ cx: float
46
+ cy: float
47
+ k: float
48
+
49
+
50
+ @dataclass
51
+ class _View:
52
+ name: str
53
+ rotation: np.ndarray
54
+ translation: np.ndarray
55
+ camera: _Camera
56
+
57
+
58
+ @dataclass
59
+ class HullResult:
60
+ mesh_path: str
61
+ views: int
62
+ voxels: int
63
+ faces: int
64
+ support_plane: bool
65
+
66
+ def to_dict(self) -> dict[str, object]:
67
+ return asdict(self)
68
+
69
+
70
+ def _rotation(q: np.ndarray) -> np.ndarray:
71
+ w, x, y, z = q
72
+ return np.array(
73
+ [
74
+ [1 - 2 * y * y - 2 * z * z, 2 * x * y - 2 * w * z, 2 * x * z + 2 * w * y],
75
+ [2 * x * y + 2 * w * z, 1 - 2 * x * x - 2 * z * z, 2 * y * z - 2 * w * x],
76
+ [2 * x * z - 2 * w * y, 2 * y * z + 2 * w * x, 1 - 2 * x * x - 2 * y * y],
77
+ ]
78
+ )
79
+
80
+
81
+ def read_colmap_text(model_dir: Path) -> tuple[list[_View], np.ndarray]:
82
+ """Views (intrinsics + world→camera pose) and sparse points of a TXT model."""
83
+ cameras: dict[int, _Camera] = {}
84
+ for line in (model_dir / "cameras.txt").read_text(encoding="utf-8").splitlines():
85
+ if not line or line.startswith("#"):
86
+ continue
87
+ p = line.split()
88
+ model, params = p[1], [float(v) for v in p[4:]]
89
+ if model in ("SIMPLE_RADIAL", "SIMPLE_PINHOLE", "RADIAL"):
90
+ f, cx, cy = params[:3]
91
+ k = params[3] if model in ("SIMPLE_RADIAL", "RADIAL") else 0.0
92
+ cam = _Camera(int(p[2]), int(p[3]), f, f, cx, cy, k)
93
+ else: # PINHOLE, OPENCV...: fx fy cx cy [distortion ignored]
94
+ cam = _Camera(int(p[2]), int(p[3]), params[0], params[1], params[2], params[3], 0.0)
95
+ cameras[int(p[0])] = cam
96
+ # Each image takes two lines (header, then its 2D points, which may be empty).
97
+ rows = [
98
+ line
99
+ for line in (model_dir / "images.txt").read_text(encoding="utf-8").splitlines()
100
+ if not line.startswith("#")
101
+ ]
102
+ views = []
103
+ i = 0
104
+ while i < len(rows):
105
+ p = rows[i].split()
106
+ if len(p) < 10:
107
+ i += 1
108
+ continue
109
+ q = np.array([float(v) for v in p[1:5]])
110
+ t = np.array([float(v) for v in p[5:8]])
111
+ views.append(_View(" ".join(p[9:]), _rotation(q), t, cameras[int(p[8])]))
112
+ i += 2
113
+ points = []
114
+ for line in (model_dir / "points3D.txt").read_text(encoding="utf-8").splitlines():
115
+ if line and not line.startswith("#"):
116
+ points.append([float(v) for v in line.split()[1:4]])
117
+ return views, np.asarray(points, dtype=np.float64).reshape(-1, 3)
118
+
119
+
120
+ def _project(points: np.ndarray, view: _View) -> tuple[np.ndarray, np.ndarray]:
121
+ cam = view.camera
122
+ xc = points @ view.rotation.T + view.translation
123
+ z = xc[:, 2]
124
+ safe = np.where(np.abs(z) < 1e-9, 1e-9, z)
125
+ x, y = xc[:, 0] / safe, xc[:, 1] / safe
126
+ s = 1 + cam.k * (x * x + y * y)
127
+ return np.stack([cam.fx * x * s + cam.cx, cam.fy * y * s + cam.cy], axis=1), z
128
+
129
+
130
+ def convergence_point(views: list[_View]) -> tuple[np.ndarray, float]:
131
+ """Point closest to every camera's optical axis, and the median camera distance."""
132
+ a = np.zeros((3, 3))
133
+ b = np.zeros(3)
134
+ centers = []
135
+ for view in views:
136
+ c = -view.rotation.T @ view.translation
137
+ d = view.rotation.T @ np.array([0.0, 0.0, 1.0])
138
+ d /= np.linalg.norm(d)
139
+ m = np.eye(3) - np.outer(d, d)
140
+ a += m
141
+ b += m @ c
142
+ centers.append(c)
143
+ point = np.linalg.solve(a, b)
144
+ return point, float(np.median(np.linalg.norm(np.asarray(centers) - point, axis=1)))
145
+
146
+
147
+ def _grabcut(
148
+ image: np.ndarray, init: np.ndarray, rect: tuple[int, int, int, int] | None
149
+ ) -> np.ndarray:
150
+ bgd, fgd = np.zeros((1, 65)), np.zeros((1, 65))
151
+ mode = cv2.GC_INIT_WITH_RECT if rect is not None else cv2.GC_INIT_WITH_MASK
152
+ try:
153
+ cv2.grabCut(image, init, rect or (0, 0, 0, 0), bgd, fgd, 4, mode)
154
+ except cv2.error:
155
+ return np.zeros(image.shape[:2], np.uint8)
156
+ return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
157
+
158
+
159
+ def _support_plane(
160
+ points: np.ndarray, center: np.ndarray, radius: float, cameras: np.ndarray
161
+ ) -> tuple[np.ndarray, float] | None:
162
+ """The table under the object: RANSAC plane through nearby sparse points, normal up."""
163
+ near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
164
+ if len(near) < 30:
165
+ return None
166
+ rng = np.random.default_rng(0)
167
+ best: tuple[np.ndarray, float, int] | None = None
168
+ for _ in range(400):
169
+ s = near[rng.choice(len(near), 3, replace=False)]
170
+ n = np.cross(s[1] - s[0], s[2] - s[0])
171
+ norm = np.linalg.norm(n)
172
+ if norm < 1e-12:
173
+ continue
174
+ n /= norm
175
+ d = -float(n @ s[0])
176
+ count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
177
+ if best is None or count > best[2]:
178
+ best = (n, d, count)
179
+ if best is None or best[2] < 20:
180
+ return None
181
+ n, d, _ = best
182
+ if np.median(cameras @ n + d) < 0: # cameras are above the table
183
+ n, d = -n, -d
184
+ return n, d
185
+
186
+
187
+ def visual_hull(
188
+ image_dir: Path,
189
+ model_text_dir: Path,
190
+ output_path: Path,
191
+ *,
192
+ resolution: int = 176,
193
+ refinements: int = 3,
194
+ ) -> HullResult:
195
+ """Carve the object seen by every photo; writes a watertight, upright mesh.
196
+
197
+ Raises:
198
+ ProcessingError: too few placed photos, or no object could be isolated.
199
+ """
200
+ try:
201
+ from scipy import ndimage
202
+ from skimage.measure import marching_cubes
203
+ except ImportError as exc: # pragma: no cover - core dependencies
204
+ raise DependencyMissingError(
205
+ "scikit-image and scipy are needed for silhouette reconstruction.",
206
+ hint="pip install scikit-image scipy",
207
+ ) from exc
208
+
209
+ views, points = read_colmap_text(model_text_dir)
210
+ if len(views) < _MIN_VIEWS:
211
+ raise ProcessingError(
212
+ f"Only {len(views)} photos were placed; at least {_MIN_VIEWS} are needed."
213
+ )
214
+ center, distance = convergence_point(views)
215
+ radius = 0.45 * distance
216
+ cameras = np.asarray([-v.rotation.T @ v.translation for v in views])
217
+
218
+ images: list[tuple[np.ndarray, float]] = []
219
+ for view in views:
220
+ bgr = cv2.imread(str(image_dir / view.name))
221
+ if bgr is None:
222
+ raise ProcessingError(f"Could not read {view.name} for silhouette carving.")
223
+ h, w = bgr.shape[:2]
224
+ scale = _WORK_SIDE / max(h, w)
225
+ images.append(
226
+ (
227
+ cv2.resize(bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA),
228
+ scale,
229
+ )
230
+ )
231
+
232
+ # First silhouettes: GrabCut in a box around where the object projects.
233
+ masks: list[np.ndarray] = []
234
+ for (small, scale), view in zip(images, views, strict=True):
235
+ uv, z = _project(center[None], view)
236
+ if z[0] <= 0:
237
+ masks.append(np.zeros(small.shape[:2], np.uint8))
238
+ continue
239
+ cu, cv_ = uv[0] * scale
240
+ half = view.camera.fx * scale * 0.6 * radius / z[0]
241
+ x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
242
+ x1 = min(small.shape[1] - 1, int(cu + half))
243
+ y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
244
+ init = np.zeros(small.shape[:2], np.uint8)
245
+ rect = (x0, y0, max(2, x1 - x0), max(2, y1 - y0))
246
+ fg = _grabcut(small, init, rect)
247
+ masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
248
+
249
+ n = resolution
250
+ axis = np.linspace(-radius, radius, n)
251
+ gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
252
+ grid = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1) + center
253
+ projections = []
254
+ for (small, scale), view in zip(images, views, strict=True):
255
+ uv, z = _project(grid, view)
256
+ u = np.round(uv[:, 0] * scale).astype(np.int64)
257
+ v = np.round(uv[:, 1] * scale).astype(np.int64)
258
+ inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
259
+ projections.append((u, v, inside))
260
+
261
+ def carve(mask_list: list[np.ndarray], tolerance: int) -> np.ndarray:
262
+ votes = np.zeros(len(grid), np.int32)
263
+ seen = np.zeros(len(grid), np.int32)
264
+ for fg, (u, v, inside) in zip(mask_list, projections, strict=True):
265
+ seen += inside
266
+ hit = np.zeros(len(grid), bool)
267
+ hit[inside] = fg[v[inside], u[inside]] > 0
268
+ votes += hit
269
+ return (seen >= 3) & (votes >= seen - tolerance)
270
+
271
+ keep = carve(masks, 2)
272
+ for _ in range(refinements):
273
+ refined: list[np.ndarray] = []
274
+ for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
275
+ hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
276
+ sel = keep & inside
277
+ hull[v[sel], u[sel]] = 255
278
+ hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
279
+ if not hull.any():
280
+ refined.append(old)
281
+ continue
282
+ init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
283
+ init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
284
+ init[hull > 0] = cv2.GC_PR_FGD
285
+ init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
286
+ fg = _grabcut(small, init, None)
287
+ refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
288
+ masks = refined
289
+ keep = carve(masks, 1)
290
+
291
+ volume = keep.reshape(n, n, n).astype(np.float32)
292
+ plane = _support_plane(points, center, radius, cameras)
293
+ if plane is not None:
294
+ normal, offset = plane
295
+ height = (grid @ normal + offset).reshape(n, n, n)
296
+ volume[height < 0.015 * radius] = 0
297
+ labels, count = ndimage.label(volume > 0.5)
298
+ if count == 0:
299
+ raise ProcessingError("No object could be isolated in the photos.")
300
+ sizes = ndimage.sum(np.ones_like(labels), labels, range(1, count + 1))
301
+ volume = (labels == 1 + int(np.argmax(sizes))).astype(np.float32)
302
+ voxels = int(volume.sum())
303
+ if voxels < 500:
304
+ raise ProcessingError("The isolated object is too small to build a model.")
305
+ touches_border = bool(
306
+ volume[0].any()
307
+ or volume[-1].any()
308
+ or volume[:, 0].any()
309
+ or volume[:, -1].any()
310
+ or volume[:, :, 0].any()
311
+ or volume[:, :, -1].any()
312
+ )
313
+ if touches_border:
314
+ logger.info("The object reaches the edge of the carving volume; it may be cropped.")
315
+ # One empty voxel of margin all around: marching cubes then yields a closed surface.
316
+ volume = np.pad(ndimage.gaussian_filter(volume, 1.4), 2)
317
+ verts, faces, _, _ = marching_cubes(volume, 0.5)
318
+ verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
319
+ mesh = trimesh.Trimesh(verts, faces, process=True)
320
+ trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
321
+
322
+ # Stand it upright: +Z along the table normal (or the mean camera "up").
323
+ up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
324
+ up = up / np.linalg.norm(up)
325
+ side = np.cross([0.0, 1.0, 0.0] if abs(up[1]) < 0.9 else [1.0, 0.0, 0.0], up)
326
+ side /= np.linalg.norm(side)
327
+ basis = np.stack([side, np.cross(up, side), up])
328
+ transform = np.eye(4)
329
+ transform[:3, :3] = basis
330
+ mesh.apply_transform(transform)
331
+ mesh.apply_translation([-mesh.centroid[0], -mesh.centroid[1], -mesh.bounds[0][2]])
332
+
333
+ output_path.parent.mkdir(parents=True, exist_ok=True)
334
+ mesh.export(output_path)
335
+ logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
336
+ return HullResult(str(output_path), len(views), voxels, len(mesh.faces), plane is not None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.5.2
3
+ Version: 0.6.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>
@@ -31,6 +31,7 @@ License-File: LICENSE
31
31
  License-File: NOTICE
32
32
  Requires-Dist: numpy>=1.26
33
33
  Requires-Dist: scipy>=1.11
34
+ Requires-Dist: scikit-image>=0.22
34
35
  Requires-Dist: opencv-python-headless>=4.9
35
36
  Requires-Dist: pillow>=10.3
36
37
  Requires-Dist: pillow-heif>=1.1
@@ -40,6 +40,7 @@ reverse3d/reconstruction/__init__.py
40
40
  reverse3d/reconstruction/depth_estimation.py
41
41
  reverse3d/reconstruction/photogrammetry.py
42
42
  reverse3d/reconstruction/pointcloud.py
43
+ reverse3d/reconstruction/silhouette.py
43
44
  reverse3d/rendering/__init__.py
44
45
  reverse3d/rendering/_blender_job.py
45
46
  reverse3d/rendering/blender_render.py
@@ -67,4 +68,5 @@ tests/test_reconstruction.py
67
68
  tests/test_render.py
68
69
  tests/test_scenes.py
69
70
  tests/test_security.py
71
+ tests/test_silhouette.py
70
72
  tests/test_vision.py
@@ -1,5 +1,6 @@
1
1
  numpy>=1.26
2
2
  scipy>=1.11
3
+ scikit-image>=0.22
3
4
  opencv-python-headless>=4.9
4
5
  pillow>=10.3
5
6
  pillow-heif>=1.1
@@ -442,7 +442,11 @@ def test_colmap_without_cuda_degrades_to_cpu(
442
442
  if args[0] == "mapper":
443
443
  (Path(args[args.index("--output_path") + 1]) / "0").mkdir()
444
444
  if args[0] == "model_converter":
445
- Path(args[args.index("--output_path") + 1]).write_text("ply")
445
+ target = Path(args[args.index("--output_path") + 1])
446
+ if args[args.index("--output_type") + 1] == "TXT":
447
+ target.mkdir(parents=True, exist_ok=True)
448
+ else:
449
+ target.write_text("ply")
446
450
  return subprocess.CompletedProcess(args, 0, "", "Registered images: 6\nPoints: 6000")
447
451
 
448
452
  monkeypatch.setattr(pg, "run_command", fake)
@@ -82,7 +82,10 @@ class FakeColmap:
82
82
  args, 0, "", f"Registered images: {n}\nPoints: {n * self.points_per_image}"
83
83
  )
84
84
  elif command == "model_converter":
85
- Path(opt("--output_path")).write_text("ply\n")
85
+ if opt("--output_type") == "TXT":
86
+ Path(opt("--output_path")).mkdir(parents=True, exist_ok=True)
87
+ else:
88
+ Path(opt("--output_path")).write_text("ply\n")
86
89
  return subprocess.CompletedProcess(args, 0, "", "")
87
90
 
88
91
 
@@ -0,0 +1,106 @@
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)
@@ -1 +0,0 @@
1
- __version__ = "0.5.2"
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