reverse3d 0.11.0__tar.gz → 0.11.2__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.11.0/reverse3d.egg-info → reverse3d-0.11.2}/PKG-INFO +1 -1
  2. reverse3d-0.11.2/reverse3d/_version.py +1 -0
  3. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/recipes.py +7 -0
  4. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/silhouette.py +371 -69
  5. {reverse3d-0.11.0 → reverse3d-0.11.2/reverse3d.egg-info}/PKG-INFO +1 -1
  6. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_silhouette.py +83 -1
  7. reverse3d-0.11.0/reverse3d/_version.py +0 -1
  8. {reverse3d-0.11.0 → reverse3d-0.11.2}/LICENSE +0 -0
  9. {reverse3d-0.11.0 → reverse3d-0.11.2}/NOTICE +0 -0
  10. {reverse3d-0.11.0 → reverse3d-0.11.2}/README.md +0 -0
  11. {reverse3d-0.11.0 → reverse3d-0.11.2}/pyproject.toml +0 -0
  12. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/__init__.py +0 -0
  13. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/__main__.py +0 -0
  14. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/_about.py +0 -0
  15. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/__init__.py +0 -0
  16. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/compact.py +0 -0
  17. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/mcp_server.py +0 -0
  18. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/prompts.py +0 -0
  19. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/skill/SKILL.md +0 -0
  20. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/tools.py +0 -0
  21. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/autopilot.py +0 -0
  22. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/cli.py +0 -0
  23. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/config.py +0 -0
  24. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/doctor.py +0 -0
  25. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/errors.py +0 -0
  26. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/hardware.py +0 -0
  27. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/imaging.py +0 -0
  28. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/logging_utils.py +0 -0
  29. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/mesh_io.py +0 -0
  30. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/offline.py +0 -0
  31. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/profiles.py +0 -0
  32. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/py.typed +0 -0
  33. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/__init__.py +0 -0
  34. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
  35. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/photogrammetry.py +0 -0
  36. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/pointcloud.py +0 -0
  37. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/texture.py +0 -0
  38. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/__init__.py +0 -0
  39. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/_blender_job.py +0 -0
  40. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/blender_render.py +0 -0
  41. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/preview.py +0 -0
  42. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/__init__.py +0 -0
  43. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
  44. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/measure.py +0 -0
  45. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
  46. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/parametric.py +0 -0
  47. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/scenes.py +0 -0
  48. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/security.py +0 -0
  49. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/setup_agents.py +0 -0
  50. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/userconfig.py +0 -0
  51. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/__init__.py +0 -0
  52. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/classifier.py +0 -0
  53. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/feature_matcher.py +0 -0
  54. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/mask_extractor.py +0 -0
  55. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/segmenter.py +0 -0
  56. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/watch.py +0 -0
  57. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/SOURCES.txt +0 -0
  58. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/dependency_links.txt +0 -0
  59. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/entry_points.txt +0 -0
  60. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/requires.txt +0 -0
  61. {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/top_level.txt +0 -0
  62. {reverse3d-0.11.0 → reverse3d-0.11.2}/setup.cfg +0 -0
  63. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_agent_tools.py +0 -0
  64. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_authorship.py +0 -0
  65. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_automation.py +0 -0
  66. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_cli.py +0 -0
  67. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_cli_automation.py +0 -0
  68. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_color.py +0 -0
  69. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_config.py +0 -0
  70. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_geometry_fit.py +0 -0
  71. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_heic.py +0 -0
  72. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_mesh.py +0 -0
  73. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_offline_autopilot.py +0 -0
  74. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_optional_extras.py +0 -0
  75. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_reconstruction.py +0 -0
  76. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_render.py +0 -0
  77. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_scenes.py +0 -0
  78. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_security.py +0 -0
  79. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_texture.py +0 -0
  80. {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_vision.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.11.0
3
+ Version: 0.11.2
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>
@@ -0,0 +1 @@
1
+ __version__ = "0.11.2"
@@ -442,6 +442,13 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
442
442
  mesh_path = photo.mesh_path
443
443
  else:
444
444
  run.details["silhouette"] = hull.to_dict()
445
+ if hull.unreliable:
446
+ names = ", ".join(Path(n).stem for n in hull.unreliable[:5])
447
+ more = "…" if len(hull.unreliable) > 5 else ""
448
+ run.warnings.append(
449
+ f"{len(hull.unreliable)} photo(s) did not match the rest and were "
450
+ f"down-weighted: {names}{more}. Retake them if the object moved."
451
+ )
445
452
  mesh_path = hull.mesh_path
446
453
  upright = True
447
454
  hull_world = hull.to_world
@@ -22,7 +22,9 @@ from __future__ import annotations
22
22
 
23
23
  import importlib
24
24
  import logging
25
- from dataclasses import asdict, dataclass
25
+ import os
26
+ from dataclasses import asdict, dataclass, field
27
+ from functools import partial
26
28
  from pathlib import Path
27
29
  from typing import TYPE_CHECKING, Any
28
30
 
@@ -88,6 +90,8 @@ class HullResult:
88
90
  to_world: list[list[float]] | None = None
89
91
  #: engine that fused the silhouettes: "reverse3d" (open) or "reverse3d-core X.Y"
90
92
  engine: str = "reverse3d"
93
+ #: photos reverse3d-core judged inconsistent with the rest (moved object, bad cut-out)
94
+ unreliable: list[str] = field(default_factory=list)
91
95
 
92
96
  def to_dict(self) -> dict[str, object]:
93
97
  return asdict(self)
@@ -182,32 +186,246 @@ def _grabcut(
182
186
  return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
183
187
 
184
188
 
189
+ # Largest pixel coordinate kept in the int16 projections (outside any work image).
190
+ _PIXEL_MAX = 32000
191
+
192
+ # reverse3d-core weight below which a photo is reported as inconsistent.
193
+ _UNRELIABLE_WEIGHT = 0.3
194
+
195
+ # A support plane must face the photographer's "up" (phones are held upright).
196
+ _MAX_TABLE_TILT_DEG = 30.0
197
+ _MIN_TABLE_COS = float(np.cos(np.radians(_MAX_TABLE_TILT_DEG)))
198
+ # Sparse points a table needs (a dark slatted table yields few).
199
+ _MIN_TABLE_POINTS = 12
200
+ # The carved hull has an unseen keel under the object, so a real table may cut a
201
+ # fair part of it; a plane cutting more than this is not under the object.
202
+ _MAX_PLANE_CUT = 0.3
203
+
204
+
185
205
  def _support_plane(
186
- points: np.ndarray, center: np.ndarray, radius: float, cameras: np.ndarray
206
+ points: np.ndarray,
207
+ center: np.ndarray,
208
+ radius: float,
209
+ cameras: np.ndarray,
210
+ occupied: np.ndarray | None = None,
211
+ up: np.ndarray | None = None,
187
212
  ) -> tuple[np.ndarray, float] | None:
188
- """The table under the object: RANSAC plane through nearby sparse points, normal up."""
189
- near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
190
- if len(near) < 30:
191
- return None
213
+ """The table under the object: RANSAC plane through nearby sparse points, normal up.
214
+
215
+ A room offers many planes (floor, walls, a door, chair seats). Candidates are
216
+ tried from the best supported down; one is the support only if it faces the
217
+ cameras' mean "up", the object stands on top of it and close to it, and it
218
+ does not cut through the carved object. Otherwise no plane is used at all.
219
+ """
220
+ if occupied is not None and len(occupied):
221
+ # Around the carved object itself (a tall object's table is far from its centre).
222
+ lo = occupied.min(axis=0) - 0.5 * radius
223
+ hi = occupied.max(axis=0) + 0.5 * radius
224
+ near = points[np.all((points >= lo) & (points <= hi), axis=1)]
225
+ else:
226
+ near = points[np.linalg.norm(points - center, axis=1) < 1.2 * radius]
227
+ if up is not None and occupied is not None and len(occupied) and len(near) >= 30:
228
+ layer = _table_layer(near, occupied, up, radius)
229
+ if layer is not None:
230
+ n, d = layer
231
+ if np.median(cameras @ n + d) >= 0 and float(center @ n + d) > 0:
232
+ cut = float(np.mean(occupied @ n + d < 0.015 * radius))
233
+ if cut <= _MAX_PLANE_CUT:
234
+ return n, d
192
235
  rng = np.random.default_rng(0)
193
- best: tuple[np.ndarray, float, int] | None = None
194
- for _ in range(400):
195
- s = near[rng.choice(len(near), 3, replace=False)]
196
- n = np.cross(s[1] - s[0], s[2] - s[0])
197
- norm = np.linalg.norm(n)
198
- if norm < 1e-12:
199
- continue
200
- n /= norm
201
- d = -float(n @ s[0])
202
- count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
203
- if best is None or count > best[2]:
204
- best = (n, d, count)
205
- if best is None or best[2] < 20:
236
+ for _ in range(5):
237
+ if len(near) < 30:
238
+ return None
239
+ best: tuple[np.ndarray, float, int] | None = None
240
+ for _ in range(1500):
241
+ s = near[rng.choice(len(near), 3, replace=False)]
242
+ n = np.cross(s[1] - s[0], s[2] - s[0])
243
+ norm = np.linalg.norm(n)
244
+ if norm < 1e-12:
245
+ continue
246
+ n /= norm
247
+ d = -float(n @ s[0])
248
+ # Only roughly horizontal planes compete: walls and doors have far more
249
+ # points than a dark table and would always win.
250
+ if up is not None and abs(float(n @ up)) < _MIN_TABLE_COS:
251
+ continue
252
+ # ... and only below the object, close to it (not the top of a cap,
253
+ # a shelf or a chair seat behind it).
254
+ if np.median(cameras @ n + d) < 0:
255
+ n, d = -n, -d
256
+ if float(center @ n + d) < 0.0:
257
+ continue
258
+ if occupied is not None and len(occupied):
259
+ # The object must reach down to it (not a floor far below the table)
260
+ # and stand on it (not a shelf or a seat at mid height).
261
+ above = occupied @ n + d
262
+ if float(np.min(above)) > 0.05 * radius:
263
+ continue
264
+ if float(np.mean(above < 0.015 * radius)) > _MAX_PLANE_CUT:
265
+ continue
266
+ elif float(center @ n + d) > radius:
267
+ continue
268
+ count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
269
+ if best is None or count > best[2]:
270
+ best = (n, d, count)
271
+ if best is None or best[2] < _MIN_TABLE_POINTS:
272
+ return None
273
+ n, d, _ = best
274
+ if np.median(cameras @ n + d) < 0: # cameras are above the table
275
+ n, d = -n, -d
276
+ height = float(center @ n + d)
277
+ tilt = float(np.degrees(np.arccos(np.clip(n @ up, -1.0, 1.0)))) if up is not None else 0.0
278
+ cut = (
279
+ float(np.mean(occupied @ n + d < 0.015 * radius))
280
+ if occupied is not None and len(occupied)
281
+ else 0.0
282
+ )
283
+ reason = (
284
+ "object below it"
285
+ if height < 0
286
+ else "too far below the object"
287
+ if (occupied is None or not len(occupied)) and height > radius
288
+ else f"tilted {tilt:.0f} deg from the cameras' up"
289
+ if tilt > _MAX_TABLE_TILT_DEG
290
+ else f"it would cut {cut:.0%} of the object"
291
+ if cut > _MAX_PLANE_CUT
292
+ else None
293
+ )
294
+ if reason is None:
295
+ return n, d
296
+ logger.info("Plane rejected as support (%s)", reason)
297
+ near = near[np.abs(near @ n + d) >= 0.01 * radius]
298
+ return None
299
+
300
+
301
+ # Base completion: the typical cross-section is measured in this band of the
302
+ # object's height, and a complete one is searched for no higher than this.
303
+ _BASE_TYPICAL = (0.2, 0.6)
304
+ _BASE_MAX_SEARCH = 0.4
305
+
306
+
307
+ def _complete_base(
308
+ volume: np.ndarray, grid: np.ndarray, normal: np.ndarray, offset: float, voxel: float
309
+ ) -> np.ndarray:
310
+ """Extend an object standing on a table down to it.
311
+
312
+ The lowest band of an object is where cut-outs fail most (a white base on a
313
+ light table, its reflection, a shadow), leaving a slanted or bitten base. A
314
+ standing object reaches the table, so the cross-section just above that band
315
+ is extruded down to the table. Only voxels under that cross-section are added
316
+ (legs stay legs), and nothing is removed.
317
+ """
318
+ occ = volume.reshape(-1) > 0.5
319
+ if not occ.any():
320
+ return volume
321
+ h = grid @ normal + offset
322
+ top = float(h[occ].max())
323
+ if top <= 0:
324
+ return volume
325
+ # Reference: the lowest cross-section that is already complete (area close to
326
+ # the typical one of the object's middle), searched upwards from the table.
327
+ hs = h[occ]
328
+ edges = np.arange(0.0, top + voxel, voxel)
329
+ area, _ = np.histogram(hs, edges)
330
+ mid = (edges[:-1] >= _BASE_TYPICAL[0] * top) & (edges[:-1] < _BASE_TYPICAL[1] * top)
331
+ if not mid.any():
332
+ return volume
333
+ typical = float(np.median(area[mid]))
334
+ lo = None
335
+ for k in range(len(area) - 2):
336
+ if edges[k] > _BASE_MAX_SEARCH * top:
337
+ break
338
+ if min(area[k], area[k + 1], area[k + 2]) >= 0.9 * typical:
339
+ lo = float(edges[k])
340
+ break
341
+ if lo is None or lo <= voxel:
342
+ return volume # already whole down to the table, or no clean reference
343
+ ref = occ & (h >= lo) & (h < lo + 3 * voxel)
344
+ if ref.sum() < 20:
345
+ return volume
346
+ helper = np.array([1.0, 0.0, 0.0]) if abs(normal[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
347
+ e1 = np.cross(normal, helper)
348
+ e1 /= np.linalg.norm(e1)
349
+ e2 = np.cross(normal, e1)
350
+ xy = np.stack([grid @ e1, grid @ e2], axis=1)
351
+ origin = xy.min(axis=0)
352
+ ij = np.floor((xy - origin) / voxel).astype(np.int64)
353
+ footprint = np.zeros(ij.max(axis=0) + 1, bool)
354
+ footprint[ij[ref, 0], ij[ref, 1]] = True
355
+ from scipy import ndimage
356
+
357
+ footprint = ndimage.binary_closing(footprint, iterations=1)
358
+ below = (h >= 0) & (h < lo) & footprint[ij[:, 0], ij[:, 1]]
359
+ added = int((below & ~occ).sum())
360
+ if added:
361
+ logger.info("Base completed down to the table (%d voxels)", added)
362
+ out = volume.reshape(-1).copy()
363
+ out[below] = 1.0
364
+ return out.reshape(volume.shape)
365
+
366
+
367
+ def _coarse_grabcut(image: np.ndarray, rect: tuple[int, int, int, int]) -> np.ndarray:
368
+ """GrabCut at half resolution (4x fewer pixels): enough for a first silhouette."""
369
+ h, w = image.shape[:2]
370
+ half = cv2.resize(image, (w // 2, h // 2), interpolation=cv2.INTER_AREA)
371
+ x, y, rw, rh = rect
372
+ fg = _grabcut(
373
+ half, np.zeros(half.shape[:2], np.uint8), (x // 2, y // 2, max(2, rw // 2), max(2, rh // 2))
374
+ )
375
+ return cv2.resize(fg, (w, h), interpolation=cv2.INTER_NEAREST)
376
+
377
+
378
+ def _parallel(jobs: list[Callable[[], np.ndarray]]) -> list[np.ndarray]:
379
+ """Run per-photo OpenCV jobs on every core (OpenCV releases the GIL)."""
380
+ from concurrent.futures import ThreadPoolExecutor
381
+
382
+ workers = max(1, min(len(jobs), (os.cpu_count() or 2)))
383
+ with ThreadPoolExecutor(max_workers=workers) as pool:
384
+ return list(pool.map(lambda job: job(), jobs))
385
+
386
+
387
+ def _table_layer(
388
+ near: np.ndarray, occupied: np.ndarray, up: np.ndarray, radius: float
389
+ ) -> tuple[np.ndarray, float] | None:
390
+ """The densest horizontal layer of sparse points just under the object.
391
+
392
+ Knowing "up" turns the table search into 1-D: a histogram of point heights
393
+ around the object's lowest point. The layer is fitted by least squares, which
394
+ tolerates a slatted or slightly uneven top that thin RANSAC bands miss.
395
+ """
396
+ heights = near @ up
397
+ base = float(np.min(occupied @ up))
398
+ step = 0.02 * radius
399
+ edges = np.arange(base - 0.5 * radius, base + 0.3 * radius + step, step)
400
+ counts, _ = np.histogram(heights, edges)
401
+ if counts.max(initial=0) < _MIN_TABLE_POINTS:
402
+ return None
403
+ peak = int(np.argmax(counts))
404
+ level = (edges[peak] + edges[peak + 1]) / 2
405
+ layer = near[np.abs(heights - level) < 2 * step]
406
+ if len(layer) < _MIN_TABLE_POINTS:
407
+ return None
408
+ centre = layer.mean(axis=0)
409
+ _, _, vt = np.linalg.svd(layer - centre, full_matrices=False)
410
+ n = vt[2] if vt[2] @ up > 0 else -vt[2]
411
+ if float(n @ up) < _MIN_TABLE_COS:
412
+ return None
413
+ return n, -float(n @ centre)
414
+
415
+
416
+ def _box_around(
417
+ sel: np.ndarray, u: np.ndarray, v: np.ndarray, shape: tuple[int, ...]
418
+ ) -> tuple[int, int, int, int] | None:
419
+ """GrabCut box around the projected voxels ``sel``, with a generous margin."""
420
+ if sel.sum() < 50:
206
421
  return None
207
- n, d, _ = best
208
- if np.median(cameras @ n + d) < 0: # cameras are above the table
209
- n, d = -n, -d
210
- return n, d
422
+ us, vs = u[sel], v[sel]
423
+ w, h = int(us.max() - us.min() + 1), int(vs.max() - vs.min() + 1)
424
+ x0 = max(0, int(us.min() - 0.2 * w))
425
+ y0 = max(0, int(vs.min() - 0.3 * h))
426
+ x1 = min(shape[1] - 1, int(us.max() + 0.2 * w))
427
+ y1 = min(shape[0] - 1, int(vs.max() + 0.3 * h))
428
+ return x0, y0, max(2, x1 - x0), max(2, y1 - y0)
211
429
 
212
430
 
213
431
  def _vertex_colors(
@@ -459,34 +677,57 @@ def visual_hull(
459
677
  )
460
678
  )
461
679
 
462
- # First silhouettes: GrabCut in a box around where the object projects.
463
- masks: list[np.ndarray] = []
464
- for (small, scale), view in zip(images, views, strict=True):
465
- uv, z = _project(center[None], view)
466
- if z[0] <= 0:
467
- masks.append(np.zeros(small.shape[:2], np.uint8))
468
- continue
469
- cu, cv_ = uv[0] * scale
470
- half = view.camera.fx * scale * 0.6 * radius / z[0]
471
- x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
472
- x1 = min(small.shape[1] - 1, int(cu + half))
473
- y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
474
- init = np.zeros(small.shape[:2], np.uint8)
475
- rect = (x0, y0, max(2, x1 - x0), max(2, y1 - y0))
476
- fg = _grabcut(small, init, rect)
477
- masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
478
-
479
680
  n = resolution
480
- axis = np.linspace(-radius, radius, n)
481
- gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
482
- grid = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1) + center
483
- projections = []
484
- for (small, scale), view in zip(images, views, strict=True):
485
- uv, z = _project(grid, view)
486
- u = np.round(uv[:, 0] * scale).astype(np.int64)
487
- v = np.round(uv[:, 1] * scale).astype(np.int64)
488
- inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
489
- projections.append((u, v, inside))
681
+
682
+ def frame(
683
+ center: np.ndarray,
684
+ radius: float,
685
+ boxes: list[tuple[int, int, int, int] | None] | None = None,
686
+ ) -> tuple[list[np.ndarray], np.ndarray, list[tuple[np.ndarray, np.ndarray, np.ndarray]]]:
687
+ """First silhouettes (GrabCut in a box around where the object projects),
688
+ the voxel grid of the cube center +/- radius and its projection in every photo."""
689
+ rects: list[tuple[int, int, int, int] | None] = []
690
+ for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
691
+ if boxes is not None and boxes[i] is not None:
692
+ rects.append(boxes[i])
693
+ continue
694
+ uv, z = _project(center[None], view)
695
+ if z[0] <= 0:
696
+ rects.append(None)
697
+ continue
698
+ cu, cv_ = uv[0] * scale
699
+ half = view.camera.fx * scale * 0.6 * radius / z[0]
700
+ x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
701
+ x1 = min(small.shape[1] - 1, int(cu + half))
702
+ y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
703
+ rects.append((x0, y0, max(2, x1 - x0), max(2, y1 - y0)))
704
+
705
+ def cut(small: np.ndarray, rect: tuple[int, int, int, int] | None) -> np.ndarray:
706
+ if rect is None:
707
+ return np.zeros(small.shape[:2], np.uint8)
708
+ fg = _coarse_grabcut(small, rect)
709
+ return cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
710
+
711
+ masks = _parallel(
712
+ [partial(cut, small, rect) for (small, _), rect in zip(images, rects, strict=True)]
713
+ )
714
+
715
+ axis = np.linspace(-radius, radius, n)
716
+ gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
717
+ grid = np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1) + center
718
+ projections = []
719
+ for (small, scale), view in zip(images, views, strict=True):
720
+ uv, z = _project(grid, view)
721
+ # int16 pixel coordinates (work images are at most 640 px): 4x less memory
722
+ # than int64 for millions of voxels times hundreds of photos.
723
+ u = np.clip(np.round(uv[:, 0] * scale), -1, _PIXEL_MAX).astype(np.int16)
724
+ v = np.clip(np.round(uv[:, 1] * scale), -1, _PIXEL_MAX).astype(np.int16)
725
+ inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
726
+ projections.append((u, v, inside))
727
+
728
+ return masks, grid, projections
729
+
730
+ masks, grid, projections = frame(center, radius)
490
731
 
491
732
  core = core_engine()
492
733
  core_cameras = (
@@ -509,13 +750,19 @@ def visual_hull(
509
750
  else None
510
751
  )
511
752
  engine = f"reverse3d-core {core.__version__}" if core is not None else "reverse3d"
753
+ core_weights: np.ndarray | None = None
512
754
 
513
755
  def carve(mask_list: list[np.ndarray], tolerance: int) -> np.ndarray:
514
- nonlocal core_cameras, engine
756
+ nonlocal core_cameras, engine, core_weights
515
757
  if core is not None and core_cameras is not None:
516
758
  try:
517
759
  binary = [np.where(m > 0, 255, 0).astype(np.uint8) for m in mask_list]
518
- prob, _ = core.fuse_cameras(core_cameras, binary, grid)
760
+ try: # reverse3d-core >= 0.2: coarse-to-fine on the grid
761
+ prob, core_weights = core.fuse_cameras(
762
+ core_cameras, binary, grid, grid_shape=(n, n, n)
763
+ )
764
+ except TypeError:
765
+ prob, core_weights = core.fuse_cameras(core_cameras, binary, grid)
519
766
  return np.asarray(prob) > 0.5
520
767
  except Exception as exc: # never fail a job because of the optional engine
521
768
  logger.warning("reverse3d-core failed (%s); using the open method", exc)
@@ -534,6 +781,42 @@ def visual_hull(
534
781
  return (seen >= 3) & (votes >= seen - allowed)
535
782
 
536
783
  keep = carve(masks, 2)
784
+ # The cameras' convergence point is biased when many photos are close-ups of one
785
+ # part (a cap): if the carved object reaches the edge of the cube, re-centre the
786
+ # cube on it, enlarge it and start again (masks included, their box depends on it).
787
+ for _ in range(2):
788
+ cube = keep.reshape(n, n, n)
789
+ if not (
790
+ cube[0].any()
791
+ or cube[-1].any()
792
+ or cube[:, 0].any()
793
+ or cube[:, -1].any()
794
+ or cube[:, :, 0].any()
795
+ or cube[:, :, -1].any()
796
+ ):
797
+ break
798
+ occupied = grid[keep]
799
+ center = (occupied.min(axis=0) + occupied.max(axis=0)) / 2
800
+ radius *= 1.5
801
+ logger.info("Object reaches the carving volume's edge: re-centred and enlarged")
802
+ projections.clear() # free the previous projections first (gigabytes)
803
+ masks, grid, projections = frame(center, radius)
804
+ keep = carve(masks, 2)
805
+ # The first boxes come from the cameras' convergence point and can crop the object
806
+ # (its base, when many photos are close-ups of the top). Re-cut every photo inside
807
+ # a box around where the carved object projects, with margin, while it grows.
808
+ for _ in range(2):
809
+ before = int(keep.sum())
810
+ boxes = [
811
+ _box_around(keep & inside, u, v, small.shape)
812
+ for (u, v, inside), (small, _) in zip(projections, images, strict=True)
813
+ ]
814
+ projections.clear() # free the previous projections first (gigabytes)
815
+ masks, grid, projections = frame(center, radius, boxes)
816
+ keep = carve(masks, 2)
817
+ if int(keep.sum()) < 1.05 * before:
818
+ break
819
+ logger.info("Object grew to %d voxels with photo boxes around it", int(keep.sum()))
537
820
  # The AI segmenter is only loaded (or downloaded) once the photos proved usable.
538
821
  ai = segmenter() if segmenter is not None else None
539
822
  if ai is not None:
@@ -564,23 +847,30 @@ def visual_hull(
564
847
  masks = ai_masks
565
848
  keep = carve(masks, 1)
566
849
  refinements = 0
850
+
851
+ def refine(
852
+ small: np.ndarray, u: np.ndarray, v: np.ndarray, inside: np.ndarray, old: np.ndarray
853
+ ) -> np.ndarray:
854
+ hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
855
+ sel = keep & inside
856
+ hull[v[sel], u[sel]] = 255
857
+ hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
858
+ if not hull.any():
859
+ return old
860
+ init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
861
+ init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
862
+ init[hull > 0] = cv2.GC_PR_FGD
863
+ init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
864
+ fg = _grabcut(small, init, None)
865
+ return cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
866
+
567
867
  for _ in range(refinements):
568
- refined: list[np.ndarray] = []
569
- for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True):
570
- hull: np.ndarray = np.zeros(small.shape[:2], np.uint8)
571
- sel = keep & inside
572
- hull[v[sel], u[sel]] = 255
573
- hull = cv2.morphologyEx(hull, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
574
- if not hull.any():
575
- refined.append(old)
576
- continue
577
- init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
578
- init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
579
- init[hull > 0] = cv2.GC_PR_FGD
580
- init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
581
- fg = _grabcut(small, init, None)
582
- refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
583
- masks = refined
868
+ masks = _parallel(
869
+ [
870
+ partial(refine, small, u, v, inside, old)
871
+ for (small, _), (u, v, inside), old in zip(images, projections, masks, strict=True)
872
+ ]
873
+ )
584
874
  keep = carve(masks, 1)
585
875
 
586
876
  # Concavities and through-holes (a handle loop, the inside of a cup) never
@@ -607,11 +897,16 @@ def visual_hull(
607
897
  )
608
898
 
609
899
  volume = keep.reshape(n, n, n).astype(np.float32)
610
- plane = _support_plane(points, center, radius, cameras)
900
+ camera_up = -np.mean([view.rotation[1] for view in views], axis=0)
901
+ camera_up /= np.linalg.norm(camera_up) + 1e-12
902
+ plane = _support_plane(
903
+ points, center, radius, cameras, grid[volume.reshape(-1) > 0.5], camera_up
904
+ )
611
905
  if plane is not None:
612
906
  normal, offset = plane
613
907
  height = (grid @ normal + offset).reshape(n, n, n)
614
908
  volume[height < 0.015 * radius] = 0
909
+ volume = _complete_base(volume, grid, normal, offset, 2 * radius / (n - 1))
615
910
  # Drop structures thinner than a couple of voxels (stray spikes from a few views).
616
911
  volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
617
912
  labels, count = ndimage.label(volume > 0.5)
@@ -670,4 +965,11 @@ def visual_hull(
670
965
  carved_by_depth,
671
966
  np.linalg.inv(world_to_model).tolist(),
672
967
  engine,
968
+ [
969
+ view.name
970
+ for view, weight in zip(views, core_weights, strict=True)
971
+ if weight < _UNRELIABLE_WEIGHT
972
+ ]
973
+ if core_weights is not None and engine != "reverse3d"
974
+ else [],
673
975
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reverse3d
3
- Version: 0.11.0
3
+ Version: 0.11.2
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>
@@ -311,7 +311,9 @@ class _FakeCore:
311
311
  ok = np.ones(len(points), bool)
312
312
  ok[inside] = mask[v[inside], u[inside]] > 127
313
313
  keep &= ok
314
- return keep.astype(np.float32), np.ones(len(cameras))
314
+ weights = np.ones(len(cameras))
315
+ weights[-1] = 0.1 # the core distrusts the last photo
316
+ return keep.astype(np.float32), weights
315
317
 
316
318
 
317
319
  def test_the_proprietary_core_fuses_the_silhouettes_when_installed(
@@ -326,6 +328,7 @@ def test_the_proprietary_core_fuses_the_silhouettes_when_installed(
326
328
  assert core.calls >= 1
327
329
  assert result.engine == "reverse3d-core 9.9.9"
328
330
  assert result.voxels > 500
331
+ assert len(result.unreliable) == 1
329
332
 
330
333
 
331
334
  def test_a_failing_core_falls_back_to_the_open_method(
@@ -339,3 +342,82 @@ def test_a_failing_core_falls_back_to_the_open_method(
339
342
  result = visual_hull(images, text, tmp_path / "hull.ply", resolution=48, refinements=1)
340
343
  assert core.calls == 1
341
344
  assert result.engine == "reverse3d"
345
+ assert result.unreliable == []
346
+
347
+
348
+ def test_a_wall_through_the_object_is_not_taken_for_the_table() -> None:
349
+ from reverse3d.reconstruction.silhouette import _support_plane
350
+
351
+ rng = np.random.default_rng(0)
352
+ table = np.column_stack([rng.uniform(-1, 1, 200), rng.uniform(-1, 1, 200), np.zeros(200)])
353
+ # More points on a vertical plane that crosses the object (a door, a wall).
354
+ wall = np.column_stack([np.full(400, 0.1), rng.uniform(-1, 1, 400), rng.uniform(0, 1.5, 400)])
355
+ obj = np.column_stack(
356
+ [rng.uniform(-0.3, 0.3, 3000), rng.uniform(-0.3, 0.3, 3000), rng.uniform(0.02, 1.2, 3000)]
357
+ )
358
+ cameras = np.array([[3.0, 0.0, 1.5], [-3.0, 0.5, 1.6], [0.0, 3.0, 1.4], [0.5, -3.0, 1.5]])
359
+ center = np.array([0.0, 0.0, 0.6])
360
+ plane = _support_plane(
361
+ np.vstack([table, wall]), center, 1.0, cameras, obj, np.array([0.0, 0.0, 1.0])
362
+ )
363
+ assert plane is not None
364
+ normal, offset = plane
365
+ assert abs(normal[2]) > 0.99 # the table, not the wall
366
+ assert np.mean(obj @ normal + offset < 0) < 0.05
367
+
368
+
369
+ def test_a_sparse_slatted_table_under_a_tall_object_is_found() -> None:
370
+ from reverse3d.reconstruction.silhouette import _support_plane
371
+
372
+ rng = np.random.default_rng(1)
373
+ # Few points on an uneven, slatted table top (z ~ 0 +- 0.03), far below the centre.
374
+ slats = rng.uniform(-1.5, 1.5, (60, 2))
375
+ table = np.column_stack([slats, rng.normal(0.0, 0.015, 60)])
376
+ # Many points on a wall and on the top of the object (its cap).
377
+ wall = np.column_stack([np.full(600, 1.2), rng.uniform(-2, 2, 600), rng.uniform(0, 4, 600)])
378
+ cap = np.column_stack([rng.uniform(-0.2, 0.2, (300, 2)), np.full(300, 3.4)])
379
+ obj = np.column_stack([rng.uniform(-0.4, 0.4, (4000, 2)), rng.uniform(0.0, 3.4, 4000)])
380
+ cameras = np.array([[4.0, 0.0, 2.0], [-4.0, 0.5, 3.5], [0.0, 4.0, 1.0], [0.5, -4.0, 4.5]])
381
+ center = np.array([0.0, 0.0, 2.6]) # pulled up by close-ups of the cap
382
+ up = np.array([0.0, 0.0, 1.0])
383
+ plane = _support_plane(np.vstack([table, wall, cap]), center, 1.0, cameras, obj, up)
384
+ assert plane is not None
385
+ normal, offset = plane
386
+ assert normal[2] > 0.99
387
+ assert abs(offset) < 0.05 # the table top at z = 0
388
+
389
+
390
+ def _voxels(n: int = 60) -> tuple[np.ndarray, float]:
391
+ axis = np.linspace(-1.5, 1.5, n)
392
+ gx, gy, gz = np.meshgrid(axis, axis, axis + 1.4, indexing="ij")
393
+ return np.stack([gx.ravel(), gy.ravel(), gz.ravel()], axis=1), float(axis[1] - axis[0])
394
+
395
+
396
+ def test_a_bitten_base_is_completed_down_to_the_table() -> None:
397
+ from reverse3d.reconstruction.silhouette import _complete_base
398
+
399
+ grid, voxel = _voxels()
400
+ r = np.hypot(grid[:, 0], grid[:, 1])
401
+ bottle = (r < 0.5) & (grid[:, 2] >= 0) & (grid[:, 2] < 2.5)
402
+ bitten = bottle & ~((grid[:, 0] > 0) & (grid[:, 2] < 0.25)) # half the base missing
403
+ volume = bitten.astype(np.float32).reshape(60, 60, 60)
404
+ out = _complete_base(volume, grid, np.array([0.0, 0.0, 1.0]), 0.0, voxel).reshape(-1) > 0.5
405
+ base = bottle & (grid[:, 2] < 0.25)
406
+ assert out[base].mean() > 0.95 # the base is whole again
407
+ assert not (out & ~bottle).any() # nothing added outside the object
408
+
409
+
410
+ def test_legs_stay_legs_when_the_base_is_completed() -> None:
411
+ from reverse3d.reconstruction.silhouette import _complete_base
412
+
413
+ grid, voxel = _voxels()
414
+ x, y, z = grid.T
415
+ legs = (
416
+ ((np.abs(np.abs(x) - 0.8) < 0.12) & (np.abs(np.abs(y) - 0.8) < 0.12)) & (z >= 0) & (z < 1.0)
417
+ )
418
+ top = (np.abs(x) < 1.0) & (np.abs(y) < 1.0) & (z >= 1.0) & (z < 1.2)
419
+ table = legs | top
420
+ volume = table.astype(np.float32).reshape(60, 60, 60)
421
+ out = _complete_base(volume, grid, np.array([0.0, 0.0, 1.0]), 0.0, voxel).reshape(-1) > 0.5
422
+ between = (np.abs(x) < 0.4) & (np.abs(y) < 0.4) & (z < 0.5)
423
+ assert not out[between].any()
@@ -1 +0,0 @@
1
- __version__ = "0.11.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes