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