reverse3d 0.12.0__tar.gz → 0.13.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 (80) hide show
  1. {reverse3d-0.12.0/reverse3d.egg-info → reverse3d-0.13.0}/PKG-INFO +2 -1
  2. {reverse3d-0.12.0 → reverse3d-0.13.0}/README.md +1 -0
  3. reverse3d-0.13.0/reverse3d/_version.py +1 -0
  4. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/recipes.py +37 -0
  5. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/silhouette.py +33 -0
  6. {reverse3d-0.12.0 → reverse3d-0.13.0/reverse3d.egg-info}/PKG-INFO +2 -1
  7. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_silhouette.py +46 -0
  8. reverse3d-0.12.0/reverse3d/_version.py +0 -1
  9. {reverse3d-0.12.0 → reverse3d-0.13.0}/LICENSE +0 -0
  10. {reverse3d-0.12.0 → reverse3d-0.13.0}/NOTICE +0 -0
  11. {reverse3d-0.12.0 → reverse3d-0.13.0}/pyproject.toml +0 -0
  12. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/__init__.py +0 -0
  13. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/__main__.py +0 -0
  14. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/_about.py +0 -0
  15. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/__init__.py +0 -0
  16. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/compact.py +0 -0
  17. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/mcp_server.py +0 -0
  18. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/prompts.py +0 -0
  19. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/skill/SKILL.md +0 -0
  20. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/agent/tools.py +0 -0
  21. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/autopilot.py +0 -0
  22. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/cli.py +0 -0
  23. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/config.py +0 -0
  24. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/doctor.py +0 -0
  25. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/errors.py +0 -0
  26. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/hardware.py +0 -0
  27. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/imaging.py +0 -0
  28. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/logging_utils.py +0 -0
  29. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/mesh_io.py +0 -0
  30. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/offline.py +0 -0
  31. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/profiles.py +0 -0
  32. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/py.typed +0 -0
  33. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/__init__.py +0 -0
  34. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/depth_estimation.py +0 -0
  35. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/photogrammetry.py +0 -0
  36. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/pointcloud.py +0 -0
  37. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reconstruction/texture.py +0 -0
  38. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/rendering/__init__.py +0 -0
  39. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/rendering/_blender_job.py +0 -0
  40. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/rendering/blender_render.py +0 -0
  41. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/rendering/preview.py +0 -0
  42. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reverse_eng/__init__.py +0 -0
  43. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  44. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reverse_eng/measure.py +0 -0
  45. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  46. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/reverse_eng/parametric.py +0 -0
  47. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/scenes.py +0 -0
  48. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/security.py +0 -0
  49. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/setup_agents.py +0 -0
  50. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/userconfig.py +0 -0
  51. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/vision/__init__.py +0 -0
  52. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/vision/classifier.py +0 -0
  53. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/vision/feature_matcher.py +0 -0
  54. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/vision/mask_extractor.py +0 -0
  55. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/vision/segmenter.py +0 -0
  56. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d/watch.py +0 -0
  57. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d.egg-info/SOURCES.txt +0 -0
  58. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d.egg-info/requires.txt +0 -0
  61. {reverse3d-0.12.0 → reverse3d-0.13.0}/reverse3d.egg-info/top_level.txt +0 -0
  62. {reverse3d-0.12.0 → reverse3d-0.13.0}/setup.cfg +0 -0
  63. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_agent_tools.py +0 -0
  64. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_authorship.py +0 -0
  65. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_automation.py +0 -0
  66. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_cli.py +0 -0
  67. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_color.py +0 -0
  69. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_config.py +0 -0
  70. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_heic.py +0 -0
  72. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_render.py +0 -0
  77. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_security.py +0 -0
  79. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_texture.py +0 -0
  80. {reverse3d-0.12.0 → reverse3d-0.13.0}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.12.0
3
+ Version: 0.13.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>
@@ -422,6 +422,7 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
422
422
  | Recortes perfectos | 0,943 | 0,941 |
423
423
  | 15 % de recortes defectuosos | 0,000 (pierde el objeto) | **0,921** |
424
424
  | Concavidad invisible a las siluetas (error en la ranura) | 0,154 | **0,091** (refinamiento por fotos, Core 0.3) |
425
+ | Mapa de confianza y consejos de captura | — | ✅ Core 0.4 (`model_quality.glb`) |
425
426
 
426
427
  *(Coincidencia volumétrica, IoU: 1 = idéntico.)*
427
428
 
@@ -353,6 +353,7 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
353
353
  | Recortes perfectos | 0,943 | 0,941 |
354
354
  | 15 % de recortes defectuosos | 0,000 (pierde el objeto) | **0,921** |
355
355
  | Concavidad invisible a las siluetas (error en la ranura) | 0,154 | **0,091** (refinamiento por fotos, Core 0.3) |
356
+ | Mapa de confianza y consejos de captura | — | ✅ Core 0.4 (`model_quality.glb`) |
356
357
 
357
358
  *(Coincidencia volumétrica, IoU: 1 = idéntico.)*
358
359
 
