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.
Files changed (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. 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