reverse3d 0.11.1__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.1/reverse3d.egg-info → reverse3d-0.11.2}/PKG-INFO +1 -1
- reverse3d-0.11.2/reverse3d/_version.py +1 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/silhouette.py +212 -32
- {reverse3d-0.11.1 → reverse3d-0.11.2/reverse3d.egg-info}/PKG-INFO +1 -1
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_silhouette.py +57 -0
- reverse3d-0.11.1/reverse3d/_version.py +0 -1
- {reverse3d-0.11.1 → reverse3d-0.11.2}/LICENSE +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/NOTICE +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/README.md +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/pyproject.toml +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/__main__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/_about.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/autopilot.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/cli.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/config.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/doctor.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/errors.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/hardware.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/imaging.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/offline.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/profiles.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/py.typed +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/recipes.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reconstruction/texture.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/_blender_job.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/blender_render.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/scenes.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/security.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/classifier.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d/watch.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/SOURCES.txt +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/setup.cfg +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_authorship.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_automation.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_cli.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_color.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_config.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_heic.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_mesh.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_offline_autopilot.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_render.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_scenes.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_security.py +0 -0
- {reverse3d-0.11.1 → reverse3d-0.11.2}/tests/test_texture.py +0 -0
- {reverse3d-0.11.1 → 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"
|
|
@@ -22,7 +22,9 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import importlib
|
|
24
24
|
import logging
|
|
25
|
+
import os
|
|
25
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
|
|
|
@@ -184,14 +186,20 @@ def _grabcut(
|
|
|
184
186
|
return np.where((init == cv2.GC_FGD) | (init == cv2.GC_PR_FGD), 255, 0).astype(np.uint8)
|
|
185
187
|
|
|
186
188
|
|
|
189
|
+
# Largest pixel coordinate kept in the int16 projections (outside any work image).
|
|
190
|
+
_PIXEL_MAX = 32000
|
|
191
|
+
|
|
187
192
|
# reverse3d-core weight below which a photo is reported as inconsistent.
|
|
188
193
|
_UNRELIABLE_WEIGHT = 0.3
|
|
189
194
|
|
|
190
195
|
# A support plane must face the photographer's "up" (phones are held upright).
|
|
191
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
|
|
192
200
|
# The carved hull has an unseen keel under the object, so a real table may cut a
|
|
193
201
|
# fair part of it; a plane cutting more than this is not under the object.
|
|
194
|
-
_MAX_PLANE_CUT = 0.
|
|
202
|
+
_MAX_PLANE_CUT = 0.3
|
|
195
203
|
|
|
196
204
|
|
|
197
205
|
def _support_plane(
|
|
@@ -209,13 +217,27 @@ def _support_plane(
|
|
|
209
217
|
cameras' mean "up", the object stands on top of it and close to it, and it
|
|
210
218
|
does not cut through the carved object. Otherwise no plane is used at all.
|
|
211
219
|
"""
|
|
212
|
-
|
|
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
|
|
213
235
|
rng = np.random.default_rng(0)
|
|
214
236
|
for _ in range(5):
|
|
215
237
|
if len(near) < 30:
|
|
216
238
|
return None
|
|
217
239
|
best: tuple[np.ndarray, float, int] | None = None
|
|
218
|
-
for _ in range(
|
|
240
|
+
for _ in range(1500):
|
|
219
241
|
s = near[rng.choice(len(near), 3, replace=False)]
|
|
220
242
|
n = np.cross(s[1] - s[0], s[2] - s[0])
|
|
221
243
|
norm = np.linalg.norm(n)
|
|
@@ -223,10 +245,30 @@ def _support_plane(
|
|
|
223
245
|
continue
|
|
224
246
|
n /= norm
|
|
225
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
|
|
226
268
|
count = int((np.abs(near @ n + d) < 0.01 * radius).sum())
|
|
227
269
|
if best is None or count > best[2]:
|
|
228
270
|
best = (n, d, count)
|
|
229
|
-
if best is None or best[2] <
|
|
271
|
+
if best is None or best[2] < _MIN_TABLE_POINTS:
|
|
230
272
|
return None
|
|
231
273
|
n, d, _ = best
|
|
232
274
|
if np.median(cameras @ n + d) < 0: # cameras are above the table
|
|
@@ -242,7 +284,7 @@ def _support_plane(
|
|
|
242
284
|
"object below it"
|
|
243
285
|
if height < 0
|
|
244
286
|
else "too far below the object"
|
|
245
|
-
if height > radius
|
|
287
|
+
if (occupied is None or not len(occupied)) and height > radius
|
|
246
288
|
else f"tilted {tilt:.0f} deg from the cameras' up"
|
|
247
289
|
if tilt > _MAX_TABLE_TILT_DEG
|
|
248
290
|
else f"it would cut {cut:.0%} of the object"
|
|
@@ -256,6 +298,121 @@ def _support_plane(
|
|
|
256
298
|
return None
|
|
257
299
|
|
|
258
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
|
+
|
|
259
416
|
def _box_around(
|
|
260
417
|
sel: np.ndarray, u: np.ndarray, v: np.ndarray, shape: tuple[int, ...]
|
|
261
418
|
) -> tuple[int, int, int, int] | None:
|
|
@@ -529,25 +686,31 @@ def visual_hull(
|
|
|
529
686
|
) -> tuple[list[np.ndarray], np.ndarray, list[tuple[np.ndarray, np.ndarray, np.ndarray]]]:
|
|
530
687
|
"""First silhouettes (GrabCut in a box around where the object projects),
|
|
531
688
|
the voxel grid of the cube center +/- radius and its projection in every photo."""
|
|
532
|
-
|
|
689
|
+
rects: list[tuple[int, int, int, int] | None] = []
|
|
533
690
|
for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
|
|
534
691
|
if boxes is not None and boxes[i] is not None:
|
|
535
|
-
|
|
536
|
-
masks.append(cv2.morphologyEx(fg, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)))
|
|
692
|
+
rects.append(boxes[i])
|
|
537
693
|
continue
|
|
538
694
|
uv, z = _project(center[None], view)
|
|
539
695
|
if z[0] <= 0:
|
|
540
|
-
|
|
696
|
+
rects.append(None)
|
|
541
697
|
continue
|
|
542
698
|
cu, cv_ = uv[0] * scale
|
|
543
699
|
half = view.camera.fx * scale * 0.6 * radius / z[0]
|
|
544
700
|
x0, y0 = max(0, int(cu - half)), max(0, int(cv_ - 1.6 * half))
|
|
545
701
|
x1 = min(small.shape[1] - 1, int(cu + half))
|
|
546
702
|
y1 = min(small.shape[0] - 1, int(cv_ + 1.6 * half))
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
+
)
|
|
551
714
|
|
|
552
715
|
axis = np.linspace(-radius, radius, n)
|
|
553
716
|
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
|
|
@@ -555,8 +718,10 @@ def visual_hull(
|
|
|
555
718
|
projections = []
|
|
556
719
|
for (small, scale), view in zip(images, views, strict=True):
|
|
557
720
|
uv, z = _project(grid, view)
|
|
558
|
-
|
|
559
|
-
|
|
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)
|
|
560
725
|
inside = (z > 0) & (u >= 0) & (v >= 0) & (u < small.shape[1]) & (v < small.shape[0])
|
|
561
726
|
projections.append((u, v, inside))
|
|
562
727
|
|
|
@@ -592,7 +757,12 @@ def visual_hull(
|
|
|
592
757
|
if core is not None and core_cameras is not None:
|
|
593
758
|
try:
|
|
594
759
|
binary = [np.where(m > 0, 255, 0).astype(np.uint8) for m in mask_list]
|
|
595
|
-
|
|
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)
|
|
596
766
|
return np.asarray(prob) > 0.5
|
|
597
767
|
except Exception as exc: # never fail a job because of the optional engine
|
|
598
768
|
logger.warning("reverse3d-core failed (%s); using the open method", exc)
|
|
@@ -629,6 +799,7 @@ def visual_hull(
|
|
|
629
799
|
center = (occupied.min(axis=0) + occupied.max(axis=0)) / 2
|
|
630
800
|
radius *= 1.5
|
|
631
801
|
logger.info("Object reaches the carving volume's edge: re-centred and enlarged")
|
|
802
|
+
projections.clear() # free the previous projections first (gigabytes)
|
|
632
803
|
masks, grid, projections = frame(center, radius)
|
|
633
804
|
keep = carve(masks, 2)
|
|
634
805
|
# The first boxes come from the cameras' convergence point and can crop the object
|
|
@@ -640,6 +811,7 @@ def visual_hull(
|
|
|
640
811
|
_box_around(keep & inside, u, v, small.shape)
|
|
641
812
|
for (u, v, inside), (small, _) in zip(projections, images, strict=True)
|
|
642
813
|
]
|
|
814
|
+
projections.clear() # free the previous projections first (gigabytes)
|
|
643
815
|
masks, grid, projections = frame(center, radius, boxes)
|
|
644
816
|
keep = carve(masks, 2)
|
|
645
817
|
if int(keep.sum()) < 1.05 * before:
|
|
@@ -675,23 +847,30 @@ def visual_hull(
|
|
|
675
847
|
masks = ai_masks
|
|
676
848
|
keep = carve(masks, 1)
|
|
677
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
|
+
|
|
678
867
|
for _ in range(refinements):
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if not hull.any():
|
|
686
|
-
refined.append(old)
|
|
687
|
-
continue
|
|
688
|
-
init = np.full(small.shape[:2], cv2.GC_BGD, np.uint8)
|
|
689
|
-
init[cv2.dilate(hull, np.ones((25, 25), np.uint8)) > 0] = cv2.GC_PR_BGD
|
|
690
|
-
init[hull > 0] = cv2.GC_PR_FGD
|
|
691
|
-
init[cv2.erode(hull, np.ones((21, 21), np.uint8)) > 0] = cv2.GC_FGD
|
|
692
|
-
fg = _grabcut(small, init, None)
|
|
693
|
-
refined.append(cv2.morphologyEx(fg, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)))
|
|
694
|
-
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
|
+
)
|
|
695
874
|
keep = carve(masks, 1)
|
|
696
875
|
|
|
697
876
|
# Concavities and through-holes (a handle loop, the inside of a cup) never
|
|
@@ -727,6 +906,7 @@ def visual_hull(
|
|
|
727
906
|
normal, offset = plane
|
|
728
907
|
height = (grid @ normal + offset).reshape(n, n, n)
|
|
729
908
|
volume[height < 0.015 * radius] = 0
|
|
909
|
+
volume = _complete_base(volume, grid, normal, offset, 2 * radius / (n - 1))
|
|
730
910
|
# Drop structures thinner than a couple of voxels (stray spikes from a few views).
|
|
731
911
|
volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
|
|
732
912
|
labels, count = ndimage.label(volume > 0.5)
|
|
@@ -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>
|
|
@@ -364,3 +364,60 @@ def test_a_wall_through_the_object_is_not_taken_for_the_table() -> None:
|
|
|
364
364
|
normal, offset = plane
|
|
365
365
|
assert abs(normal[2]) > 0.99 # the table, not the wall
|
|
366
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.1"
|
|
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
|
|
File without changes
|