@@ -0,0 +1 @@
1
+ __version__ = "0.13.0"
@@ -177,6 +177,42 @@ _CAMERA_FEATURES = 4096
177
177
  _OBJECT_EXHAUSTIVE_LIMIT = 400
178
178
 
179
179
 
180
+ _WHERE = {
181
+ "top": "from above",
182
+ "around": "around the object at mid height",
183
+ "low": "from a low angle",
184
+ }
185
+
186
+
187
+ def _quality_outputs(run: _Run, hull: Any) -> None:
188
+ """Confidence map (model_quality.glb) and capture advice from reverse3d-core."""
189
+ quality = getattr(hull, "quality", None) or {}
190
+ if hull.quality_path:
191
+ try:
192
+ import trimesh
193
+
194
+ glb = run.out / "model_quality.glb"
195
+ _export_glb(trimesh.load(hull.quality_path, process=False, force="mesh"), glb)
196
+ run.outputs["model_quality"] = str(glb)
197
+ except Exception as exc: # the map is a bonus: never fail the model for it
198
+ logger.info("Confidence map skipped: %s", exc)
199
+ covered = quality.get("covered")
200
+ if covered is None or covered >= _WELL_COVERED:
201
+ return
202
+ for advice in quality.get("advice", [])[:2]:
203
+ where = _WHERE.get(str(advice.get("elevation")), "around the object")
204
+ photo = Path(str(advice.get("near_photo", ""))).stem
205
+ share = round(100 * float(advice.get("share", 0.0)))
206
+ run.warnings.append(
207
+ f"{share}% of the model is weakly supported by the photos: take more photos "
208
+ f"{where}, near where you took {photo}."
209
+ )
210
+
211
+
212
+ # Share of the surface the photos must support well for no capture advice.
213
+ _WELL_COVERED = 0.95
214
+
215
+
180
216
  def _export_glb(mesh: Any, path: Path) -> None:
