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/watch.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Watched inbox: drop files in a folder, get results without typing anything.
|
|
2
|
+
|
|
3
|
+
``reverse3d watch`` keeps an eye on ``<workspace>/bandeja``. Whatever you drop
|
|
4
|
+
there (a folder of photos, a drawing, a photo, an STL...) is processed with the
|
|
5
|
+
autopilot (:func:`reverse3d.autopilot.auto_run`) once it has finished copying:
|
|
6
|
+
|
|
7
|
+
* results go to ``<workspace>/resultados/<name>/`` together with ``RESUMEN.md``;
|
|
8
|
+
* the original moves to ``bandeja/_procesado/``;
|
|
9
|
+
* if it cannot be processed it moves to ``bandeja/_revisar/`` with a
|
|
10
|
+
``<name>.LEEME.txt`` explaining what to change.
|
|
11
|
+
|
|
12
|
+
It needs no internet and no AI model, so it keeps working offline. Polling is
|
|
13
|
+
used instead of OS file events: it is portable and has no extra dependency.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import shutil
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from reverse3d.config import Settings
|
|
27
|
+
from reverse3d.errors import (
|
|
28
|
+
CaptureQualityError,
|
|
29
|
+
DependencyMissingError,
|
|
30
|
+
InputValidationError,
|
|
31
|
+
Reverse3DError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
INBOX_NAME = "bandeja"
|
|
37
|
+
OUTBOX_NAME = "resultados"
|
|
38
|
+
DONE_DIR = "_procesado"
|
|
39
|
+
REVIEW_DIR = "_revisar"
|
|
40
|
+
#: Partial downloads and editor temp files are never picked up.
|
|
41
|
+
_TEMP_SUFFIXES = (".part", ".crdownload", ".tmp", ".download", ".partial", ".lock")
|
|
42
|
+
_MAX_SCAN_FILES = 2000
|
|
43
|
+
|
|
44
|
+
Event = dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class WatchFolders:
|
|
49
|
+
inbox: Path
|
|
50
|
+
outbox: Path
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def for_workspace(cls, settings: Settings) -> WatchFolders:
|
|
54
|
+
return cls(settings.workspace / INBOX_NAME, settings.workspace / OUTBOX_NAME)
|
|
55
|
+
|
|
56
|
+
def ensure(self) -> None:
|
|
57
|
+
for folder in (self.inbox, self.outbox, self.inbox / DONE_DIR, self.inbox / REVIEW_DIR):
|
|
58
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _Tracker:
|
|
63
|
+
"""Remembers each item's fingerprint to know when it has finished copying."""
|
|
64
|
+
|
|
65
|
+
seen: dict[str, tuple[int, int, int]] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_candidate(path: Path) -> bool:
|
|
69
|
+
name = path.name
|
|
70
|
+
if name.startswith((".", "_", "~$")) or name.lower().endswith(_TEMP_SUFFIXES):
|
|
71
|
+
return False
|
|
72
|
+
return path.is_dir() or path.is_file()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fingerprint(path: Path) -> tuple[int, int, int]:
|
|
76
|
+
if path.is_file():
|
|
77
|
+
stat = path.stat()
|
|
78
|
+
return (1, stat.st_size, stat.st_mtime_ns)
|
|
79
|
+
count = size = newest = 0
|
|
80
|
+
for i, file in enumerate(path.rglob("*")):
|
|
81
|
+
if i >= _MAX_SCAN_FILES:
|
|
82
|
+
break
|
|
83
|
+
if file.is_file():
|
|
84
|
+
stat = file.stat()
|
|
85
|
+
count += 1
|
|
86
|
+
size += stat.st_size
|
|
87
|
+
newest = max(newest, stat.st_mtime_ns)
|
|
88
|
+
return (count, size, newest)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _unique(path: Path) -> Path:
|
|
92
|
+
if not path.exists():
|
|
93
|
+
return path
|
|
94
|
+
for n in range(2, 10_000):
|
|
95
|
+
candidate = (
|
|
96
|
+
path.with_name(f"{path.stem}-{n}{path.suffix}")
|
|
97
|
+
if path.suffix
|
|
98
|
+
else path.with_name(f"{path.name}-{n}")
|
|
99
|
+
)
|
|
100
|
+
if not candidate.exists():
|
|
101
|
+
return candidate
|
|
102
|
+
raise RuntimeError(f"Too many items named {path.name}") # pragma: no cover
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def advice_es(error: Reverse3DError) -> str:
|
|
106
|
+
"""Plain Spanish advice for the user, by kind of problem."""
|
|
107
|
+
if isinstance(error, CaptureQualityError):
|
|
108
|
+
return (
|
|
109
|
+
"Las fotos no sirven todavía para reconstruir el objeto. Repítelas con buena luz, "
|
|
110
|
+
"sin desenfoque, rodeando el objeto y con mucho solape entre una foto y la siguiente "
|
|
111
|
+
"(20-60 fotos)."
|
|
112
|
+
)
|
|
113
|
+
if isinstance(error, DependencyMissingError):
|
|
114
|
+
return f"Falta un componente en este equipo. {error.hint or ''}".strip()
|
|
115
|
+
if isinstance(error, InputValidationError):
|
|
116
|
+
return (
|
|
117
|
+
"Deja una carpeta con 20-60 fotos del objeto, una imagen (plano, silueta o foto) "
|
|
118
|
+
"o un archivo 3D: STL, OBJ, PLY, GLB, XYZ o PCD."
|
|
119
|
+
)
|
|
120
|
+
return "Revisa el detalle técnico de abajo; si el problema sigue, abre un issue en GitHub."
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _review_note(item: Path, error: Reverse3DError) -> str:
|
|
124
|
+
lines = [
|
|
125
|
+
f"reverse3d no pudo procesar «{item.name}».",
|
|
126
|
+
"",
|
|
127
|
+
f"Qué hacer: {advice_es(error)}",
|
|
128
|
+
"",
|
|
129
|
+
"Cuando lo corrijas, vuelve a dejarlo en la carpeta «bandeja»: se procesará solo.",
|
|
130
|
+
"",
|
|
131
|
+
"Detalle técnico:",
|
|
132
|
+
f" {error.message}",
|
|
133
|
+
]
|
|
134
|
+
if error.hint:
|
|
135
|
+
lines.append(f" {error.hint}")
|
|
136
|
+
return "\n".join(lines) + "\n"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def process_item(item: Path, folders: WatchFolders, settings: Settings) -> Event:
|
|
140
|
+
"""Process one inbox item and move it to _procesado or _revisar."""
|
|
141
|
+
from reverse3d.autopilot import auto_run, detect_input
|
|
142
|
+
|
|
143
|
+
detection = detect_input(item)
|
|
144
|
+
stem = item.stem if item.is_file() else item.name
|
|
145
|
+
out = _unique(folders.outbox / stem)
|
|
146
|
+
try:
|
|
147
|
+
summary = auto_run(item, out, settings)
|
|
148
|
+
except Reverse3DError as exc:
|
|
149
|
+
target = _unique(folders.inbox / REVIEW_DIR / item.name)
|
|
150
|
+
shutil.move(str(item), str(target))
|
|
151
|
+
note = target.with_name(f"{target.name}.LEEME.txt")
|
|
152
|
+
note.write_text(_review_note(item, exc), encoding="utf-8")
|
|
153
|
+
if out.exists() and not any(out.iterdir()):
|
|
154
|
+
out.rmdir()
|
|
155
|
+
return {
|
|
156
|
+
"event": "failed",
|
|
157
|
+
"item": item.name,
|
|
158
|
+
"kind": detection.kind,
|
|
159
|
+
"error": exc.to_dict(),
|
|
160
|
+
"advice": advice_es(exc),
|
|
161
|
+
"moved_to": str(target),
|
|
162
|
+
"note": str(note),
|
|
163
|
+
}
|
|
164
|
+
target = _unique(folders.inbox / DONE_DIR / item.name)
|
|
165
|
+
shutil.move(str(item), str(target))
|
|
166
|
+
return {
|
|
167
|
+
"event": "done",
|
|
168
|
+
"item": item.name,
|
|
169
|
+
"kind": detection.kind,
|
|
170
|
+
"recipe": summary.get("recipe"),
|
|
171
|
+
"output_dir": str(out),
|
|
172
|
+
"summary": summary["outputs"].get("summary"),
|
|
173
|
+
"dimensions": summary.get("dimensions", {}),
|
|
174
|
+
"warnings": summary.get("warnings", []),
|
|
175
|
+
"moved_to": str(target),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def scan(
|
|
180
|
+
folders: WatchFolders,
|
|
181
|
+
settings: Settings,
|
|
182
|
+
tracker: _Tracker,
|
|
183
|
+
*,
|
|
184
|
+
settle: bool = True,
|
|
185
|
+
on_event: Callable[[Event], None] | None = None,
|
|
186
|
+
) -> list[Event]:
|
|
187
|
+
"""Look at the inbox once and process every item that has finished copying.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
settle: when True an item is processed only after two scans with the
|
|
191
|
+
same size and modification time (it is still being copied otherwise).
|
|
192
|
+
"""
|
|
193
|
+
emit = on_event or (lambda _e: None)
|
|
194
|
+
events: list[Event] = []
|
|
195
|
+
for item in sorted(p for p in folders.inbox.iterdir() if _is_candidate(p)):
|
|
196
|
+
key = str(item)
|
|
197
|
+
try:
|
|
198
|
+
fingerprint = _fingerprint(item)
|
|
199
|
+
except OSError:
|
|
200
|
+
continue
|
|
201
|
+
if settle and tracker.seen.get(key) != fingerprint:
|
|
202
|
+
if key not in tracker.seen:
|
|
203
|
+
emit({"event": "detected", "item": item.name})
|
|
204
|
+
tracker.seen[key] = fingerprint
|
|
205
|
+
continue
|
|
206
|
+
tracker.seen.pop(key, None)
|
|
207
|
+
emit({"event": "processing", "item": item.name})
|
|
208
|
+
try:
|
|
209
|
+
event = process_item(item, folders, settings)
|
|
210
|
+
except OSError as exc: # e.g. the file is still locked by another program
|
|
211
|
+
event = {"event": "retry", "item": item.name, "error": str(exc)}
|
|
212
|
+
events.append(event)
|
|
213
|
+
emit(event)
|
|
214
|
+
return events
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def watch(
|
|
218
|
+
folders: WatchFolders,
|
|
219
|
+
settings: Settings,
|
|
220
|
+
*,
|
|
221
|
+
interval_s: float = 2.0,
|
|
222
|
+
once: bool = False,
|
|
223
|
+
on_event: Callable[[Event], None] | None = None,
|
|
224
|
+
stop: Callable[[], bool] | None = None,
|
|
225
|
+
) -> list[Event]:
|
|
226
|
+
"""Watch the inbox until ``stop()`` returns True (or Ctrl+C).
|
|
227
|
+
|
|
228
|
+
With ``once=True`` it processes what is already in the inbox and returns.
|
|
229
|
+
"""
|
|
230
|
+
folders.ensure()
|
|
231
|
+
tracker = _Tracker()
|
|
232
|
+
if once:
|
|
233
|
+
return scan(folders, settings, tracker, settle=False, on_event=on_event)
|
|
234
|
+
events: list[Event] = []
|
|
235
|
+
while not (stop and stop()):
|
|
236
|
+
events.extend(scan(folders, settings, tracker, on_event=on_event))
|
|
237
|
+
time.sleep(interval_s)
|
|
238
|
+
return events
|