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/mesh_io.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Safe mesh / point-cloud I/O on top of trimesh.
|
|
2
|
+
|
|
3
|
+
Security notes:
|
|
4
|
+
|
|
5
|
+
* Only allowlisted formats are parsed (no pickle-based or executable formats).
|
|
6
|
+
* ``.gltf`` and ``.obj`` may reference sibling files (buffers, textures, .mtl).
|
|
7
|
+
:class:`SandboxedResolver` only serves files located inside the model's own
|
|
8
|
+
directory, so a crafted model cannot read ``../../secrets`` (CWE-22).
|
|
9
|
+
* Face counts are bounded by ``Settings.max_mesh_faces`` after loading.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import unquote
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import trimesh
|
|
21
|
+
from trimesh.resolvers import FilePathResolver
|
|
22
|
+
|
|
23
|
+
from reverse3d.config import Settings
|
|
24
|
+
from reverse3d.errors import InputValidationError, SecurityViolationError
|
|
25
|
+
from reverse3d.security import (
|
|
26
|
+
MESH_EXTENSIONS,
|
|
27
|
+
POINT_CLOUD_EXTENSIONS,
|
|
28
|
+
prepare_output_file,
|
|
29
|
+
validate_input_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
Geometry = trimesh.Trimesh | trimesh.PointCloud
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SandboxedResolver(FilePathResolver):
|
|
38
|
+
"""A trimesh resolver that refuses to read or write outside its directory."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, source: str | Path) -> None:
|
|
41
|
+
super().__init__(str(source))
|
|
42
|
+
self._root = Path(self.parent).resolve()
|
|
43
|
+
|
|
44
|
+
def _check(self, name: str) -> None:
|
|
45
|
+
for variant in {name, unquote(name)}:
|
|
46
|
+
target = (self._root / variant.strip()).resolve()
|
|
47
|
+
if target != self._root and not target.is_relative_to(self._root):
|
|
48
|
+
raise SecurityViolationError("Model references a file outside its own directory.")
|
|
49
|
+
|
|
50
|
+
def get(self, name: str) -> bytes:
|
|
51
|
+
self._check(name)
|
|
52
|
+
return super().get(name)
|
|
53
|
+
|
|
54
|
+
def write(self, name: str, data: bytes | str) -> None:
|
|
55
|
+
self._check(name)
|
|
56
|
+
super().write(name, data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_faces(mesh: trimesh.Trimesh, settings: Settings) -> None:
|
|
60
|
+
if len(mesh.faces) > settings.max_mesh_faces:
|
|
61
|
+
raise InputValidationError(
|
|
62
|
+
f"Mesh has too many faces ({len(mesh.faces):,} > {settings.max_mesh_faces:,}).",
|
|
63
|
+
hint="Decimate it first or raise REVERSE3D_MAX_MESH_FACES.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_geometry(path: str | Path, settings: Settings) -> Geometry:
|
|
68
|
+
"""Load a mesh or point cloud from an allowlisted format.
|
|
69
|
+
|
|
70
|
+
Scenes (glTF/GLB with many nodes) are flattened into a single mesh with
|
|
71
|
+
node transforms applied.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
InputValidationError: unsupported, unreadable, empty or oversize input.
|
|
75
|
+
"""
|
|
76
|
+
allowed = MESH_EXTENSIONS | POINT_CLOUD_EXTENSIONS
|
|
77
|
+
resolved = validate_input_file(
|
|
78
|
+
path, allowed_extensions=allowed, max_bytes=settings.max_input_bytes
|
|
79
|
+
)
|
|
80
|
+
loaded: Any
|
|
81
|
+
try:
|
|
82
|
+
loaded = trimesh.load(
|
|
83
|
+
str(resolved),
|
|
84
|
+
file_type=resolved.suffix.lower().lstrip("."),
|
|
85
|
+
resolver=SandboxedResolver(resolved),
|
|
86
|
+
# Merge duplicated vertices: STL stores unshared triangle soups and
|
|
87
|
+
# topology metrics (watertight, volume, holes) would be meaningless.
|
|
88
|
+
process=True,
|
|
89
|
+
)
|
|
90
|
+
except SecurityViolationError:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc: # trimesh raises many exception types for bad files
|
|
93
|
+
raise InputValidationError(f"Could not parse geometry file: {resolved.name}") from exc
|
|
94
|
+
|
|
95
|
+
if isinstance(loaded, trimesh.Scene):
|
|
96
|
+
if not loaded.geometry:
|
|
97
|
+
raise InputValidationError(f"Scene contains no geometry: {resolved.name}")
|
|
98
|
+
loaded = loaded.to_mesh() if hasattr(loaded, "to_mesh") else loaded.dump(concatenate=True)
|
|
99
|
+
|
|
100
|
+
if isinstance(loaded, trimesh.Trimesh):
|
|
101
|
+
if len(loaded.faces) == 0:
|
|
102
|
+
# A PLY with vertices only is a point cloud.
|
|
103
|
+
if len(loaded.vertices) == 0:
|
|
104
|
+
raise InputValidationError(f"Empty geometry: {resolved.name}")
|
|
105
|
+
return trimesh.PointCloud(loaded.vertices)
|
|
106
|
+
_check_faces(loaded, settings)
|
|
107
|
+
return loaded
|
|
108
|
+
if isinstance(loaded, trimesh.PointCloud):
|
|
109
|
+
if len(loaded.vertices) == 0:
|
|
110
|
+
raise InputValidationError(f"Empty point cloud: {resolved.name}")
|
|
111
|
+
return loaded
|
|
112
|
+
raise InputValidationError(f"Unsupported geometry type in {resolved.name}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_mesh(path: str | Path, settings: Settings) -> trimesh.Trimesh:
|
|
116
|
+
"""Like :func:`load_geometry` but requires a triangle mesh."""
|
|
117
|
+
geometry = load_geometry(path, settings)
|
|
118
|
+
if not isinstance(geometry, trimesh.Trimesh):
|
|
119
|
+
raise InputValidationError(
|
|
120
|
+
"File contains a point cloud, not a mesh.",
|
|
121
|
+
hint="Use the photogrammetry/meshing tools to build a surface first.",
|
|
122
|
+
)
|
|
123
|
+
return geometry
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_mesh(mesh: trimesh.Trimesh, path: str | Path, *, overwrite: bool = False) -> Path:
|
|
127
|
+
"""Export ``mesh`` to an allowlisted format inferred from the extension."""
|
|
128
|
+
out = prepare_output_file(path, allowed_extensions=MESH_EXTENSIONS, overwrite=overwrite)
|
|
129
|
+
file_type = out.suffix.lower().lstrip(".")
|
|
130
|
+
if file_type == "gltf":
|
|
131
|
+
# Embed buffers so the export is a single self-contained file.
|
|
132
|
+
data = trimesh.exchange.gltf.export_gltf(mesh.scene(), embed_buffers=True)
|
|
133
|
+
out.write_bytes(data["model.gltf"])
|
|
134
|
+
else:
|
|
135
|
+
mesh.export(str(out), file_type=file_type)
|
|
136
|
+
logger.info("Saved mesh %s (%d faces)", out.name, len(mesh.faces))
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sample_points(
|
|
141
|
+
geometry: Geometry, count: int, *, seed: int = 0
|
|
142
|
+
) -> tuple[np.ndarray, np.ndarray | None]:
|
|
143
|
+
"""Sample up to ``count`` points (and normals for meshes) from ``geometry``."""
|
|
144
|
+
if isinstance(geometry, trimesh.Trimesh):
|
|
145
|
+
points, face_idx = trimesh.sample.sample_surface(geometry, count, seed=seed)
|
|
146
|
+
normals = geometry.face_normals[face_idx]
|
|
147
|
+
return np.asarray(points, dtype=np.float64), np.asarray(normals, dtype=np.float64)
|
|
148
|
+
vertices = np.asarray(geometry.vertices, dtype=np.float64)
|
|
149
|
+
if len(vertices) > count:
|
|
150
|
+
rng = np.random.default_rng(seed)
|
|
151
|
+
vertices = vertices[rng.choice(len(vertices), count, replace=False)]
|
|
152
|
+
return vertices, None
|
reverse3d/offline.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Offline mode: keep working without internet and switch to it automatically.
|
|
2
|
+
|
|
3
|
+
Everything reverse3d computes runs locally (OpenCV, COLMAP, Open3D, trimesh,
|
|
4
|
+
Blender). The only part that ever needs the network is the AI depth model,
|
|
5
|
+
downloaded from Hugging Face the first time it is used. This module removes
|
|
6
|
+
that dependency:
|
|
7
|
+
|
|
8
|
+
* :func:`has_internet` is a cheap, cached TCP probe (no HTTP, no data sent).
|
|
9
|
+
* Models live in ``~/.reverse3d/models``. :func:`prepare` downloads the
|
|
10
|
+
allowlisted ones in advance (``reverse3d offline prepare``; the installer runs
|
|
11
|
+
it too), fetching only ``*.safetensors`` weights and small config files.
|
|
12
|
+
* :func:`resolve_model` always prefers the local copy, so a prepared machine
|
|
13
|
+
never touches the network, and fails with a clear hint when a model is
|
|
14
|
+
missing and there is no connection.
|
|
15
|
+
* :func:`offline_status` tells the user, in one call, what works offline.
|
|
16
|
+
|
|
17
|
+
``Settings.offline`` (``REVERSE3D_OFFLINE`` / ``reverse3d config set offline``):
|
|
18
|
+
``auto`` (default) detects the connection, ``on`` never uses the network,
|
|
19
|
+
``off`` skips the probe and assumes the network is available.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib.util
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import socket
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from reverse3d.config import Settings
|
|
33
|
+
from reverse3d.errors import DependencyMissingError, InputValidationError, Reverse3DError
|
|
34
|
+
from reverse3d.userconfig import home_dir
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
#: Hosts probed to decide whether the machine is online. Hugging Face is the
|
|
39
|
+
#: only service reverse3d downloads from at run time; 1.1.1.1 covers networks
|
|
40
|
+
#: where it is blocked but the internet works.
|
|
41
|
+
_PROBE_HOSTS: tuple[tuple[str, int], ...] = (("huggingface.co", 443), ("1.1.1.1", 443))
|
|
42
|
+
_PROBE_TIMEOUT_S = 1.5
|
|
43
|
+
_CACHE_TTL_S = 60.0
|
|
44
|
+
_cache: tuple[float, bool] | None = None
|
|
45
|
+
|
|
46
|
+
#: Only these files are downloaded: safetensors weights and small configs.
|
|
47
|
+
#: Pickled ``*.bin`` checkpoints (code execution on load) are never fetched.
|
|
48
|
+
MODEL_FILE_PATTERNS = ["*.json", "*.safetensors", "*.txt"]
|
|
49
|
+
|
|
50
|
+
#: Pinned Hugging Face commit of every allowlisted model: only this exact
|
|
51
|
+
#: revision is downloaded or accepted from disk, so a changed upstream
|
|
52
|
+
#: repository cannot alter what runs (bump together with a review).
|
|
53
|
+
MODEL_REVISIONS: dict[str, str] = {
|
|
54
|
+
"depth-anything/Depth-Anything-V2-Small-hf": "5426e4f0f36572d16453bbda7a8389317b1bef99",
|
|
55
|
+
"depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
|
|
56
|
+
"depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def models_dir() -> Path:
|
|
61
|
+
"""Folder that holds the downloaded AI models (``~/.reverse3d/models``)."""
|
|
62
|
+
return home_dir() / "models"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _reachable(hosts: tuple[tuple[str, int], ...]) -> bool:
|
|
66
|
+
for host, port in hosts:
|
|
67
|
+
try:
|
|
68
|
+
with socket.create_connection((host, port), timeout=_PROBE_TIMEOUT_S):
|
|
69
|
+
return True
|
|
70
|
+
except OSError:
|
|
71
|
+
continue
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _probe() -> bool:
|
|
76
|
+
return _reachable(_PROBE_HOSTS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def has_internet(*, refresh: bool = False) -> bool:
|
|
80
|
+
"""Return True if the internet is reachable. The answer is cached for a minute."""
|
|
81
|
+
global _cache
|
|
82
|
+
now = time.monotonic()
|
|
83
|
+
if not refresh and _cache is not None and now - _cache[0] < _CACHE_TTL_S:
|
|
84
|
+
return _cache[1]
|
|
85
|
+
online = _probe()
|
|
86
|
+
_cache = (now, online)
|
|
87
|
+
if not online:
|
|
88
|
+
logger.info("No internet connection detected: working offline.")
|
|
89
|
+
return online
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_offline(settings: Settings) -> bool:
|
|
93
|
+
"""Whether reverse3d must avoid the network right now."""
|
|
94
|
+
if settings.offline == "on":
|
|
95
|
+
return True
|
|
96
|
+
if settings.offline == "off":
|
|
97
|
+
return False
|
|
98
|
+
return not has_internet()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _allowed_models() -> dict[str, tuple[str, str]]:
|
|
102
|
+
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
103
|
+
|
|
104
|
+
return dict(ALLOWED_MODELS)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _default_model() -> str:
|
|
108
|
+
from reverse3d.reconstruction.depth_estimation import DEFAULT_MODEL
|
|
109
|
+
|
|
110
|
+
return DEFAULT_MODEL
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cache_folder(model_id: str) -> Path:
|
|
114
|
+
return models_dir() / ("models--" + model_id.replace("/", "--"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def local_model_path(model_id: str) -> Path | None:
|
|
118
|
+
"""Return the local snapshot folder of ``model_id`` if its weights are on disk.
|
|
119
|
+
|
|
120
|
+
Pure filesystem check: it never imports Hugging Face nor touches the network.
|
|
121
|
+
"""
|
|
122
|
+
revision = MODEL_REVISIONS.get(model_id)
|
|
123
|
+
if revision is None:
|
|
124
|
+
return None
|
|
125
|
+
snapshot = _cache_folder(model_id) / "snapshots" / revision
|
|
126
|
+
return snapshot if snapshot.is_dir() and any(snapshot.glob("*.safetensors")) else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _folder_mb(path: Path) -> float:
|
|
130
|
+
total = 0
|
|
131
|
+
for file in path.rglob("*"):
|
|
132
|
+
try:
|
|
133
|
+
if file.is_file():
|
|
134
|
+
total += file.stat().st_size # resolves the symlinked blobs
|
|
135
|
+
except OSError:
|
|
136
|
+
continue
|
|
137
|
+
return round(total / (1024 * 1024), 1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _depth_stack_installed() -> bool:
|
|
141
|
+
return all(
|
|
142
|
+
importlib.util.find_spec(m) is not None
|
|
143
|
+
for m in ("torch", "transformers", "huggingface_hub")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _download(model_id: str) -> Path:
|
|
148
|
+
from huggingface_hub import snapshot_download
|
|
149
|
+
|
|
150
|
+
path = snapshot_download(
|
|
151
|
+
model_id,
|
|
152
|
+
revision=MODEL_REVISIONS[model_id],
|
|
153
|
+
cache_dir=str(models_dir()),
|
|
154
|
+
allow_patterns=MODEL_FILE_PATTERNS,
|
|
155
|
+
)
|
|
156
|
+
return Path(path)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_model(model_id: str, settings: Settings) -> Path:
|
|
160
|
+
"""Return a local folder with the files of an allowlisted model.
|
|
161
|
+
|
|
162
|
+
Uses the local copy when there is one (no network at all). Otherwise it
|
|
163
|
+
downloads the model if the machine is online.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
InputValidationError: the model is not in the allowlist.
|
|
167
|
+
DependencyMissingError: the model is not on disk and cannot be downloaded
|
|
168
|
+
(offline, or the download failed).
|
|
169
|
+
"""
|
|
170
|
+
if model_id not in _allowed_models() or model_id not in MODEL_REVISIONS:
|
|
171
|
+
raise InputValidationError(
|
|
172
|
+
f"Model '{model_id}' is not in the allowlist.",
|
|
173
|
+
hint=f"Allowed: {', '.join(_allowed_models())}",
|
|
174
|
+
)
|
|
175
|
+
local = local_model_path(model_id)
|
|
176
|
+
if local is not None:
|
|
177
|
+
return local
|
|
178
|
+
if is_offline(settings):
|
|
179
|
+
raise DependencyMissingError(
|
|
180
|
+
"The AI depth model is not on this computer and there is no internet connection.",
|
|
181
|
+
hint="Connect to the internet once and run: reverse3d offline prepare",
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
return _download(model_id)
|
|
185
|
+
except ImportError as exc:
|
|
186
|
+
raise DependencyMissingError(
|
|
187
|
+
"huggingface_hub is required to download AI models.",
|
|
188
|
+
hint='pip install "reverse3d[depth]"',
|
|
189
|
+
) from exc
|
|
190
|
+
except Exception as exc: # network errors come in many shapes; report them plainly
|
|
191
|
+
has_internet(refresh=True)
|
|
192
|
+
raise DependencyMissingError(
|
|
193
|
+
f"Could not download the AI depth model ({exc.__class__.__name__}).",
|
|
194
|
+
hint="Check your connection and run: reverse3d offline prepare",
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict[str, Any]:
|
|
199
|
+
"""Download the allowlisted AI models so reverse3d works without internet.
|
|
200
|
+
|
|
201
|
+
Safe to run any time: models already on disk are skipped. It never raises
|
|
202
|
+
for a missing optional stack; that is reported in the result instead.
|
|
203
|
+
"""
|
|
204
|
+
models = [_default_model()]
|
|
205
|
+
if include_non_commercial:
|
|
206
|
+
models += [m for m in _allowed_models() if m not in models]
|
|
207
|
+
|
|
208
|
+
if not _depth_stack_installed():
|
|
209
|
+
return {
|
|
210
|
+
"ready": True,
|
|
211
|
+
"models_dir": str(models_dir()),
|
|
212
|
+
"models": [
|
|
213
|
+
{"model": m, "status": "skipped", "detail": "AI depth extra not installed"}
|
|
214
|
+
for m in models
|
|
215
|
+
],
|
|
216
|
+
"message": "Nothing to download: AI depth is not installed and everything else "
|
|
217
|
+
"already works offline.",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
results: list[dict[str, Any]] = []
|
|
221
|
+
online = not is_offline(settings)
|
|
222
|
+
for model_id in models:
|
|
223
|
+
local = local_model_path(model_id)
|
|
224
|
+
if local is not None:
|
|
225
|
+
results.append(
|
|
226
|
+
{
|
|
227
|
+
"model": model_id,
|
|
228
|
+
"status": "ready",
|
|
229
|
+
"path": str(local),
|
|
230
|
+
"size_mb": _folder_mb(local),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
if not online:
|
|
235
|
+
results.append(
|
|
236
|
+
{
|
|
237
|
+
"model": model_id,
|
|
238
|
+
"status": "missing",
|
|
239
|
+
"detail": "No internet connection: connect and run this again.",
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
path = resolve_model(model_id, settings)
|
|
245
|
+
except Reverse3DError as exc:
|
|
246
|
+
results.append({"model": model_id, "status": "failed", "detail": exc.message})
|
|
247
|
+
continue
|
|
248
|
+
results.append(
|
|
249
|
+
{
|
|
250
|
+
"model": model_id,
|
|
251
|
+
"status": "downloaded",
|
|
252
|
+
"path": str(path),
|
|
253
|
+
"size_mb": _folder_mb(path),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
ready = all(r["status"] in ("ready", "downloaded") for r in results)
|
|
258
|
+
return {
|
|
259
|
+
"ready": ready,
|
|
260
|
+
"models_dir": str(models_dir()),
|
|
261
|
+
"models": results,
|
|
262
|
+
"message": (
|
|
263
|
+
"Ready: reverse3d now works without internet."
|
|
264
|
+
if ready
|
|
265
|
+
else "Some models are missing: connect to the internet and run "
|
|
266
|
+
"`reverse3d offline prepare`."
|
|
267
|
+
),
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def offline_status(settings: Settings) -> dict[str, Any]:
|
|
272
|
+
"""Report what works right now and what would work without internet."""
|
|
273
|
+
from reverse3d.doctor import environment_report
|
|
274
|
+
|
|
275
|
+
env = environment_report(settings, include_offline=False)
|
|
276
|
+
caps: dict[str, bool] = env["capabilities"] # type: ignore[assignment]
|
|
277
|
+
depth_installed = bool(caps.get("depth_estimation"))
|
|
278
|
+
model_on_disk = local_model_path(_default_model()) is not None
|
|
279
|
+
internet = None if settings.offline != "auto" else has_internet()
|
|
280
|
+
|
|
281
|
+
components = {
|
|
282
|
+
"measure_repair_cad": {"available": True, "offline": True},
|
|
283
|
+
"plan_to_part": {"available": True, "offline": True},
|
|
284
|
+
"photogrammetry": {"available": caps.get("photogrammetry", False), "offline": True},
|
|
285
|
+
"dense_meshing": {"available": caps.get("dense_meshing", False), "offline": True},
|
|
286
|
+
"render": {
|
|
287
|
+
"available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
|
|
288
|
+
"offline": True,
|
|
289
|
+
},
|
|
290
|
+
"ai_depth": {"available": depth_installed, "offline": depth_installed and model_on_disk},
|
|
291
|
+
}
|
|
292
|
+
missing = []
|
|
293
|
+
if depth_installed and not model_on_disk:
|
|
294
|
+
missing.append(
|
|
295
|
+
"AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
|
|
296
|
+
)
|
|
297
|
+
working_offline = is_offline(settings)
|
|
298
|
+
return {
|
|
299
|
+
"mode": settings.offline,
|
|
300
|
+
"internet": internet,
|
|
301
|
+
"working_offline": working_offline,
|
|
302
|
+
"ready_for_offline": not missing,
|
|
303
|
+
"models_dir": str(models_dir()),
|
|
304
|
+
"components": components,
|
|
305
|
+
"missing": missing,
|
|
306
|
+
"message": ("Everything installed works without internet." if not missing else missing[0]),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def force_offline_env() -> None:
|
|
311
|
+
"""Tell Hugging Face libraries not to reach the network in this process."""
|
|
312
|
+
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
313
|
+
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
reverse3d/profiles.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Performance profiles: one knob that trades speed for quality everywhere.
|
|
2
|
+
|
|
3
|
+
``fast`` is for previews and weak machines, ``balanced`` is the default and
|
|
4
|
+
``quality`` is for final deliverables. Explicit tool arguments always win over
|
|
5
|
+
the profile.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from reverse3d.config import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ProfileValues:
|
|
17
|
+
photo_quality: str
|
|
18
|
+
render_samples: int
|
|
19
|
+
render_width: int
|
|
20
|
+
render_height: int
|
|
21
|
+
depth_stride: int
|
|
22
|
+
fit_samples: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PROFILES: dict[str, ProfileValues] = {
|
|
26
|
+
"fast": ProfileValues("low", 16, 960, 720, 4, 8_000),
|
|
27
|
+
"balanced": ProfileValues("medium", 64, 1600, 1200, 2, 20_000),
|
|
28
|
+
"quality": ProfileValues("high", 256, 2400, 1800, 1, 50_000),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def profile_values(settings: Settings) -> ProfileValues:
|
|
33
|
+
return PROFILES[settings.profile]
|
reverse3d/py.typed
ADDED
|
File without changes
|