181
217
  """Write a GLB that stands up in any viewer: glTF is Y-up, our models are Z-up.
182
218
 
@@ -442,6 +478,7 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
442
478
  mesh_path = photo.mesh_path
443
479
  else:
444
480
  run.details["silhouette"] = hull.to_dict()
481
+ _quality_outputs(run, hull)
445
482
  if hull.unreliable:
446
483
  names = ", ".join(Path(n).stem for n in hull.unreliable[:5])
447
484
  more = "…" if len(hull.unreliable) > 5 else ""
@@ -94,6 +94,10 @@ class HullResult:
94
94
  unreliable: list[str] = field(default_factory=list)
95
95
  #: vertices reverse3d-core moved by photo consistency (0 without it)
96
96
  refined_vertices: int = 0
97
+ #: reverse3d-core quality agent: share of the surface well supported and advice
98
+ quality: dict[str, object] = field(default_factory=dict)
99
+ #: confidence map (red -> green vertex colours), same coordinates as the model
100
+ quality_path: str | None = None
97
101
 
98
102
  def to_dict(self) -> dict[str, object]:
99
103
  return asdict(self)
@@ -369,6 +373,12 @@ def _complete_base(
369
373
  return out.reshape(volume.shape)
370
374
 
371
375
 
376
+ def _outward_share(mesh: trimesh.Trimesh) -> float:
377
+ """Share of vertex normals pointing away from the centroid (about 1 when outward)."""
378
+ rel = np.asarray(mesh.vertices) - np.asarray(mesh.centroid)
379
+ return float(np.mean((rel * np.asarray(mesh.vertex_normals)).sum(axis=1) > 0))
380
+
381
+
372
382
  def _coarse_grabcut(image: np.ndarray, rect: tuple[int, int, int, int]) -> np.ndarray:
373
383
  """GrabCut at half resolution (4x fewer pixels): enough for a first silhouette."""
374
384
  h, w = image.shape[:2]
@@ -965,6 +975,10 @@ def visual_hull(
965
975
  mesh = trimesh.Trimesh(verts, faces, process=True)
966
976
  trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
967
977
  mesh.fix_normals()
978
+ # fix_normals cannot tell outside from inside on a mesh that is not perfectly
979
+ # closed: make the normals point away from the object's centre.
980
+ if _outward_share(mesh) < 0.5:
981
+ mesh.invert()
968
982
  refined_vertices = 0
969
983
  refine_mesh = getattr(core, "refine_mesh", None) if core_cameras is not None else None
970
984
  if refine_mesh is not None:
@@ -985,6 +999,17 @@ def visual_hull(
985
999
  # Stand it upright: +Z along the table normal (or the mean camera "up").
986
1000
  up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
987
1001
  up = up / np.linalg.norm(up)
1002
+ quality: dict[str, object] = {}
1003
+ quality_colors: np.ndarray | None = None
1004
+ assess_quality = getattr(core, "assess_quality", None) if core_cameras is not None else None
1005
+ if assess_quality is not None:
1006
+ # reverse3d-core >= 0.4: how well the photos support each part, and advice.
1007
+ try:
1008
+ report = assess_quality(mesh, core_cameras, up, 0.5 * (2 * radius / (n - 1)))
1009
+ quality_colors = np.asarray(report["colors"])
1010
+ quality = {"covered": float(report["covered"]), "advice": list(report["advice"])}
1011
+ except Exception as exc: # never fail a job because of the optional engine
1012
+ logger.warning("reverse3d-core quality check failed (%s)", exc)
988
1013
  side = np.cross([0.0, 1.0, 0.0] if abs(up[1]) < 0.9 else [1.0, 0.0, 0.0], up)
989
1014
  side /= np.linalg.norm(side)
990
1015
  basis = np.stack([side, np.cross(up, side), up])
@@ -997,6 +1022,12 @@ def visual_hull(
997
1022
 
998
1023
  output_path.parent.mkdir(parents=True, exist_ok=True)
999
1024
  mesh.export(output_path)
1025
+ quality_path: str | None = None
1026
+ if quality_colors is not None and len(quality_colors) == len(mesh.vertices):
1027
+ heat = trimesh.Trimesh(mesh.vertices, mesh.faces, process=False)
1028
+ heat.visual = trimesh.visual.ColorVisuals(heat, vertex_colors=quality_colors)
1029
+ quality_path = str(output_path.with_name(output_path.stem + "_quality.ply"))
1030
+ heat.export(quality_path)
1000
1031
  logger.info("Silhouette model: %d views, %d faces", len(views), len(mesh.faces))
1001
1032
  return HullResult(
1002
1033
  str(output_path),
@@ -1016,4 +1047,6 @@ def visual_hull(
1016
1047
  if core_weights is not None and engine != "reverse3d"
1017
1048
  else [],
1018
1049
  refined_vertices,
1050
+ quality,
1051
+ quality_path,
1019
1052
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.12.0
3
+ Version: 0.13.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>
@@ -422,6 +422,7 @@ Si las fotos están borrosas o falta una herramienta, **no reintenta en vano**:
422
422
  | Recortes perfectos | 0,943 | 0,941 |
423
423
  | 15 % de recortes defectuosos | 0,000 (pierde el objeto) | **0,921** |
424
424
  | Concavidad invisible a las siluetas (error en la ranura) | 0,154 | **0,091** (refinamiento por fotos, Core 0.3) |
425
+ | Mapa de confianza y consejos de captura | — | ✅ Core 0.4 (`model_quality.glb`) |
425
426
 
426
427
  *(Coincidencia volumétrica, IoU: 1 = idéntico.)*
427
428
 
@@ -440,3 +440,49 @@ def test_the_core_refines_the_hull_by_photo_consistency(
440
440
  images, text = _scene(tmp_path)
441
441
  result = visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=1)
442
442
  assert result.refined_vertices == 42
443
+
444
+
445
+ class _QualityCore(_FakeCore):
446
+ """A fake reverse3d-core >= 0.4 with the quality agent."""
447
+
448
+ def assess_quality(self, mesh, cameras, up, step): # type: ignore[no-untyped-def]
449
+ n = len(mesh.vertices)
450
+ colors = np.tile(np.array([[74, 222, 128, 255]], np.uint8), (n, 1))
451
+ advice = [{"elevation": "top", "share": 0.08, "near_photo": "img_03.png"}]
452
+ return {"confidence": np.ones(n), "colors": colors, "covered": 0.82, "advice": advice}
453
+
454
+
455
+ def test_the_core_quality_agent_writes_a_confidence_map(
456
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
457
+ ) -> None:
458
+ from reverse3d.reconstruction import silhouette
459
+
460
+ monkeypatch.setattr(silhouette, "core_engine", lambda: _QualityCore())
461
+ images, text = _scene(tmp_path)
462
+ result = visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=1)
463
+ assert result.quality["covered"] == pytest.approx(0.82)
464
+ assert result.quality_path is not None
465
+ heat = trimesh.load(result.quality_path, process=False)
466
+ assert len(heat.vertices) == len(trimesh.load(result.mesh_path, process=False).vertices)
467
+
468
+
469
+ def test_capture_advice_becomes_a_warning_only_when_coverage_is_low(tmp_path: Path) -> None:
470
+ from types import SimpleNamespace
471
+
472
+ from reverse3d.config import Settings
473
+ from reverse3d.recipes import _quality_outputs, _Run
474
+
475
+ run = _Run(recipe="photos_to_model", settings=Settings(workspace=tmp_path), out=tmp_path)
476
+ advice = [{"elevation": "top", "share": 0.08, "near_photo": "IMG_5026.jpg"}]
477
+ _quality_outputs(
478
+ run, SimpleNamespace(quality={"covered": 0.82, "advice": advice}, quality_path=None)
479
+ )
480
+ assert run.warnings == [
481
+ "8% of the model is weakly supported by the photos: take more photos from above, "
482
+ "near where you took IMG_5026."
483
+ ]
484
+ run.warnings.clear()
485
+ _quality_outputs(
486
+ run, SimpleNamespace(quality={"covered": 0.98, "advice": advice}, quality_path=None)
487
+ )
488
+ assert run.warnings == []
@@ -1 +0,0 @@
1
- __version__ = "0.12.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes