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/config.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Runtime settings.
|
|
2
|
+
|
|
3
|
+
Precedence (lowest → highest): built-in defaults → user config file
|
|
4
|
+
(``~/.reverse3d/config.json``, managed with ``reverse3d config``) → environment
|
|
5
|
+
variables. :meth:`Settings.load` applies all three; :meth:`Settings.from_env`
|
|
6
|
+
reads only the given mapping (useful in tests).
|
|
7
|
+
|
|
8
|
+
All resource limits exist to bound the work an AI agent (or a malicious input
|
|
9
|
+
file) can trigger: OWASP LLM10 "Unbounded Consumption". Every value is parsed
|
|
10
|
+
and range-checked; invalid values fail loudly instead of silently falling back
|
|
11
|
+
to "unlimited".
|
|
12
|
+
|
|
13
|
+
============================== ===================================== ============
|
|
14
|
+
Variable Meaning Default
|
|
15
|
+
============================== ===================================== ============
|
|
16
|
+
REVERSE3D_WORKSPACE Sandbox root for the agent tool layer current dir
|
|
17
|
+
REVERSE3D_DEVICE auto | cpu | gpu auto
|
|
18
|
+
REVERSE3D_PROFILE fast | balanced | quality balanced
|
|
19
|
+
REVERSE3D_VERBOSITY compact | full (agent tool output) compact
|
|
20
|
+
REVERSE3D_TOOLSET full | lean (tools exposed to agents) full
|
|
21
|
+
REVERSE3D_OFFLINE auto | on | off (internet use) auto
|
|
22
|
+
REVERSE3D_THREADS CPU threads, 0 = automatic 0
|
|
23
|
+
REVERSE3D_MAX_INPUT_MB Max size of any input file 512
|
|
24
|
+
REVERSE3D_MAX_IMAGE_PIXELS Max pixels per decoded image 100000000
|
|
25
|
+
REVERSE3D_MAX_IMAGES Max images per photogrammetry job 500
|
|
26
|
+
REVERSE3D_MAX_MESH_FACES Max faces of a loaded mesh 5000000
|
|
27
|
+
REVERSE3D_TIMEOUT_S Timeout per external process (s) 3600
|
|
28
|
+
REVERSE3D_BLENDER Explicit path to the Blender binary (PATH)
|
|
29
|
+
REVERSE3D_COLMAP Explicit path to the COLMAP binary (PATH)
|
|
30
|
+
REVERSE3D_LOG_LEVEL DEBUG | INFO | WARNING | ERROR INFO
|
|
31
|
+
REVERSE3D_LOG_JSON 1 to emit JSON log lines on stderr 0
|
|
32
|
+
REVERSE3D_HOME Folder of config.json ~/.reverse3d
|
|
33
|
+
============================== ===================================== ============
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from dataclasses import dataclass, field, replace
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Literal
|
|
43
|
+
|
|
44
|
+
from reverse3d.errors import InputValidationError
|
|
45
|
+
|
|
46
|
+
_MB = 1024 * 1024
|
|
47
|
+
_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR"}
|
|
48
|
+
|
|
49
|
+
Device = Literal["auto", "cpu", "gpu"]
|
|
50
|
+
Profile = Literal["fast", "balanced", "quality"]
|
|
51
|
+
Verbosity = Literal["compact", "full"]
|
|
52
|
+
Toolset = Literal["full", "lean"]
|
|
53
|
+
OfflineMode = Literal["auto", "on", "off"]
|
|
54
|
+
|
|
55
|
+
_CHOICES: dict[str, tuple[str, ...]] = {
|
|
56
|
+
"REVERSE3D_DEVICE": ("auto", "cpu", "gpu"),
|
|
57
|
+
"REVERSE3D_PROFILE": ("fast", "balanced", "quality"),
|
|
58
|
+
"REVERSE3D_VERBOSITY": ("compact", "full"),
|
|
59
|
+
"REVERSE3D_TOOLSET": ("full", "lean"),
|
|
60
|
+
"REVERSE3D_OFFLINE": ("auto", "on", "off"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _int_env(env: Mapping[str, str], name: str, default: int, lo: int, hi: int) -> int:
|
|
65
|
+
raw = env.get(name)
|
|
66
|
+
if raw is None or str(raw).strip() == "":
|
|
67
|
+
return default
|
|
68
|
+
try:
|
|
69
|
+
value = int(str(raw).strip())
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise InputValidationError(f"{name} must be an integer, got {raw!r}") from exc
|
|
72
|
+
if not lo <= value <= hi:
|
|
73
|
+
raise InputValidationError(f"{name} must be between {lo} and {hi}, got {value}")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _choice_env(env: Mapping[str, str], name: str, default: str) -> str:
|
|
78
|
+
raw = str(env.get(name, "") or "").strip().lower()
|
|
79
|
+
if not raw:
|
|
80
|
+
return default
|
|
81
|
+
if raw not in _CHOICES[name]:
|
|
82
|
+
raise InputValidationError(f"{name} must be one of {list(_CHOICES[name])}, got {raw!r}")
|
|
83
|
+
return raw
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _optional_path(env: Mapping[str, str], name: str) -> Path | None:
|
|
87
|
+
raw = env.get(name)
|
|
88
|
+
return Path(raw).expanduser() if raw and str(raw).strip() else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Settings:
|
|
93
|
+
"""Immutable runtime configuration. Build it with :meth:`load`."""
|
|
94
|
+
|
|
95
|
+
workspace: Path = field(default_factory=Path.cwd)
|
|
96
|
+
device: Device = "auto"
|
|
97
|
+
profile: Profile = "balanced"
|
|
98
|
+
verbosity: Verbosity = "compact"
|
|
99
|
+
toolset: Toolset = "full"
|
|
100
|
+
offline: OfflineMode = "auto"
|
|
101
|
+
threads: int = 0
|
|
102
|
+
max_input_bytes: int = 512 * _MB
|
|
103
|
+
max_image_pixels: int = 100_000_000
|
|
104
|
+
max_images: int = 500
|
|
105
|
+
max_mesh_faces: int = 5_000_000
|
|
106
|
+
subprocess_timeout_s: int = 3600
|
|
107
|
+
blender_path: Path | None = None
|
|
108
|
+
colmap_path: Path | None = None
|
|
109
|
+
log_level: str = "INFO"
|
|
110
|
+
log_json: bool = False
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
|
|
114
|
+
"""Build settings from ``env`` only (defaults to ``os.environ``).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
InputValidationError: if a variable is malformed or out of range.
|
|
118
|
+
"""
|
|
119
|
+
env = os.environ if env is None else env
|
|
120
|
+
workspace_raw = env.get("REVERSE3D_WORKSPACE")
|
|
121
|
+
workspace = Path(workspace_raw).expanduser() if workspace_raw else Path.cwd()
|
|
122
|
+
|
|
123
|
+
log_level = str(env.get("REVERSE3D_LOG_LEVEL", "INFO")).strip().upper()
|
|
124
|
+
if log_level not in _LOG_LEVELS:
|
|
125
|
+
raise InputValidationError(f"REVERSE3D_LOG_LEVEL must be one of {sorted(_LOG_LEVELS)}")
|
|
126
|
+
|
|
127
|
+
return cls(
|
|
128
|
+
workspace=workspace.resolve(),
|
|
129
|
+
device=_choice_env(env, "REVERSE3D_DEVICE", "auto"), # type: ignore[arg-type]
|
|
130
|
+
profile=_choice_env(env, "REVERSE3D_PROFILE", "balanced"), # type: ignore[arg-type]
|
|
131
|
+
verbosity=_choice_env(env, "REVERSE3D_VERBOSITY", "compact"), # type: ignore[arg-type]
|
|
132
|
+
toolset=_choice_env(env, "REVERSE3D_TOOLSET", "full"), # type: ignore[arg-type]
|
|
133
|
+
offline=_choice_env(env, "REVERSE3D_OFFLINE", "auto"), # type: ignore[arg-type]
|
|
134
|
+
threads=_int_env(env, "REVERSE3D_THREADS", 0, 0, 1024),
|
|
135
|
+
max_input_bytes=_int_env(env, "REVERSE3D_MAX_INPUT_MB", 512, 1, 16_384) * _MB,
|
|
136
|
+
max_image_pixels=_int_env(
|
|
137
|
+
env, "REVERSE3D_MAX_IMAGE_PIXELS", 100_000_000, 1_000, 1_000_000_000
|
|
138
|
+
),
|
|
139
|
+
max_images=_int_env(env, "REVERSE3D_MAX_IMAGES", 500, 1, 10_000),
|
|
140
|
+
max_mesh_faces=_int_env(env, "REVERSE3D_MAX_MESH_FACES", 5_000_000, 100, 200_000_000),
|
|
141
|
+
subprocess_timeout_s=_int_env(env, "REVERSE3D_TIMEOUT_S", 3600, 10, 172_800),
|
|
142
|
+
blender_path=_optional_path(env, "REVERSE3D_BLENDER"),
|
|
143
|
+
colmap_path=_optional_path(env, "REVERSE3D_COLMAP"),
|
|
144
|
+
log_level=log_level,
|
|
145
|
+
log_json=str(env.get("REVERSE3D_LOG_JSON", "0")).strip() in {"1", "true", "yes"},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def load(
|
|
150
|
+
cls, env: Mapping[str, str] | None = None, *, config_file: Path | None = None
|
|
151
|
+
) -> Settings:
|
|
152
|
+
"""Defaults ← user config file ← environment variables."""
|
|
153
|
+
from reverse3d.userconfig import config_as_env, read_config
|
|
154
|
+
|
|
155
|
+
env = os.environ if env is None else env
|
|
156
|
+
merged = {**config_as_env(read_config(config_file)), **env}
|
|
157
|
+
return cls.from_env(merged)
|
|
158
|
+
|
|
159
|
+
def with_workspace(self, workspace: Path) -> Settings:
|
|
160
|
+
"""Return a copy with a different (resolved) sandbox root."""
|
|
161
|
+
return replace(self, workspace=workspace.expanduser().resolve())
|
|
162
|
+
|
|
163
|
+
def with_device(self, device: Device) -> Settings:
|
|
164
|
+
"""Return a copy with another compute device preference."""
|
|
165
|
+
if device not in ("auto", "cpu", "gpu"):
|
|
166
|
+
raise InputValidationError("device must be auto, cpu or gpu")
|
|
167
|
+
return replace(self, device=device)
|
reverse3d/doctor.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Environment diagnostics: which optional stacks and external tools are usable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
|
|
10
|
+
from reverse3d._version import __version__
|
|
11
|
+
from reverse3d.config import Settings
|
|
12
|
+
from reverse3d.errors import Reverse3DError
|
|
13
|
+
from reverse3d.security import resolve_executable
|
|
14
|
+
|
|
15
|
+
_PACKAGES = {
|
|
16
|
+
"numpy": "core",
|
|
17
|
+
"opencv-python-headless": "core",
|
|
18
|
+
"trimesh": "core",
|
|
19
|
+
"shapely": "core",
|
|
20
|
+
"fast-simplification": "core",
|
|
21
|
+
"open3d": "recon",
|
|
22
|
+
"torch": "depth",
|
|
23
|
+
"transformers": "depth",
|
|
24
|
+
"pyvista": "render",
|
|
25
|
+
"mcp": "mcp",
|
|
26
|
+
"anthropic": "agents",
|
|
27
|
+
}
|
|
28
|
+
_MODULES = {
|
|
29
|
+
"opencv-python-headless": "cv2",
|
|
30
|
+
"fast-simplification": "fast_simplification",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _package_version(dist: str) -> str | None:
|
|
35
|
+
module = _MODULES.get(dist, dist.replace("-", "_"))
|
|
36
|
+
if importlib.util.find_spec(module) is None:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return metadata.version(dist)
|
|
40
|
+
except metadata.PackageNotFoundError:
|
|
41
|
+
return "installed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def environment_report(settings: Settings, *, include_offline: bool = True) -> dict[str, object]:
|
|
45
|
+
"""Return a JSON-serialisable description of the runtime environment.
|
|
46
|
+
|
|
47
|
+
Never raises for missing optional components; they are reported as absent.
|
|
48
|
+
"""
|
|
49
|
+
packages = {
|
|
50
|
+
name: {"extra": extra, "version": _package_version(name)}
|
|
51
|
+
for name, extra in _PACKAGES.items()
|
|
52
|
+
}
|
|
53
|
+
tools: dict[str, dict[str, object]] = {}
|
|
54
|
+
for tool, names, override in (
|
|
55
|
+
("colmap", ["colmap", "COLMAP", "colmap.exe", "COLMAP.bat"], settings.colmap_path),
|
|
56
|
+
("blender", ["blender", "blender.exe"], settings.blender_path),
|
|
57
|
+
):
|
|
58
|
+
try:
|
|
59
|
+
tools[tool] = {
|
|
60
|
+
"available": True,
|
|
61
|
+
"path": str(resolve_executable(names, override, tool=tool)),
|
|
62
|
+
}
|
|
63
|
+
except Reverse3DError as exc:
|
|
64
|
+
tools[tool] = {"available": False, "hint": exc.hint}
|
|
65
|
+
|
|
66
|
+
from reverse3d._about import AUTHOR, AUTHOR_URL, OFFICIAL_REPOSITORY
|
|
67
|
+
from reverse3d.hardware import hardware_report
|
|
68
|
+
|
|
69
|
+
report: dict[str, object] = {
|
|
70
|
+
"reverse3d": __version__,
|
|
71
|
+
"author": f"{AUTHOR} ({AUTHOR_URL})",
|
|
72
|
+
"official_source": OFFICIAL_REPOSITORY,
|
|
73
|
+
"python": sys.version.split()[0],
|
|
74
|
+
"platform": platform.platform(),
|
|
75
|
+
"workspace": str(settings.workspace),
|
|
76
|
+
"settings": {
|
|
77
|
+
"device": settings.device,
|
|
78
|
+
"profile": settings.profile,
|
|
79
|
+
"verbosity": settings.verbosity,
|
|
80
|
+
"toolset": settings.toolset,
|
|
81
|
+
},
|
|
82
|
+
"hardware": hardware_report(settings),
|
|
83
|
+
"limits": {
|
|
84
|
+
"max_input_mb": settings.max_input_bytes // (1024 * 1024),
|
|
85
|
+
"max_image_pixels": settings.max_image_pixels,
|
|
86
|
+
"max_images": settings.max_images,
|
|
87
|
+
"max_mesh_faces": settings.max_mesh_faces,
|
|
88
|
+
"timeout_s": settings.subprocess_timeout_s,
|
|
89
|
+
},
|
|
90
|
+
"packages": packages,
|
|
91
|
+
"external_tools": tools,
|
|
92
|
+
"capabilities": {
|
|
93
|
+
"vision": True,
|
|
94
|
+
"mesh_repair": True,
|
|
95
|
+
"primitive_fitting": True,
|
|
96
|
+
"photogrammetry": bool(tools["colmap"]["available"]),
|
|
97
|
+
"dense_meshing": packages["open3d"]["version"] is not None,
|
|
98
|
+
"depth_estimation": packages["torch"]["version"] is not None
|
|
99
|
+
and packages["transformers"]["version"] is not None,
|
|
100
|
+
"render_blender": bool(tools["blender"]["available"]),
|
|
101
|
+
"render_pyvista": packages["pyvista"]["version"] is not None,
|
|
102
|
+
"mcp_server": packages["mcp"]["version"] is not None,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
if include_offline:
|
|
106
|
+
from reverse3d.offline import offline_status
|
|
107
|
+
|
|
108
|
+
status = offline_status(settings)
|
|
109
|
+
report["offline"] = {
|
|
110
|
+
k: status[k]
|
|
111
|
+
for k in ("mode", "internet", "working_offline", "ready_for_offline", "missing")
|
|
112
|
+
}
|
|
113
|
+
return report
|
reverse3d/errors.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every error raised intentionally by reverse3d derives from :class:`Reverse3DError`
|
|
4
|
+
and carries a stable machine-readable ``code`` so that CLI ``--json`` output and
|
|
5
|
+
the agent tool layer can report failures without leaking stack traces.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Reverse3DError(Exception):
|
|
12
|
+
"""Base class for all reverse3d errors."""
|
|
13
|
+
|
|
14
|
+
code: str = "reverse3d_error"
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.hint = hint
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, str]:
|
|
22
|
+
data = {"code": self.code, "message": self.message}
|
|
23
|
+
if self.hint:
|
|
24
|
+
data["hint"] = self.hint
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputValidationError(Reverse3DError):
|
|
29
|
+
"""An argument or input file is invalid (missing, wrong type, too large...)."""
|
|
30
|
+
|
|
31
|
+
code = "invalid_input"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SecurityViolationError(Reverse3DError):
|
|
35
|
+
"""A request tried to escape the sandbox or violate a security limit."""
|
|
36
|
+
|
|
37
|
+
code = "security_violation"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DependencyMissingError(Reverse3DError):
|
|
41
|
+
"""An optional Python extra or an external binary is not available."""
|
|
42
|
+
|
|
43
|
+
code = "dependency_missing"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExternalToolError(Reverse3DError):
|
|
47
|
+
"""An external binary (COLMAP, Blender) failed or timed out."""
|
|
48
|
+
|
|
49
|
+
code = "external_tool_failed"
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str,
|
|
54
|
+
*,
|
|
55
|
+
hint: str | None = None,
|
|
56
|
+
returncode: int | None = None,
|
|
57
|
+
stderr_tail: str = "",
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(message, hint=hint)
|
|
60
|
+
self.returncode = returncode
|
|
61
|
+
self.stderr_tail = stderr_tail
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, str]:
|
|
64
|
+
data = super().to_dict()
|
|
65
|
+
if self.returncode is not None:
|
|
66
|
+
data["returncode"] = str(self.returncode)
|
|
67
|
+
if self.stderr_tail:
|
|
68
|
+
data["stderr_tail"] = self.stderr_tail
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ProcessingError(Reverse3DError):
|
|
73
|
+
"""An algorithm could not produce a valid result for the given data."""
|
|
74
|
+
|
|
75
|
+
code = "processing_failed"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CaptureQualityError(ProcessingError):
|
|
79
|
+
"""The input capture (e.g. a photo set) is not good enough to process.
|
|
80
|
+
|
|
81
|
+
Retrying with other parameters will not help: the user has to improve the
|
|
82
|
+
capture. Kept under ``processing_failed`` so existing clients see the same code.
|
|
83
|
+
"""
|
reverse3d/hardware.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Compute-device detection and resolution (CPU / NVIDIA CUDA / Apple MPS).
|
|
2
|
+
|
|
3
|
+
``Settings.device`` expresses a *preference* (auto | cpu | gpu). These helpers
|
|
4
|
+
turn it into concrete choices per backend, falling back to CPU when the
|
|
5
|
+
requested accelerator is not usable, so a GPU preference never breaks a job.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from reverse3d.config import Settings
|
|
19
|
+
from reverse3d.errors import Reverse3DError
|
|
20
|
+
from reverse3d.security import run_command
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
TorchDevice = Literal["cpu", "cuda", "mps"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class GpuInfo:
|
|
29
|
+
"""What accelerators this machine exposes."""
|
|
30
|
+
|
|
31
|
+
cuda: bool
|
|
32
|
+
name: str | None
|
|
33
|
+
vram_mb: int | None
|
|
34
|
+
driver: str | None
|
|
35
|
+
mps: bool
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@lru_cache(maxsize=1)
|
|
42
|
+
def detect_gpu() -> GpuInfo:
|
|
43
|
+
"""Detect NVIDIA GPUs (via ``nvidia-smi``) and Apple Silicon (MPS). Cached."""
|
|
44
|
+
mps = platform.system() == "Darwin" and platform.machine() in {"arm64", "aarch64"}
|
|
45
|
+
smi = shutil.which("nvidia-smi")
|
|
46
|
+
if not smi:
|
|
47
|
+
return GpuInfo(cuda=False, name=None, vram_mb=None, driver=None, mps=mps)
|
|
48
|
+
try:
|
|
49
|
+
proc = run_command(
|
|
50
|
+
Path(smi),
|
|
51
|
+
["--query-gpu=name,memory.total,driver_version", "--format=csv,noheader,nounits"],
|
|
52
|
+
timeout_s=15,
|
|
53
|
+
tool="nvidia-smi",
|
|
54
|
+
)
|
|
55
|
+
except Reverse3DError:
|
|
56
|
+
return GpuInfo(cuda=False, name=None, vram_mb=None, driver=None, mps=mps)
|
|
57
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
58
|
+
if not first:
|
|
59
|
+
return GpuInfo(cuda=False, name=None, vram_mb=None, driver=None, mps=mps)
|
|
60
|
+
parts = [p.strip() for p in first[0].split(",")]
|
|
61
|
+
vram = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
|
62
|
+
return GpuInfo(
|
|
63
|
+
cuda=True,
|
|
64
|
+
name=parts[0] or None,
|
|
65
|
+
vram_mb=vram,
|
|
66
|
+
driver=parts[2] if len(parts) > 2 else None,
|
|
67
|
+
mps=mps,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def wants_gpu(settings: Settings) -> bool:
|
|
72
|
+
"""Should GPU-capable external tools (COLMAP, Blender Cycles) use the GPU?"""
|
|
73
|
+
if settings.device == "cpu":
|
|
74
|
+
return False
|
|
75
|
+
if settings.device == "gpu":
|
|
76
|
+
return True
|
|
77
|
+
return detect_gpu().cuda
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def torch_device(settings: Settings) -> TorchDevice:
|
|
81
|
+
"""Best PyTorch device for the preference; CPU if torch/accelerator is missing."""
|
|
82
|
+
if settings.device == "cpu":
|
|
83
|
+
return "cpu"
|
|
84
|
+
try:
|
|
85
|
+
import torch
|
|
86
|
+
except ImportError:
|
|
87
|
+
return "cpu"
|
|
88
|
+
if torch.cuda.is_available():
|
|
89
|
+
return "cuda"
|
|
90
|
+
mps = getattr(torch.backends, "mps", None)
|
|
91
|
+
if mps is not None and mps.is_available():
|
|
92
|
+
return "mps"
|
|
93
|
+
if settings.device == "gpu":
|
|
94
|
+
logger.warning("GPU requested but PyTorch has no CUDA/MPS support; using CPU")
|
|
95
|
+
return "cpu"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def apply_threads(settings: Settings) -> None:
|
|
99
|
+
"""Apply the CPU thread budget to in-process libraries (OpenCV; PyTorch lazily)."""
|
|
100
|
+
if settings.threads <= 0:
|
|
101
|
+
return
|
|
102
|
+
import cv2
|
|
103
|
+
|
|
104
|
+
cv2.setNumThreads(settings.threads)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def hardware_report(settings: Settings) -> dict[str, object]:
|
|
108
|
+
"""JSON-friendly summary used by doctor / environment_check / configure."""
|
|
109
|
+
gpu = detect_gpu()
|
|
110
|
+
return {
|
|
111
|
+
"device_preference": settings.device,
|
|
112
|
+
"external_tools_use_gpu": wants_gpu(settings),
|
|
113
|
+
"gpu": gpu.to_dict(),
|
|
114
|
+
"threads": settings.threads or "auto",
|
|
115
|
+
}
|
reverse3d/imaging.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Safe image I/O.
|
|
2
|
+
|
|
3
|
+
Images are size-checked from their header with Pillow *before* OpenCV decodes
|
|
4
|
+
the full pixel buffer, which prevents decompression bombs from exhausting
|
|
5
|
+
memory. Decoding goes through ``np.fromfile`` + ``cv2.imdecode`` so non-ASCII
|
|
6
|
+
paths work on Windows (``cv2.imread`` does not support them).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
from reverse3d.config import Settings
|
|
18
|
+
from reverse3d.errors import InputValidationError, ProcessingError
|
|
19
|
+
from reverse3d.security import IMAGE_EXTENSIONS, prepare_output_file, validate_input_file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_image_header(path: Path, max_pixels: int) -> tuple[int, int]:
|
|
23
|
+
"""Return ``(width, height)`` from the image header, enforcing ``max_pixels``."""
|
|
24
|
+
# Pillow's own bomb guard would raise a warning/error at ~2x its default;
|
|
25
|
+
# we apply our explicit, configurable limit instead.
|
|
26
|
+
try:
|
|
27
|
+
with Image.open(path) as img:
|
|
28
|
+
width, height = img.size
|
|
29
|
+
except (OSError, Image.DecompressionBombError) as exc:
|
|
30
|
+
raise InputValidationError(f"Unreadable or unsafe image: {path.name}") from exc
|
|
31
|
+
if width * height > max_pixels:
|
|
32
|
+
raise InputValidationError(
|
|
33
|
+
f"Image too large ({width}x{height} px).",
|
|
34
|
+
hint="Raise REVERSE3D_MAX_IMAGE_PIXELS if this is expected.",
|
|
35
|
+
)
|
|
36
|
+
return width, height
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_image(
|
|
40
|
+
path: str | Path,
|
|
41
|
+
settings: Settings,
|
|
42
|
+
*,
|
|
43
|
+
flags: int = cv2.IMREAD_COLOR,
|
|
44
|
+
) -> np.ndarray:
|
|
45
|
+
"""Validate and decode an image into a NumPy array (BGR / grayscale / BGRA).
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
InputValidationError: invalid file, type, or size.
|
|
49
|
+
"""
|
|
50
|
+
resolved = validate_input_file(
|
|
51
|
+
path, allowed_extensions=IMAGE_EXTENSIONS, max_bytes=settings.max_input_bytes
|
|
52
|
+
)
|
|
53
|
+
check_image_header(resolved, settings.max_image_pixels)
|
|
54
|
+
buffer = np.fromfile(resolved, dtype=np.uint8)
|
|
55
|
+
image = cv2.imdecode(buffer, flags)
|
|
56
|
+
if image is None:
|
|
57
|
+
raise InputValidationError(f"OpenCV could not decode image: {resolved.name}")
|
|
58
|
+
return image
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_image(image: np.ndarray, path: str | Path, *, overwrite: bool = False) -> Path:
|
|
62
|
+
"""Encode and write ``image`` to ``path`` (format inferred from extension)."""
|
|
63
|
+
out = prepare_output_file(path, allowed_extensions=IMAGE_EXTENSIONS, overwrite=overwrite)
|
|
64
|
+
ok, encoded = cv2.imencode(out.suffix, image)
|
|
65
|
+
if not ok:
|
|
66
|
+
raise ProcessingError(f"Could not encode image as {out.suffix}")
|
|
67
|
+
encoded.tofile(out)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def downscale(image: np.ndarray, max_dim: int) -> tuple[np.ndarray, float]:
|
|
72
|
+
"""Downscale so the longest side is ``max_dim``. Returns ``(image, scale)``."""
|
|
73
|
+
h, w = image.shape[:2]
|
|
74
|
+
longest = max(h, w)
|
|
75
|
+
if longest <= max_dim:
|
|
76
|
+
return image, 1.0
|
|
77
|
+
scale = max_dim / longest
|
|
78
|
+
resized = cv2.resize(image, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA)
|
|
79
|
+
return resized, scale
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Logging setup.
|
|
2
|
+
|
|
3
|
+
Logs always go to **stderr**: stdout is reserved for command results (``--json``)
|
|
4
|
+
and for the MCP stdio transport, which would be corrupted by stray log lines.
|
|
5
|
+
Logged messages never include file contents, credentials or full absolute paths
|
|
6
|
+
supplied by agents; modules log file *names* only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _JsonFormatter(logging.Formatter):
|
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
19
|
+
payload: dict[str, str | None] = {
|
|
20
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
21
|
+
"level": record.levelname,
|
|
22
|
+
"logger": record.name,
|
|
23
|
+
"msg": record.getMessage(),
|
|
24
|
+
}
|
|
25
|
+
if record.exc_info:
|
|
26
|
+
payload["exc_type"] = record.exc_info[0].__name__ if record.exc_info[0] else None
|
|
27
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure_logging(level: str = "INFO", *, json_lines: bool = False) -> None:
|
|
31
|
+
"""Configure the ``reverse3d`` logger hierarchy (idempotent)."""
|
|
32
|
+
root = logging.getLogger("reverse3d")
|
|
33
|
+
root.setLevel(level)
|
|
34
|
+
for handler in list(root.handlers):
|
|
35
|
+
root.removeHandler(handler)
|
|
36
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
37
|
+
handler.setFormatter(
|
|
38
|
+
_JsonFormatter() if json_lines else logging.Formatter("%(levelname)s %(name)s: %(message)s")
|
|
39
|
+
)
|
|
40
|
+
root.addHandler(handler)
|
|
41
|
+
root.propagate = False
|