reverse3d 0.4.0__tar.gz → 0.5.1__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 (71) hide show
  1. {reverse3d-0.4.0/reverse3d.egg-info → reverse3d-0.5.1}/PKG-INFO +5 -2
  2. {reverse3d-0.4.0 → reverse3d-0.5.1}/README.md +3 -1
  3. {reverse3d-0.4.0 → reverse3d-0.5.1}/pyproject.toml +1 -0
  4. reverse3d-0.5.1/reverse3d/_version.py +1 -0
  5. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/autopilot.py +154 -9
  6. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/imaging.py +62 -1
  7. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/recipes.py +76 -4
  8. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reconstruction/depth_estimation.py +99 -1
  9. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reconstruction/photogrammetry.py +11 -0
  10. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/security.py +2 -0
  11. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/vision/feature_matcher.py +108 -31
  12. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/vision/mask_extractor.py +56 -0
  13. {reverse3d-0.4.0 → reverse3d-0.5.1/reverse3d.egg-info}/PKG-INFO +5 -2
  14. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d.egg-info/SOURCES.txt +1 -0
  15. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d.egg-info/requires.txt +1 -0
  16. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_automation.py +1 -1
  17. reverse3d-0.5.1/tests/test_heic.py +44 -0
  18. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_offline_autopilot.py +36 -6
  19. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_reconstruction.py +37 -2
  20. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_vision.py +9 -2
  21. reverse3d-0.4.0/reverse3d/_version.py +0 -1
  22. {reverse3d-0.4.0 → reverse3d-0.5.1}/LICENSE +0 -0
  23. {reverse3d-0.4.0 → reverse3d-0.5.1}/NOTICE +0 -0
  24. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/__init__.py +0 -0
  25. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/__main__.py +0 -0
  26. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/_about.py +0 -0
  27. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/__init__.py +0 -0
  28. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/compact.py +0 -0
  29. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/mcp_server.py +0 -0
  30. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/prompts.py +0 -0
  31. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/skill/SKILL.md +0 -0
  32. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/agent/tools.py +0 -0
  33. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/cli.py +0 -0
  34. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/config.py +0 -0
  35. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/doctor.py +0 -0
  36. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/errors.py +0 -0
  37. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/hardware.py +0 -0
  38. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/logging_utils.py +0 -0
  39. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/mesh_io.py +0 -0
  40. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/offline.py +0 -0
  41. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/profiles.py +0 -0
  42. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/py.typed +0 -0
  43. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reconstruction/__init__.py +0 -0
  44. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reconstruction/pointcloud.py +0 -0
  45. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/rendering/__init__.py +0 -0
  46. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/rendering/_blender_job.py +0 -0
  47. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/rendering/blender_render.py +0 -0
  48. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/rendering/preview.py +0 -0
  49. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reverse_eng/__init__.py +0 -0
  50. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  51. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reverse_eng/measure.py +0 -0
  52. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  53. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/reverse_eng/parametric.py +0 -0
  54. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/setup_agents.py +0 -0
  55. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/userconfig.py +0 -0
  56. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/vision/__init__.py +0 -0
  57. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d/watch.py +0 -0
  58. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.4.0 → reverse3d-0.5.1}/reverse3d.egg-info/top_level.txt +0 -0
  61. {reverse3d-0.4.0 → reverse3d-0.5.1}/setup.cfg +0 -0
  62. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_agent_tools.py +0 -0
  63. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_authorship.py +0 -0
  64. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_cli.py +0 -0
  65. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_cli_automation.py +0 -0
  66. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_config.py +0 -0
  67. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_geometry_fit.py +0 -0
  68. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_mesh.py +0 -0
  69. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_optional_extras.py +0 -0
  70. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_render.py +0 -0
  71. {reverse3d-0.4.0 → reverse3d-0.5.1}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.4.0
3
+ Version: 0.5.1
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>
@@ -33,6 +33,7 @@ Requires-Dist: numpy>=1.26
33
33
  Requires-Dist: scipy>=1.11
34
34
  Requires-Dist: opencv-python-headless>=4.9
35
35
  Requires-Dist: pillow>=10.3
36
+ Requires-Dist: pillow-heif>=1.1
36
37
  Requires-Dist: trimesh>=4.4
37
38
  Requires-Dist: shapely>=2.0
38
39
  Requires-Dist: mapbox-earcut>=1.0
@@ -422,7 +423,9 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
422
423
  | 🛰️ **Piloto automático** | Detección de la entrada, reintentos inteligentes, carpeta vigilada, `RESUMEN.md` | Reglas deterministas, sin IA |
423
424
  | 🔌 **Offline** | Detección de conexión, cambio automático, modelos fijados por revisión | `~/.reverse3d/models` |
424
425
 
