reverse3d 0.4.0__py3-none-any.whl
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/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- reverse3d-0.4.0.dist-info/top_level.txt +1 -0
reverse3d/autopilot.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Autopilot: give it anything, it works out what it is and how to process it.
|
|
2
|
+
|
|
3
|
+
:func:`auto_run` is the engine behind ``reverse3d auto``, the ``auto`` recipe
|
|
4
|
+
and the watched inbox (:mod:`reverse3d.watch`). It is deterministic (plain
|
|
5
|
+
rules, no AI model), so it works the same offline and on any machine:
|
|
6
|
+
|
|
7
|
+
1. :func:`detect_input` classifies the input: a folder of photos, a drawing or
|
|
8
|
+
silhouette, a single photo, a mesh or a point cloud.
|
|
9
|
+
2. It runs the matching recipe and retries only when another setting can fix
|
|
10
|
+
the failure (another mask method, higher photogrammetry quality, stronger
|
|
11
|
+
mesh repair). Problems that need the user (blurry photos, a missing tool)
|
|
12
|
+
stop immediately with a clear hint.
|
|
13
|
+
3. It writes ``RESUMEN.md``: what was detected, sizes, files and next steps.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from dataclasses import asdict, dataclass, replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
import cv2
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from reverse3d.config import Settings
|
|
27
|
+
from reverse3d.errors import (
|
|
28
|
+
CaptureQualityError,
|
|
29
|
+
DependencyMissingError,
|
|
30
|
+
ExternalToolError,
|
|
31
|
+
InputValidationError,
|
|
32
|
+
ProcessingError,
|
|
33
|
+
Reverse3DError,
|
|
34
|
+
)
|
|
35
|
+
from reverse3d.recipes import MaskMethod, RecipeOptions, run_recipe
|
|
36
|
+
from reverse3d.security import IMAGE_EXTENSIONS, MESH_EXTENSIONS, POINT_CLOUD_EXTENSIONS
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
InputKind = Literal["photos", "plan", "photo", "mesh", "point_cloud", "unknown"]
|
|
41
|
+
|
|
42
|
+
_RECIPE_FOR: dict[str, str | None] = {
|
|
43
|
+
"photos": "photos_to_model",
|
|
44
|
+
"plan": "plan_to_part",
|
|
45
|
+
"photo": "photo_to_relief",
|
|
46
|
+
"mesh": "scan_to_cad",
|
|
47
|
+
"point_cloud": "scan_to_cad",
|
|
48
|
+
"unknown": None,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
KIND_LABEL_ES: dict[str, str] = {
|
|
52
|
+
"photos": "Fotos de un objeto (fotogrametría)",
|
|
53
|
+
"plan": "Plano, careta o silueta",
|
|
54
|
+
"photo": "Una foto (relieve con IA)",
|
|
55
|
+
"mesh": "Malla o escaneo 3D",
|
|
56
|
+
"point_cloud": "Nube de puntos",
|
|
57
|
+
"unknown": "No reconocido",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_MIN_PHOTOS = 3
|
|
61
|
+
_ANALYSIS_SIDE = 256
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Detection:
|
|
66
|
+
"""What :func:`detect_input` found."""
|
|
67
|
+
|
|
68
|
+
kind: InputKind
|
|
69
|
+
recipe: str | None
|
|
70
|
+
reason: str
|
|
71
|
+
source: str
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
return asdict(self)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _visible(path: Path) -> bool:
|
|
78
|
+
return not path.name.startswith((".", "_", "~$"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _has_mesh_faces(ply: Path) -> bool:
|
|
82
|
+
"""Read the PLY header only: a PLY with faces is a mesh, without is a point cloud."""
|
|
83
|
+
try:
|
|
84
|
+
with ply.open("rb") as fh:
|
|
85
|
+
header = fh.read(4096).decode("latin-1", errors="ignore")
|
|
86
|
+
except OSError:
|
|
87
|
+
return True
|
|
88
|
+
for line in header.splitlines():
|
|
89
|
+
parts = line.split()
|
|
90
|
+
if len(parts) == 3 and parts[:2] == ["element", "face"]:
|
|
91
|
+
return parts[2] != "0"
|
|
92
|
+
if line.strip() == "end_header":
|
|
93
|
+
break
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def looks_like_plan(image_path: Path) -> tuple[bool, str]:
|
|
98
|
+
"""Heuristic: drawings, stencils and silhouettes are near-binary images or
|
|
99
|
+
have a transparent background; photos have many tones and colors."""
|
|
100
|
+
image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
|
|
101
|
+
if image is None:
|
|
102
|
+
return False, "unreadable image"
|
|
103
|
+
if image.ndim == 3 and image.shape[2] == 4:
|
|
104
|
+
transparent = float(np.mean(image[:, :, 3] < 16))
|
|
105
|
+
if transparent > 0.05:
|
|
106
|
+
return True, f"transparent background ({transparent:.0%})"
|
|
107
|
+
image = image[:, :, :3]
|
|
108
|
+
scale = _ANALYSIS_SIDE / max(image.shape[:2])
|
|
109
|
+
if scale < 1:
|
|
110
|
+
image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
|
111
|
+
if image.ndim == 2:
|
|
112
|
+
gray, saturation = image, 0.0
|
|
113
|
+
else:
|
|
114
|
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
115
|
+
saturation = float(np.mean(cv2.cvtColor(image, cv2.COLOR_BGR2HSV)[:, :, 1]))
|
|
116
|
+
extremes = float(np.mean((gray < 60) | (gray > 195)))
|
|
117
|
+
if extremes > 0.85 and saturation < 45:
|
|
118
|
+
return True, f"high-contrast drawing ({extremes:.0%} black/white pixels)"
|
|
119
|
+
return False, f"photo-like image ({extremes:.0%} black/white pixels)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def detect_input(path: str | Path) -> Detection:
|
|
123
|
+
"""Classify an input file or folder. Never raises for unknown content."""
|
|
124
|
+
src = Path(path).expanduser()
|
|
125
|
+
if src.is_dir():
|
|
126
|
+
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]
|
|
128
|
+
meshes = [p for p in files if p.suffix.lower() in MESH_EXTENSIONS | POINT_CLOUD_EXTENSIONS]
|
|
129
|
+
if len(images) >= _MIN_PHOTOS:
|
|
130
|
+
return Detection(
|
|
131
|
+
"photos", "photos_to_model", f"folder with {len(images)} photos", str(src)
|
|
132
|
+
)
|
|
133
|
+
if len(images) == 1 and not meshes:
|
|
134
|
+
return detect_input(images[0])
|
|
135
|
+
if len(meshes) == 1 and not images:
|
|
136
|
+
return detect_input(meshes[0])
|
|
137
|
+
if images:
|
|
138
|
+
return Detection(
|
|
139
|
+
"unknown",
|
|
140
|
+
None,
|
|
141
|
+
f"only {len(images)} photos: photogrammetry needs at least {_MIN_PHOTOS} "
|
|
142
|
+
"(ideally 20-60)",
|
|
143
|
+
str(src),
|
|
144
|
+
)
|
|
145
|
+
return Detection("unknown", None, "no photos, drawings or 3D files in the folder", str(src))
|
|
146
|
+
|
|
147
|
+
if not src.is_file():
|
|
148
|
+
return Detection("unknown", None, "the path does not exist", str(src))
|
|
149
|
+
ext = src.suffix.lower()
|
|
150
|
+
if ext in IMAGE_EXTENSIONS:
|
|
151
|
+
is_plan, why = looks_like_plan(src)
|
|
152
|
+
kind: InputKind = "plan" if is_plan else "photo"
|
|
153
|
+
return Detection(kind, _RECIPE_FOR[kind], why, str(src))
|
|
154
|
+
if ext == ".ply":
|
|
155
|
+
kind = "mesh" if _has_mesh_faces(src) else "point_cloud"
|
|
156
|
+
return Detection(
|
|
157
|
+
kind, "scan_to_cad", f"PLY {'mesh' if kind == 'mesh' else 'point cloud'}", str(src)
|
|
158
|
+
)
|
|
159
|
+
if ext in MESH_EXTENSIONS:
|
|
160
|
+
return Detection("mesh", "scan_to_cad", f"{ext[1:].upper()} mesh", str(src))
|
|
161
|
+
if ext in POINT_CLOUD_EXTENSIONS:
|
|
162
|
+
return Detection("point_cloud", "scan_to_cad", f"{ext[1:].upper()} point cloud", str(src))
|
|
163
|
+
return Detection("unknown", None, f"unsupported file type '{ext or src.name}'", str(src))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class _Attempt:
|
|
168
|
+
label: str
|
|
169
|
+
options: RecipeOptions
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _attempt_plan(detection: Detection, base: RecipeOptions) -> list[_Attempt]:
|
|
173
|
+
if detection.kind == "plan":
|
|
174
|
+
_, why = looks_like_plan(Path(detection.source))
|
|
175
|
+
methods: list[MaskMethod] = (
|
|
176
|
+
["alpha", "otsu"] if why.startswith("transparent") else ["otsu", "adaptive", "canny"]
|
|
177
|
+
)
|
|
178
|
+
return [
|
|
179
|
+
_Attempt(f"mask={m}", replace(base, mask_method=m, overwrite=base.overwrite or i > 0))
|
|
180
|
+
for i, m in enumerate(methods)
|
|
181
|
+
]
|
|
182
|
+
if detection.kind == "photos" and base.quality != "high":
|
|
183
|
+
return [
|
|
184
|
+
_Attempt("profile quality", base),
|
|
185
|
+
_Attempt("quality=high", replace(base, quality="high", overwrite=True)),
|
|
186
|
+
]
|
|
187
|
+
return [_Attempt("default", base)]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _mesh_from_points(detection: Detection, out: Path, settings: Settings, overwrite: bool) -> Path:
|
|
191
|
+
from reverse3d.reconstruction.pointcloud import poisson_mesh
|
|
192
|
+
|
|
193
|
+
target = out / "mesh_from_points.ply"
|
|
194
|
+
poisson_mesh(detection.source, target, settings, overwrite=overwrite)
|
|
195
|
+
return target
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def auto_run(
|
|
199
|
+
input_path: str | Path,
|
|
200
|
+
output_dir: str | Path,
|
|
201
|
+
settings: Settings,
|
|
202
|
+
options: RecipeOptions | None = None,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""Detect the input, run the right recipe (with smart retries) and write RESUMEN.md.
|
|
205
|
+
|
|
206
|
+
Returns the recipe summary plus an ``auto`` block (detection and attempts).
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
InputValidationError: the input is not something reverse3d can process.
|
|
210
|
+
CaptureQualityError: the photos must be retaken (with recommendations).
|
|
211
|
+
DependencyMissingError: a required tool or model is not available.
|
|
212
|
+
Reverse3DError: every attempt failed (the last error is raised).
|
|
213
|
+
"""
|
|
214
|
+
base = options or RecipeOptions()
|
|
215
|
+
detection = detect_input(input_path)
|
|
216
|
+
if detection.recipe is None:
|
|
217
|
+
raise InputValidationError(
|
|
218
|
+
f"Cannot process this input: {detection.reason}.",
|
|
219
|
+
hint="Use a folder with 20-60 photos, a drawing or photo, or an STL/OBJ/PLY/GLB file.",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
out = Path(output_dir).expanduser().resolve()
|
|
223
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
source = Path(detection.source)
|
|
225
|
+
if detection.kind == "point_cloud":
|
|
226
|
+
source = _mesh_from_points(detection, out, settings, base.overwrite)
|
|
227
|
+
|
|
228
|
+
history: list[dict[str, Any]] = []
|
|
229
|
+
summary: dict[str, Any] | None = None
|
|
230
|
+
last_error: Reverse3DError | None = None
|
|
231
|
+
for attempt in _attempt_plan(detection, base):
|
|
232
|
+
try:
|
|
233
|
+
summary = run_recipe(detection.recipe, source, out, settings, attempt.options)
|
|
234
|
+
except (CaptureQualityError, DependencyMissingError, InputValidationError) as exc:
|
|
235
|
+
history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
|
|
236
|
+
raise # the user has to act: retrying with other settings will not help
|
|
237
|
+
except (ProcessingError, ExternalToolError) as exc:
|
|
238
|
+
history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
|
|
239
|
+
last_error = exc
|
|
240
|
+
logger.info("Autopilot attempt '%s' failed: %s", attempt.label, exc.message)
|
|
241
|
+
continue
|
|
242
|
+
history.append({"attempt": attempt.label, "ok": True})
|
|
243
|
+
if (
|
|
244
|
+
detection.kind in ("mesh", "point_cloud")
|
|
245
|
+
and summary["dimensions"].get("watertight") is False
|
|
246
|
+
and attempt.options.repair == "normal"
|
|
247
|
+
):
|
|
248
|
+
try:
|
|
249
|
+
stronger = run_recipe(
|
|
250
|
+
detection.recipe,
|
|
251
|
+
source,
|
|
252
|
+
out,
|
|
253
|
+
settings,
|
|
254
|
+
replace(attempt.options, repair="strong", overwrite=True),
|
|
255
|
+
)
|
|
256
|
+
except Reverse3DError as exc:
|
|
257
|
+
history.append({"attempt": "repair=strong", "ok": False, "error": exc.message})
|
|
258
|
+
else:
|
|
259
|
+
history.append({"attempt": "repair=strong", "ok": True})
|
|
260
|
+
if stronger["dimensions"].get("watertight"):
|
|
261
|
+
summary = stronger
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
if summary is None:
|
|
265
|
+
raise last_error or ProcessingError("No processing attempt could run.")
|
|
266
|
+
|
|
267
|
+
summary["auto"] = {"detected": detection.to_dict(), "attempts": history}
|
|
268
|
+
summary["next_steps"] = next_steps(detection, summary, base)
|
|
269
|
+
summary["outputs"]["summary"] = str(write_summary(out, detection, summary))
|
|
270
|
+
return summary
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def next_steps(detection: Detection, summary: dict[str, Any], options: RecipeOptions) -> list[str]:
|
|
274
|
+
"""Plain-language suggestions (Spanish) for what to do with the results."""
|
|
275
|
+
outputs = summary.get("outputs", {})
|
|
276
|
+
steps: list[str] = []
|
|
277
|
+
if "cad_openscad" in outputs:
|
|
278
|
+
steps.append(
|
|
279
|
+
"Edita cad.scad en OpenSCAD (o cad.py con CadQuery) para ajustar medidas "
|
|
280
|
+
"y exportar STEP."
|
|
281
|
+
)
|
|
282
|
+
if "model" in outputs:
|
|
283
|
+
steps.append(
|
|
284
|
+
"Abre model.stl en tu laminador (Cura, PrusaSlicer, Bambu Studio…) para imprimirlo."
|
|
285
|
+
)
|
|
286
|
+
if detection.kind == "plan" and options.units_per_pixel is None:
|
|
287
|
+
steps.append(
|
|
288
|
+
"Las medidas están en píxeles: indica la escala con --units-per-pixel "
|
|
289
|
+
"para tener medidas reales."
|
|
290
|
+
)
|
|
291
|
+
if detection.kind == "photo":
|
|
292
|
+
steps.append(
|
|
293
|
+
"La profundidad con IA es relativa: usa --scale si necesitas un tamaño concreto."
|
|
294
|
+
)
|
|
295
|
+
if detection.kind == "photo":
|
|
296
|
+
steps.append(
|
|
297
|
+
"Un relieve es una superficie abierta: dale grosor en tu programa 3D "
|
|
298
|
+
"si lo vas a imprimir."
|
|
299
|
+
)
|
|
300
|
+
elif summary.get("dimensions", {}).get("watertight") is False:
|
|
301
|
+
steps.append("La malla no quedó cerrada (no estanca): revisa los avisos antes de imprimir.")
|
|
302
|
+
if "render" not in outputs:
|
|
303
|
+
steps.append(
|
|
304
|
+
"Instala Blender (o el extra render) para obtener una vista previa render.png."
|
|
305
|
+
)
|
|
306
|
+
return steps
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _fmt(value: Any) -> str:
|
|
310
|
+
if isinstance(value, (list, tuple)):
|
|
311
|
+
return " × ".join(f"{float(v):.2f}" for v in value)
|
|
312
|
+
if isinstance(value, float):
|
|
313
|
+
return f"{value:.2f}"
|
|
314
|
+
return str(value)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def write_summary(out: Path, detection: Detection, summary: dict[str, Any]) -> Path:
|
|
318
|
+
"""Write ``RESUMEN.md``: a human-readable report next to the results."""
|
|
319
|
+
dims = summary.get("dimensions", {})
|
|
320
|
+
lines = [
|
|
321
|
+
f"# reverse3d · {Path(detection.source).name}",
|
|
322
|
+
"",
|
|
323
|
+
f"- **Detectado:** {KIND_LABEL_ES[detection.kind]}",
|
|
324
|
+
f"- **Flujo:** `{summary.get('recipe')}` · {str(summary.get('device', 'cpu')).upper()}"
|
|
325
|
+
f" · perfil {summary.get('profile')}",
|
|
326
|
+
f"- **Tiempo:** {summary.get('seconds')} s",
|
|
327
|
+
"",
|
|
328
|
+
"## Medidas (unidades del modelo)",
|
|
329
|
+
"",
|
|
330
|
+
f"- Tamaño (caja orientada): {_fmt(dims.get('size_obb', '—'))}",
|
|
331
|
+
f"- Volumen: {_fmt(dims.get('volume', '—'))}",
|
|
332
|
+
f"- Superficie: {_fmt(dims.get('area', '—'))}",
|
|
333
|
+
f"- Cerrada (estanca): {'sí' if dims.get('watertight') else 'no'}",
|
|
334
|
+
"",
|
|
335
|
+
"## Archivos",
|
|
336
|
+
"",
|
|
337
|
+
]
|
|
338
|
+
lines += [
|
|
339
|
+
f"- {key}: `{Path(path).name}`" for key, path in summary.get("outputs", {}).items() if path
|
|
340
|
+
]
|
|
341
|
+
lines.append("- summary: `RESUMEN.md`")
|
|
342
|
+
if summary.get("primitives"):
|
|
343
|
+
lines += ["", "## Geometría detectada", ""]
|
|
344
|
+
lines += [
|
|
345
|
+
f"- {p.get('kind')}: "
|
|
346
|
+
+ ", ".join(f"{k}={_fmt(v)}" for k, v in p.items() if k != "kind")
|
|
347
|
+
for p in summary["primitives"]
|
|
348
|
+
]
|
|
349
|
+
if summary.get("warnings"):
|
|
350
|
+
lines += ["", "## Avisos", ""] + [f"- {w}" for w in summary["warnings"]]
|
|
351
|
+
if summary.get("next_steps"):
|
|
352
|
+
lines += ["", "## Siguiente paso", ""] + [f"- {s}" for s in summary["next_steps"]]
|
|
353
|
+
path = out / "RESUMEN.md"
|
|
354
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
355
|
+
return path
|