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.
- {reverse3d-0.11.0/reverse3d.egg-info → reverse3d-0.11.2}/PKG-INFO +1 -1
- reverse3d-0.11.2/reverse3d/_version.py +1 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/recipes.py +7 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/silhouette.py +371 -69
- {reverse3d-0.11.0 → reverse3d-0.11.2/reverse3d.egg-info}/PKG-INFO +1 -1
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_silhouette.py +83 -1
- reverse3d-0.11.0/reverse3d/_version.py +0 -1
- {reverse3d-0.11.0 → reverse3d-0.11.2}/LICENSE +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/NOTICE +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/README.md +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/pyproject.toml +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/__main__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/_about.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/cli.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/config.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/doctor.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/errors.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/hardware.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/imaging.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/offline.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/profiles.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/py.typed +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reconstruction/texture.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/scenes.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/security.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d/watch.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/SOURCES.txt +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/setup.cfg +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_authorship.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_automation.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_cli.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_color.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_config.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_heic.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_mesh.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_render.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_scenes.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_security.py +0 -0
- {reverse3d-0.11.0 → reverse3d-0.11.2}/tests/test_texture.py +0 -0
- {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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|