425
- **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp` · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
426
+ **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp`, y fotos de iPhone `heic heif` (se convierten a JPEG conservando el EXIF) · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
427
+
428
+ **¿Cómo tomar las fotos?** Mínimo 20, recomendadas 40-60, en 4 perfiles (anillos a 10-20°, 35-45° y 60-70°, y 4 desde arriba), sin mover el objeto, con luz pareja y sin reflejos. Guía completa: [docs/captura.md](docs/captura.md).
426
429
 
427
430
  ---
428
431
 
@@ -356,7 +356,9 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
356
356
  | 🛰️ **Piloto automático** | Detección de la entrada, reintentos inteligentes, carpeta vigilada, `RESUMEN.md` | Reglas deterministas, sin IA |
357
357
  | 🔌 **Offline** | Detección de conexión, cambio automático, modelos fijados por revisión | `~/.reverse3d/models` |
358
358
 
359
- **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp` · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
359
+ **Formatos:** imágenes `png jpg jpeg tif tiff bmp webp`, y fotos de iPhone `heic heif` (se convierten a JPEG conservando el EXIF) · mallas `stl obj ply glb gltf off` · nubes `ply xyz pcd`.
360
+
361
+ **¿Cómo tomar las fotos?** Mínimo 20, recomendadas 40-60, en 4 perfiles (anillos a 10-20°, 35-45° y 60-70°, y 4 desde arriba), sin mover el objeto, con luz pareja y sin reflejos. Guía completa: [docs/captura.md](docs/captura.md).
360
362
 
361
363
  ---
362
364
 
@@ -41,6 +41,7 @@ dependencies = [
41
41
  "scipy>=1.11",
42
42
  "opencv-python-headless>=4.9",
43
43
  "pillow>=10.3",
44
+ "pillow-heif>=1.1",
44
45
  "trimesh>=4.4",
45
46
  "shapely>=2.0",
46
47
  "mapbox-earcut>=1.0",
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -8,8 +8,11 @@ rules, no AI model), so it works the same offline and on any machine:
8
8
  silhouette, a single photo, a mesh or a point cloud.
9
9
  2. It runs the matching recipe and retries only when another setting can fix
10
10
  the failure (another mask method, higher photogrammetry quality, stronger
11
- mesh repair). Problems that need the user (blurry photos, a missing tool)
12
- stop immediately with a clear hint.
11
+ mesh repair). Problems that need the user (a missing tool, an unreadable
12
+ input) stop immediately with a clear hint.
13
+ When a photo set cannot be reconstructed (photos that do not overlap,
14
+ shiny or changing surfaces...), it still delivers a model: an AI-depth
15
+ relief of the best photo, flagged as approximate, plus capture advice.
13
16
  3. It writes ``RESUMEN.md``: what was detected, sizes, files and next steps.
14
17
  """
15
18
 
@@ -32,8 +35,15 @@ from reverse3d.errors import (
32
35
  ProcessingError,
33
36
  Reverse3DError,
34
37
  )
38
+ from reverse3d.imaging import prepare_heic_input
35
39
  from reverse3d.recipes import MaskMethod, RecipeOptions, run_recipe
36
- from reverse3d.security import IMAGE_EXTENSIONS, MESH_EXTENSIONS, POINT_CLOUD_EXTENSIONS
40
+ from reverse3d.security import (
41
+ HEIC_EXTENSIONS,
42
+ IMAGE_EXTENSIONS,
43
+ MESH_EXTENSIONS,
44
+ POINT_CLOUD_EXTENSIONS,
45
+ list_images,
46
+ )
37
47
 
38
48
  logger = logging.getLogger(__name__)
39
49
 
@@ -58,6 +68,14 @@ KIND_LABEL_ES: dict[str, str] = {
58
68
  }
59
69
 
60
70
  _MIN_PHOTOS = 3
71
+
72
+ #: Capture guide in one sentence (docs/captura.md has the full guide; Studio shows it too).
73
+ CAPTURE_ADVICE_ES = (
74
+ "Para un modelo 3D completo: 40-60 fotos (mínimo 20) en 4 perfiles: anillo bajo a 10-20° "
75
+ "(16 fotos), medio a 35-45° (16), alto a 60-70° (12) y 4 desde arriba; con 60-80 % de "
76
+ "solape, sin mover el objeto, luz pareja y sin reflejos (luces RGB apagadas; en superficies "
77
+ "brillantes, spray mate o talco). Guía completa: docs/captura.md."
78
+ )
61
79
  _ANALYSIS_SIDE = 256
62
80
 
63
81
 
@@ -124,7 +142,7 @@ def detect_input(path: str | Path) -> Detection:
124
142
  src = Path(path).expanduser()
125
143
  if src.is_dir():
126
144
  files = [p for p in sorted(src.iterdir()) if p.is_file() and _visible(p)]
127
- images = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS]
145
+ images = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS | HEIC_EXTENSIONS]
128
146
  meshes = [p for p in files if p.suffix.lower() in MESH_EXTENSIONS | POINT_CLOUD_EXTENSIONS]
129
147
  if len(images) >= _MIN_PHOTOS:
130
148
  return Detection(
@@ -139,7 +157,7 @@ def detect_input(path: str | Path) -> Detection:
139
157
  "unknown",
140
158
  None,
141
159
  f"only {len(images)} photos: photogrammetry needs at least {_MIN_PHOTOS} "
142
- "(ideally 20-60)",
160
+ "(ideally 40-60)",
143
161
  str(src),
144
162
  )
145
163
  return Detection("unknown", None, "no photos, drawings or 3D files in the folder", str(src))
@@ -195,6 +213,112 @@ def _mesh_from_points(detection: Detection, out: Path, settings: Settings, overw
195
213
  return target
196
214
 
197
215
 
216
+ def _nearer_part(mask: np.ndarray, disparity: np.ndarray) -> np.ndarray:
217
+ """Keep the part of ``mask`` that stands out from the surface (the object,
218
+ not the desk, pad or light glow around it): Otsu split of its height."""
219
+ inside = mask > 0
220
+ if inside.sum() < 100 or (~inside).sum() < 100:
221
+ return mask
222
+ from reverse3d.reconstruction.depth_estimation import height_above_support
223
+
224
+ height = height_above_support(mask, disparity)
225
+ values = height[inside]
226
+ lo, hi = float(np.percentile(values, 1)), float(np.percentile(values, 99))
227
+ if hi - lo < 1e-6:
228
+ return mask
229
+ scaled = ((height - lo) / (hi - lo) * 255).clip(0, 255).astype(np.uint8)
230
+ threshold, _ = cv2.threshold(
231
+ scaled[inside].reshape(-1, 1), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
232
+ )
233
+ near: np.ndarray = np.where(inside & (scaled > threshold), 255, 0).astype(np.uint8)
234
+ near = cv2.morphologyEx(near, cv2.MORPH_OPEN, np.ones((7, 7), np.uint8))
235
+ count, labels, stats, _ = cv2.connectedComponentsWithStats(near)
236
+ if count <= 1:
237
+ return mask
238
+ largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
239
+ near = np.where(labels == largest, 255, 0).astype(np.uint8)
240
+ contours, _ = cv2.findContours(near, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
241
+ filled = np.zeros_like(near)
242
+ cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
243
+ # A tiny blob means the split failed (a flat object): keep the first mask.
244
+ return filled if filled.sum() >= 0.2 * inside.sum() * 255 else mask
245
+
246
+
247
+ def _best_photo(folder: Path, out: Path, settings: Settings) -> tuple[Path, Path | None]:
248
+ """Photo for the fallback relief, and a mask that isolates the object in it.
249
+
250
+ Every photo gets a quick GrabCut; the one where the object looks largest,
251
+ whole and sharp wins. Its mask is then refined at higher resolution and
252
+ trimmed with the AI depth so the desk and background stay out of the relief.
253
+ """
254
+ from reverse3d.hardware import torch_device
255
+ from reverse3d.imaging import downscale, load_image, save_image
256
+ from reverse3d.reconstruction.depth_estimation import predict_depth
257
+ from reverse3d.vision.mask_extractor import foreground_mask, mask_score
258
+
259
+ images = list_images(folder, max_images=settings.max_images, max_bytes=settings.max_input_bytes)
260
+ best: tuple[float, Path] | None = None
261
+ for path in images:
262
+ small, _ = downscale(load_image(path, settings), 320)
263
+ gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
264
+ sharp = min(float(cv2.Laplacian(gray, cv2.CV_64F).var()) / 300.0, 1.0)
265
+ try:
266
+ score = mask_score(foreground_mask(small, work_side=320)) * (0.5 + 0.5 * sharp)
267
+ except cv2.error:
268
+ score = 0.1 * sharp
269
+ if best is None or score > best[0]:
270
+ best = (score, path)
271
+ if best is None: # pragma: no cover - detect_input guarantees photos
272
+ raise ProcessingError("No photo available for the fallback relief.")
273
+ score, photo = best
274
+ if score < 0.15:
275
+ return photo, None # no clear object: keep the whole photo
276
+
277
+ full = load_image(photo, settings)
278
+ work, _ = downscale(full, 800)
279
+ mask = foreground_mask(work, work_side=800)
280
+ try:
281
+ rgb = cv2.cvtColor(work, cv2.COLOR_BGR2RGB)
282
+ mask = _nearer_part(
283
+ mask, predict_depth(rgb, device=torch_device(settings), settings=settings)
284
+ )
285
+ except Reverse3DError as exc: # depth model unavailable: the GrabCut mask still helps
286
+ logger.info("Depth refinement of the fallback mask skipped: %s", exc.message)
287
+ mask = cv2.erode(mask, np.ones((3, 3), np.uint8))
288
+ mask = cv2.resize(mask, (full.shape[1], full.shape[0]), interpolation=cv2.INTER_NEAREST)
289
+ return photo, save_image(mask, out / "fallback_mask.png", overwrite=True)
290
+
291
+
292
+ def _relief_fallback(
293
+ source: Path,
294
+ out: Path,
295
+ settings: Settings,
296
+ base: RecipeOptions,
297
+ reason: str,
298
+ history: list[dict[str, Any]],
299
+ ) -> dict[str, Any]:
300
+ """Photogrammetry was not possible: build an approximate model from the best photo."""
301
+ photo, mask = _best_photo(source, out, settings)
302
+ logger.info("Photogrammetry not possible; building an AI-depth relief from %s", photo.name)
303
+ summary = run_recipe(
304
+ "photo_to_relief",
305
+ photo,
306
+ out,
307
+ settings,
308
+ replace(base, overwrite=True, mask_path=str(mask) if mask else None),
309
+ )
310
+ if mask:
311
+ summary["outputs"]["mask"] = str(mask)
312
+ history.append({"attempt": f"fallback=photo_to_relief ({photo.name})", "ok": True})
313
+ summary["warnings"] = [
314
+ "A full 3D model could not be reconstructed from these photos "
315
+ f"({reason}). This is an approximate relief from the best photo, {photo.name}.",
316
+ *summary.get("warnings", []),
317
+ ]
318
+ summary["fallback"] = {"from": "photos_to_model", "photo": str(photo), "reason": reason}
319
+ return summary
320
+
321
+
198
322
  def auto_run(
199
323
  input_path: str | Path,
200
324
  output_dir: str | Path,
@@ -212,6 +336,8 @@ def auto_run(
212
336
  Reverse3DError: every attempt failed (the last error is raised).
213
337
  """
214
338
  base = options or RecipeOptions()
339
+ # iPhone photos (HEIC) are converted to JPEG first; other inputs pass through.
340
+ input_path = prepare_heic_input(input_path, output_dir)
215
341
  detection = detect_input(input_path)
216
342
  if detection.recipe is None:
217
343
  raise InputValidationError(
@@ -228,10 +354,17 @@ def auto_run(
228
354
  history: list[dict[str, Any]] = []
229
355
  summary: dict[str, Any] | None = None
230
356
  last_error: Reverse3DError | None = None
357
+ capture_advice: list[str] = []
231
358
  for attempt in _attempt_plan(detection, base):
232
359
  try:
233
360
  summary = run_recipe(detection.recipe, source, out, settings, attempt.options)
234
- except (CaptureQualityError, DependencyMissingError, InputValidationError) as exc:
361
+ except CaptureQualityError as exc:
362
+ history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
363
+ if detection.kind != "photos":
364
+ raise
365
+ last_error = exc
366
+ break # better settings will not fix the capture: go to the fallback
367
+ except (DependencyMissingError, InputValidationError) as exc:
235
368
  history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
236
369
  raise # the user has to act: retrying with other settings will not help
237
370
  except (ProcessingError, ExternalToolError) as exc:
@@ -261,11 +394,22 @@ def auto_run(
261
394
  summary = stronger
262
395
  break
263
396
 
397
+ if summary is None and detection.kind == "photos" and last_error is not None:
398
+ reason = last_error.message.split(":", 1)[0].rstrip(".")
399
+ capture_advice = [CAPTURE_ADVICE_ES]
400
+ try:
401
+ summary = _relief_fallback(source, out, settings, base, reason, history)
402
+ except Reverse3DError as exc:
403
+ logger.warning("Relief fallback failed: %s", exc.message)
404
+ history.append(
405
+ {"attempt": "fallback=photo_to_relief", "ok": False, "error": exc.message}
406
+ )
407
+ raise last_error from exc
264
408
  if summary is None:
265
409
  raise last_error or ProcessingError("No processing attempt could run.")
266
410
 
267
411
  summary["auto"] = {"detected": detection.to_dict(), "attempts": history}
268
- summary["next_steps"] = next_steps(detection, summary, base)
412
+ summary["next_steps"] = capture_advice + next_steps(detection, summary, base)
269
413
  summary["outputs"]["summary"] = str(write_summary(out, detection, summary))
270
414
  return summary
271
415
 
@@ -288,11 +432,12 @@ def next_steps(detection: Detection, summary: dict[str, Any], options: RecipeOpt
288
432
  "Las medidas están en píxeles: indica la escala con --units-per-pixel "
289
433
  "para tener medidas reales."
290
434
  )
291
- if detection.kind == "photo":
435
+ relief = detection.kind == "photo" or "fallback" in summary
436
+ if relief:
292
437
  steps.append(
293
438
  "La profundidad con IA es relativa: usa --scale si necesitas un tamaño concreto."
294
439
  )
295
- if detection.kind == "photo":
440
+ if relief:
296
441
  steps.append(
297
442
  "Un relieve es una superficie abierta: dale grosor en tu programa 3D "
298
443
  "si lo vas a imprimir."
@@ -8,6 +8,8 @@ paths work on Windows (``cv2.imread`` does not support them).
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
12
+ import shutil
11
13
  from pathlib import Path
12
14
 
13
15
  import cv2
@@ -16,7 +18,14 @@ from PIL import Image
16
18
 
17
19
  from reverse3d.config import Settings
18
20
  from reverse3d.errors import InputValidationError, ProcessingError
19
- from reverse3d.security import IMAGE_EXTENSIONS, prepare_output_file, validate_input_file
21
+ from reverse3d.security import (
22
+ HEIC_EXTENSIONS,
23
+ IMAGE_EXTENSIONS,
24
+ prepare_output_file,
25
+ validate_input_file,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
20
29
 
21
30
 
22
31
  def check_image_header(path: Path, max_pixels: int) -> tuple[int, int]:
@@ -77,3 +86,55 @@ def downscale(image: np.ndarray, max_dim: int) -> tuple[np.ndarray, float]:
77
86
  scale = max_dim / longest
78
87
  resized = cv2.resize(image, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA)
79
88
  return resized, scale
89
+
90
+
91
+ def _heic_to_jpeg(src: Path, dst: Path) -> Path:
92
+ """Decode one HEIC/HEIF photo and save it as JPEG, keeping its EXIF (focal length)."""
93
+ import pillow_heif
94
+
95
+ pillow_heif.register_heif_opener()
96
+ try:
97
+ with Image.open(src) as image:
98
+ exif = image.info.get("exif")
99
+ rgb = image.convert("RGB")
100
+ kwargs: dict[str, object] = {"quality": 95}
101
+ if exif:
102
+ kwargs["exif"] = exif
103
+ rgb.save(dst, "JPEG", **kwargs)
104
+ except Exception as exc: # pillow-heif raises several error types
105
+ raise InputValidationError(f"Could not read the HEIC photo {src.name}: {exc}") from exc
106
+ return dst
107
+
108
+
109
+ def prepare_heic_input(path: str | Path, work_dir: str | Path) -> Path:
110
+ """Return ``path`` ready for OpenCV and COLMAP, converting HEIC/HEIF photos to JPEG.
111
+
112
+ A HEIC file becomes ``work_dir/photos_jpg/<name>.jpg``; a folder with HEIC
113
+ photos becomes ``work_dir/photos_jpg`` holding the converted photos plus the
114
+ other images. Inputs without HEIC are returned unchanged.
115
+ """
116
+ src = Path(path).expanduser()
117
+ if src.is_file() and src.suffix.lower() not in HEIC_EXTENSIONS:
118
+ return src
119
+ if src.is_dir():
120
+ files = [p for p in sorted(src.iterdir()) if p.is_file() and not p.name.startswith(".")]
121
+ if not any(p.suffix.lower() in HEIC_EXTENSIONS for p in files):
122
+ return src
123
+ elif not src.is_file():
124
+ return src
125
+ target = Path(work_dir).expanduser() / "photos_jpg"
126
+ target.mkdir(parents=True, exist_ok=True)
127
+ if src.is_file():
128
+ return _heic_to_jpeg(src, target / f"{src.stem}.jpg")
129
+ converted = 0
130
+ for item in files:
131
+ ext = item.suffix.lower()
132
+ if ext in HEIC_EXTENSIONS:
133
+ out = target / f"{item.stem}.jpg"
134
+ if not out.exists():
135
+ _heic_to_jpeg(item, out)
136
+ converted += 1
137
+ elif ext in IMAGE_EXTENSIONS and not (target / item.name).exists():
138
+ shutil.copy2(item, target / item.name)
139
+ logger.info("Converted %d HEIC photo(s) to JPEG", converted)
140
+ return target
@@ -23,6 +23,8 @@ from __future__ import annotations
23
23
 
24
24
  import json
25
25
  import logging
26
+ import math
27
+ import shutil
26
28
  import time
27
29
  from collections.abc import Callable
28
30
  from dataclasses import dataclass, field
@@ -71,6 +73,8 @@ class RecipeOptions:
71
73
  overwrite: bool = False
72
74
  #: plan_to_part: how the drawing is separated from the background.
73
75
  mask_method: MaskMethod = "otsu"
76
+ #: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
77
+ mask_path: str | None = None
74
78
  #: mesh repair strength; "strong" closes much larger holes.
75
79
  repair: Literal["normal", "strong"] = "normal"
76
80
 
@@ -184,13 +188,33 @@ def _measure(run: _Run, mesh: Path) -> MeshStats:
184
188
  # ─── recipes ──────────────────────────────────────────────────────────────────
185
189
 
186
190
 
191
+ # Smallest overlapping group worth reconstructing when the rest of a set does not match.
192
+ _MIN_GROUP = 8
193
+
194
+
187
195
  def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
188
196
  from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
189
197
  from reverse3d.vision.feature_matcher import analyze_image_set
190
198
 
191
199
  analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
192
200
  run.details["analysis"] = analysis.to_dict()
193
- if not analysis.suitable_for_photogrammetry:
201
+ if not analysis.suitable_for_photogrammetry and analysis.connected_images >= _MIN_GROUP:
202
+ # Part of the set overlaps well: reconstruct from that group only.
203
+ dropped = set(analysis.weak_links)
204
+ group_dir = run.out / "photos_used"
205
+ group_dir.mkdir(parents=True, exist_ok=True)
206
+ for image in sorted(src.iterdir()):
207
+ if image.is_file() and image.name not in dropped and not image.name.startswith("."):
208
+ target = group_dir / image.name
209
+ if not target.exists():
210
+ shutil.copy2(image, target)
211
+ run.warnings.append(
212
+ f"Used the {analysis.connected_images} photos that overlap; skipped "
213
+ f"{len(dropped)} that do not match the rest."
214
+ )
215
+ run.details["photos_used"] = analysis.connected_images
216
+ src = group_dir
217
+ elif not analysis.suitable_for_photogrammetry:
194
218
  if not opt.force_analysis:
195
219
  raise CaptureQualityError(
196
220
  "The photo set is not suitable for photogrammetry: "
@@ -290,9 +314,24 @@ def _scan_to_cad(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
290
314
  return stats
291
315
 
292
316
 
317
+ # A relief mesh has one vertex per sampled pixel: keep phone photos (12+ MP) light.
318
+ _RELIEF_MAX_VERTICES = 500_000
319
+
320
+
321
+ def _relief_stride(src: Path, minimum: int) -> int:
322
+ """Pixel step so the relief stays under ``_RELIEF_MAX_VERTICES`` vertices."""
323
+ from PIL import Image
324
+
325
+ with Image.open(src) as image:
326
+ width, height = image.size
327
+ return max(minimum, math.ceil(math.sqrt(width * height / _RELIEF_MAX_VERTICES)))
328
+
329
+
293
330
  def _photo_to_relief(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
294
331
  from reverse3d.reconstruction.depth_estimation import estimate_depth
295
332
 
333
+ stride = _relief_stride(src, profile_values(run.settings).depth_stride)
334
+
296
335
  depth = run.step(
297
336
  "depth",
298
337
  lambda: estimate_depth(
@@ -300,15 +339,43 @@ def _photo_to_relief(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
300
339
  run.out / "depth",
301
340
  run.settings,
302
341
  device=torch_device(run.settings),
303
- stride=profile_values(run.settings).depth_stride,
342
+ stride=stride,
343
+ mask_path=opt.mask_path,
304
344
  overwrite=opt.overwrite,
305
345
  ),
306
346
  )
307
347
  run.details["depth"] = depth.to_dict()
348
+ model = run.out / "model.stl"
349
+ if opt.mask_path:
350
+ # The object is isolated: build a closed solid standing on its surface.
351
+ import numpy as np
352
+
353
+ from reverse3d.imaging import load_image
354
+ from reverse3d.mesh_io import save_mesh
355
+ from reverse3d.reconstruction.depth_estimation import object_solid
356
+
357
+ disparity = np.load(depth.depth_npy_path, allow_pickle=False)
358
+ mask = load_image(opt.mask_path, run.settings, flags=0)
359
+ solid = run.step("solid", lambda: object_solid(disparity, mask, stride=stride))
360
+ solid_path = save_mesh(solid, run.out / "depth" / "object_solid.ply", overwrite=True)
361
+ run.warnings.append(
362
+ "Single-photo model: the hidden side is flat and the height is estimated, "
363
+ "so proportions are approximate."
364
+ )
365
+ _clean(
366
+ run,
367
+ Path(solid_path),
368
+ model,
369
+ scale=opt.scale,
370
+ overwrite=opt.overwrite,
371
+ fill_holes=True,
372
+ align="obb",
373
+ keep_largest=True,
374
+ )
375
+ return _measure(run, model)
308
376
  run.warnings.append("Monocular depth is relative: shape is approximate, not metric.")
309
377
  if depth.mesh_path is None: # pragma: no cover - make_mesh is always on here
310
378
  raise ProcessingError("Depth estimation produced no mesh.")
311
- model = run.out / "model.stl"
312
379
  _clean(
313
380
  run,
314
381
  Path(depth.mesh_path),
@@ -366,7 +433,12 @@ def run_recipe(
366
433
  out = prepare_output_dir(output_dir)
367
434
  run = _Run(recipe=recipe, settings=settings, out=out)
368
435
  start = time.perf_counter()
369
- stats = _RECIPES[recipe](run, Path(input_path), opt)
436
+ source = Path(input_path)
437
+ if recipe != "scan_to_cad" and recipe != "mesh_report":
438
+ from reverse3d.imaging import prepare_heic_input
439
+
440
+ source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
441
+ stats = _RECIPES[recipe](run, source, opt)
370
442
  if opt.render:
371
443
  _render(run, Path(run.outputs["model"]), opt.overwrite)
372
444
 
@@ -29,7 +29,7 @@ import numpy as np
29
29
  import trimesh
30
30
 
31
31
  from reverse3d.config import Settings
32
- from reverse3d.errors import DependencyMissingError, InputValidationError
32
+ from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
33
33
  from reverse3d.imaging import load_image, save_image
34
34
  from reverse3d.mesh_io import save_mesh
35
35
  from reverse3d.security import prepare_output_dir, prepare_output_file
@@ -261,3 +261,101 @@ def estimate_depth(
261
261
  height=h,
262
262
  mesh_faces=faces,
263
263
  )
264
+
265
+
266
+ def height_above_support(mask: np.ndarray, disparity: np.ndarray) -> np.ndarray:
267
+ """Disparity minus the plane fitted to the surface around the object.
268
+
269
+ Photos look down at an angle, so the desk itself gets nearer towards the
270
+ bottom of the frame; removing that plane leaves how much each pixel stands
271
+ out from the surface the object rests on.
272
+ """
273
+ h, w = disparity.shape
274
+ ring = cv2.dilate(mask, np.ones((41, 41), np.uint8)) > 0
275
+ around = ring & (mask == 0)
276
+ if around.sum() < 200:
277
+ around = mask == 0
278
+ ys, xs = np.nonzero(around)
279
+ step = max(1, len(xs) // 20000)
280
+ xs, ys = xs[::step], ys[::step]
281
+ design = np.column_stack([xs, ys, np.ones_like(xs)]).astype(np.float64)
282
+ coeffs, *_ = np.linalg.lstsq(design, disparity[ys, xs].astype(np.float64), rcond=None)
283
+ grid_y, grid_x = np.mgrid[0:h, 0:w]
284
+ plane = coeffs[0] * grid_x + coeffs[1] * grid_y + coeffs[2]
285
+ return disparity - plane
286
+
287
+
288
+ def object_solid(
289
+ disparity: np.ndarray,
290
+ mask: np.ndarray,
291
+ *,
292
+ stride: int = 4,
293
+ height_ratio: float = 0.35,
294
+ smooth_px: int = 9,
295
+ ) -> trimesh.Trimesh:
296
+ """Closed, printable solid of the object in a photo: smoothed height above
297
+ the surface it rests on, a flat base and side walls.
298
+
299
+ ``height_ratio`` sets the tallest point relative to the object's longest
300
+ side (single-photo depth has no scale; 0.35 suits most hand-held objects).
301
+
302
+ Raises:
303
+ ProcessingError: the mask is empty or the object has no height.
304
+ """
305
+ inside = mask > 0
306
+ if inside.sum() < 100:
307
+ raise ProcessingError("The object mask is empty.")
308
+ k = smooth_px | 1
309
+ height = height_above_support(np.where(inside, 255, 0).astype(np.uint8), disparity)
310
+ weights = inside.astype(np.float32)
311
+ num = cv2.GaussianBlur(np.where(inside, height, 0).astype(np.float32), (k, k), 0)
312
+ den = cv2.GaussianBlur(weights, (k, k), 0)
313
+ height = np.where(inside, num / np.maximum(den, 1e-6), 0)
314
+ # Smooth outline: no jagged pixel stairs on the side walls.
315
+ outline = cv2.GaussianBlur(weights * 255, (2 * k + 1, 2 * k + 1), 0) > 127
316
+ h = height[::stride, ::stride]
317
+ keep = outline[::stride, ::stride]
318
+ if keep.sum() < 16:
319
+ raise ProcessingError("The object is too small in the photo.")
320
+ h = np.clip(h - np.percentile(h[keep], 2), 0, None)
321
+ peak = float(h[keep].max())
322
+ if peak <= 0:
323
+ raise ProcessingError("The object shows no height above its surface.")
324
+ ys, xs = np.nonzero(keep)
325
+ extent = float(max(np.ptp(xs), np.ptp(ys)) + 1)
326
+ z = h / peak * height_ratio * extent
327
+ # Round the outline: height eases to a low rim instead of a vertical cliff.
328
+ rim = cv2.distanceTransform(keep.astype(np.uint8), cv2.DIST_L2, 5)
329
+ z = z * (0.15 + 0.85 * np.sqrt(np.clip(rim / (0.08 * extent), 0.0, 1.0)))
330
+
331
+ index = -np.ones(keep.shape, dtype=np.int64)
332
+ index[keep] = np.arange(int(keep.sum()))
333
+ # A thin base slab keeps the top and bottom apart everywhere (closed solid).
334
+ base = 0.03 * extent
335
+ top = np.column_stack([xs, -ys, z[ys, xs] + base]).astype(np.float64)
336
+ bottom = top.copy()
337
+ bottom[:, 2] = 0.0
338
+ a, b = index[:-1, :-1], index[:-1, 1:]
339
+ c, d_ = index[1:, :-1], index[1:, 1:]
340
+ ok = (a >= 0) & (b >= 0) & (c >= 0) & (d_ >= 0)
341
+ a, b, c, d_ = a[ok], b[ok], c[ok], d_[ok]
342
+ top_faces = np.vstack([np.column_stack([a, c, b]), np.column_stack([b, c, d_])])
343
+ n = len(top)
344
+ bottom_faces = top_faces[:, ::-1] + n
345
+ edges = np.vstack([top_faces[:, [0, 1]], top_faces[:, [1, 2]], top_faces[:, [2, 0]]])
346
+ _, inverse, counts = np.unique(
347
+ np.sort(edges, axis=1), axis=0, return_inverse=True, return_counts=True
348
+ )
349
+ border = edges[counts[inverse.ravel()] == 1]
350
+ walls = np.vstack(
351
+ [
352
+ np.column_stack([border[:, 1], border[:, 0], border[:, 0] + n]),
353
+ np.column_stack([border[:, 1], border[:, 0] + n, border[:, 1] + n]),
354
+ ]
355
+ )
356
+ solid = trimesh.Trimesh(
357
+ np.vstack([top, bottom]), np.vstack([top_faces, bottom_faces, walls]), process=True
358
+ )
359
+ solid.remove_unreferenced_vertices()
360
+ trimesh.repair.fix_normals(solid)
361
+ return solid
@@ -59,6 +59,11 @@ _PRESETS: dict[str, dict[str, int]] = {
59
59
  "high": {"max_image_size": 3200, "max_features": 16384, "poisson_depth": 10},
60
60
  }
61
61
 
62
+ # Below these, the sparse model is too thin for a usable surface: stop before the
63
+ # (slow) dense stage so the caller can fall back to something better.
64
+ _MIN_REGISTERED = 5
65
+ _MIN_SPARSE_POINTS = 1000
66
+
62
67
  # Above this many images exhaustive matching (O(n²)) becomes impractical.
63
68
  _EXHAUSTIVE_LIMIT = 150
64
69
 
@@ -335,6 +340,12 @@ class ColmapPipeline:
335
340
  best, best_stats = model, stats
336
341
  result.sparse_model_dir = str(best)
337
342
  result.registered_images, result.sparse_points = best_stats
343
+ if result.registered_images < _MIN_REGISTERED or result.sparse_points < _MIN_SPARSE_POINTS:
344
+ raise ProcessingError(
345
+ f"COLMAP placed {result.registered_images}/{len(images)} photos with only "
346
+ f"{result.sparse_points} 3D points: too little for a reliable model.",
347
+ hint="More overlap, a still object, even light and matte surfaces are needed.",
348
+ )
338
349
  if result.registered_images and result.registered_images < len(images) * 0.5:
339
350
  result.warnings.append(
340
351
  f"Only {result.registered_images}/{len(images)} images were registered."
@@ -34,6 +34,8 @@ from reverse3d.errors import (
34
34
  logger = logging.getLogger(__name__)
35
35
 
36
36
  IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"})
37
+ #: iPhone / modern camera photos; converted to JPEG before processing.
38
+ HEIC_EXTENSIONS = frozenset({".heic", ".heif"})
37
39
  MESH_EXTENSIONS = frozenset({".stl", ".obj", ".ply", ".glb", ".gltf", ".off"})
38
40
  POINT_CLOUD_EXTENSIONS = frozenset({".ply", ".xyz", ".pcd"})
39
41
  JSON_EXTENSIONS = frozenset({".json"})