reverse3d 0.7.0__tar.gz → 0.8.1__tar.gz
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-0.7.0/reverse3d.egg-info → reverse3d-0.8.1}/PKG-INFO +5 -4
- {reverse3d-0.7.0 → reverse3d-0.8.1}/README.md +4 -3
- reverse3d-0.8.1/reverse3d/_version.py +1 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/autopilot.py +6 -1
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/offline.py +32 -4
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/recipes.py +93 -21
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/silhouette.py +66 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/_blender_job.py +41 -7
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/blender_render.py +55 -1
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/scenes.py +44 -0
- reverse3d-0.8.1/reverse3d/vision/classifier.py +170 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1/reverse3d.egg-info}/PKG-INFO +5 -4
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/SOURCES.txt +2 -0
- reverse3d-0.8.1/tests/test_color.py +49 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_offline_autopilot.py +3 -1
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_scenes.py +52 -0
- reverse3d-0.7.0/reverse3d/_version.py +0 -1
- {reverse3d-0.7.0 → reverse3d-0.8.1}/LICENSE +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/NOTICE +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/pyproject.toml +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/__main__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/_about.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/compact.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/mcp_server.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/prompts.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/skill/SKILL.md +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/agent/tools.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/cli.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/config.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/doctor.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/errors.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/hardware.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/imaging.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/logging_utils.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/mesh_io.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/profiles.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/py.typed +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/depth_estimation.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/photogrammetry.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reconstruction/pointcloud.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/rendering/preview.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/geometry_fit.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/measure.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/mesh_cleaner.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/reverse_eng/parametric.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/security.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/setup_agents.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/userconfig.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/__init__.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/feature_matcher.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/mask_extractor.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/vision/segmenter.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d/watch.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/dependency_links.txt +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/entry_points.txt +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/requires.txt +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/reverse3d.egg-info/top_level.txt +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/setup.cfg +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_agent_tools.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_authorship.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_automation.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_cli.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_cli_automation.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_config.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_geometry_fit.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_heic.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_mesh.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_optional_extras.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_reconstruction.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_render.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_security.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_silhouette.py +0 -0
- {reverse3d-0.7.0 → reverse3d-0.8.1}/tests/test_vision.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
215
215
|
|
|
216
216
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
217
217
|
|---------------------|----------------------------|--------|
|
|
218
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
219
|
-
| `--lite` | `-Lite` | Todo
|
|
220
|
-
| `--minimal` | `-Minimal` |
|
|
218
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
219
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
220
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
221
221
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
222
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
222
223
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
223
224
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
224
225
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -147,10 +147,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
147
147
|
|
|
148
148
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
149
149
|
|---------------------|----------------------------|--------|
|
|
150
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
151
|
-
| `--lite` | `-Lite` | Todo
|
|
152
|
-
| `--minimal` | `-Minimal` |
|
|
150
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
151
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
152
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
153
153
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
154
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
154
155
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
155
156
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
156
157
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.1"
|
|
@@ -361,7 +361,12 @@ def auto_run(
|
|
|
361
361
|
if base.scene == "auto":
|
|
362
362
|
predicted = detect_scene(source, settings)
|
|
363
363
|
scene_info = predicted.to_dict()
|
|
364
|
-
base = replace(
|
|
364
|
+
base = replace(
|
|
365
|
+
base,
|
|
366
|
+
scene=predicted.kind,
|
|
367
|
+
subject=predicted.subject,
|
|
368
|
+
render_preset=predicted.render,
|
|
369
|
+
)
|
|
365
370
|
elif base.scene in SCENE_PRESETS:
|
|
366
371
|
scene_info = {"kind": base.scene, "confidence": 1.0, "reasons": ["chosen"]}
|
|
367
372
|
summary: dict[str, Any] | None = None
|
|
@@ -55,6 +55,7 @@ MODEL_REVISIONS: dict[str, str] = {
|
|
|
55
55
|
"depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
|
|
56
56
|
"depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
|
|
57
57
|
"facebook/sam-vit-base": "70c1a07f894ebb5b307fd9eaaee97b9dfc16068f",
|
|
58
|
+
"wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": "95ec8197b3f2fe7f747865c61ca556cf0768b2f7",
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
|
|
@@ -101,9 +102,10 @@ def is_offline(settings: Settings) -> bool:
|
|
|
101
102
|
|
|
102
103
|
def _allowed_models() -> dict[str, tuple[str, str]]:
|
|
103
104
|
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
105
|
+
from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
|
|
104
106
|
from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
|
|
105
107
|
|
|
106
|
-
return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS}
|
|
108
|
+
return {**ALLOWED_MODELS, **ALLOWED_SEGMENTERS, **ALLOWED_CLASSIFIERS}
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
def _default_model() -> str:
|
|
@@ -203,10 +205,12 @@ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict
|
|
|
203
205
|
Safe to run any time: models already on disk are skipped. It never raises
|
|
204
206
|
for a missing optional stack; that is reported in the result instead.
|
|
205
207
|
"""
|
|
208
|
+
from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
|
|
206
209
|
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
207
210
|
|
|
208
|
-
# Depth (reliefs)
|
|
209
|
-
|
|
211
|
+
# Depth (reliefs), segmentation (silhouettes) and the subject classifier,
|
|
212
|
+
# all commercial-friendly.
|
|
213
|
+
models = [_default_model(), DEFAULT_SEGMENTER, DEFAULT_CLASSIFIER]
|
|
210
214
|
if include_non_commercial:
|
|
211
215
|
models += [m for m in _allowed_models() if m not in models]
|
|
212
216
|
|
|
@@ -279,8 +283,18 @@ def offline_status(settings: Settings) -> dict[str, Any]:
|
|
|
279
283
|
|
|
280
284
|
env = environment_report(settings, include_offline=False)
|
|
281
285
|
caps: dict[str, bool] = env["capabilities"] # type: ignore[assignment]
|
|
286
|
+
from reverse3d.vision.classifier import DEFAULT_CLASSIFIER
|
|
287
|
+
from reverse3d.vision.segmenter import DEFAULT_SEGMENTER
|
|
288
|
+
|
|
282
289
|
depth_installed = bool(caps.get("depth_estimation"))
|
|
283
290
|
model_on_disk = local_model_path(_default_model()) is not None
|
|
291
|
+
# The local AI team: what each model does, and whether it is on this computer.
|
|
292
|
+
ai_models = {
|
|
293
|
+
"ai_subject": DEFAULT_CLASSIFIER,
|
|
294
|
+
"ai_segmentation": DEFAULT_SEGMENTER,
|
|
295
|
+
"ai_depth": _default_model(),
|
|
296
|
+
}
|
|
297
|
+
on_disk = {key: local_model_path(model) is not None for key, model in ai_models.items()}
|
|
284
298
|
internet = None if settings.offline != "auto" else has_internet()
|
|
285
299
|
|
|
286
300
|
components = {
|
|
@@ -292,13 +306,27 @@ def offline_status(settings: Settings) -> dict[str, Any]:
|
|
|
292
306
|
"available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
|
|
293
307
|
"offline": True,
|
|
294
308
|
},
|
|
295
|
-
|
|
309
|
+
**{
|
|
310
|
+
key: {"available": depth_installed, "offline": depth_installed and on_disk[key]}
|
|
311
|
+
for key in ai_models
|
|
312
|
+
},
|
|
296
313
|
}
|
|
297
314
|
missing = []
|
|
315
|
+
names = {
|
|
316
|
+
"ai_subject": "AI subject classifier",
|
|
317
|
+
"ai_segmentation": "AI segmentation",
|
|
318
|
+
"ai_depth": "AI depth",
|
|
319
|
+
}
|
|
298
320
|
if depth_installed and not model_on_disk:
|
|
299
321
|
missing.append(
|
|
300
322
|
"AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
|
|
301
323
|
)
|
|
324
|
+
for key in ("ai_subject", "ai_segmentation"):
|
|
325
|
+
if depth_installed and not on_disk[key]:
|
|
326
|
+
missing.append(
|
|
327
|
+
f"{names[key]} model not downloaded yet: "
|
|
328
|
+
"run `reverse3d offline prepare` while online."
|
|
329
|
+
)
|
|
302
330
|
working_offline = is_offline(settings)
|
|
303
331
|
return {
|
|
304
332
|
"mode": settings.offline,
|
|
@@ -30,6 +30,8 @@ from dataclasses import dataclass, field
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Literal, TypeVar
|
|
32
32
|
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
33
35
|
from reverse3d.config import Settings
|
|
34
36
|
from reverse3d.errors import (
|
|
35
37
|
CaptureQualityError,
|
|
@@ -75,6 +77,9 @@ class RecipeOptions:
|
|
|
75
77
|
mask_method: MaskMethod = "otsu"
|
|
76
78
|
#: photos_to_model: scene profile (object, character, architecture, landscape) or "auto".
|
|
77
79
|
scene: str = "auto"
|
|
80
|
+
#: what was photographed (AI classifier) and the render look; None = from the scene.
|
|
81
|
+
subject: str | None = None
|
|
82
|
+
render_preset: str | None = None
|
|
78
83
|
#: photo_to_relief: object mask (white = keep), e.g. from the autopilot fallback.
|
|
79
84
|
mask_path: str | None = None
|
|
80
85
|
#: mesh repair strength; "strong" closes much larger holes.
|
|
@@ -132,39 +137,85 @@ def _clean(
|
|
|
132
137
|
parts, and tiny debris is already dropped by the area filter."""
|
|
133
138
|
from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
|
|
134
139
|
|
|
140
|
+
options = CleanOptions(
|
|
141
|
+
keep_largest_only=keep_largest,
|
|
142
|
+
fill_holes=fill_holes,
|
|
143
|
+
align=align,
|
|
144
|
+
ground=align != "none",
|
|
145
|
+
scale=scale,
|
|
146
|
+
max_hole_edges=2000 if repair == "strong" else 200,
|
|
147
|
+
)
|
|
135
148
|
report = run.step(
|
|
136
149
|
"clean",
|
|
137
|
-
lambda: clean_mesh_file(
|
|
138
|
-
src,
|
|
139
|
-
dst,
|
|
140
|
-
run.settings,
|
|
141
|
-
CleanOptions(
|
|
142
|
-
keep_largest_only=keep_largest,
|
|
143
|
-
fill_holes=fill_holes,
|
|
144
|
-
align=align,
|
|
145
|
-
ground=align != "none",
|
|
146
|
-
scale=scale,
|
|
147
|
-
max_hole_edges=2000 if repair == "strong" else 200,
|
|
148
|
-
),
|
|
149
|
-
overwrite=overwrite,
|
|
150
|
-
),
|
|
150
|
+
lambda: clean_mesh_file(src, dst, run.settings, options, overwrite=overwrite),
|
|
151
151
|
)
|
|
152
152
|
run.warnings.extend(report.warnings)
|
|
153
153
|
run.outputs["model"] = str(dst)
|
|
154
154
|
run.details["clean"] = report.to_dict()
|
|
155
|
+
if _has_vertex_colors(src):
|
|
156
|
+
# Same cleaning on the coloured mesh (deterministic, colours follow the
|
|
157
|
+
# vertices): model_color.ply for renders and model.glb for games and viewers.
|
|
158
|
+
try:
|
|
159
|
+
color_ply = dst.with_name("model_color.ply")
|
|
160
|
+
clean_mesh_file(src, color_ply, run.settings, options, overwrite=True)
|
|
161
|
+
import trimesh
|
|
162
|
+
|
|
163
|
+
glb = dst.with_name("model.glb")
|
|
164
|
+
trimesh.load(color_ply, process=False, force="mesh").export(glb)
|
|
165
|
+
run.outputs["model_color"] = str(color_ply)
|
|
166
|
+
run.outputs["model_glb"] = str(glb)
|
|
167
|
+
except Exception as exc: # colour is a bonus: never fail the model for it
|
|
168
|
+
logger.info("Coloured model skipped: %s", exc)
|
|
155
169
|
return report.after
|
|
156
170
|
|
|
157
171
|
|
|
172
|
+
def _has_vertex_colors(path: Path) -> bool:
|
|
173
|
+
import trimesh
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
mesh: Any = trimesh.load(path, process=False, force="mesh")
|
|
177
|
+
except Exception:
|
|
178
|
+
return False
|
|
179
|
+
colors = getattr(mesh.visual, "vertex_colors", None)
|
|
180
|
+
if colors is None or len(colors) != len(mesh.vertices):
|
|
181
|
+
return False
|
|
182
|
+
rgb = np.asarray(colors)[:, :3]
|
|
183
|
+
return float(np.max(np.std(rgb, axis=0))) > 1.0 # not just a uniform default colour
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _color_from_cloud(mesh_path: Path, cloud_path: Path, out: Path) -> Path | None:
|
|
187
|
+
"""Paint a mesh with the colours of the point cloud it was built from."""
|
|
188
|
+
import trimesh
|
|
189
|
+
from scipy.spatial import cKDTree
|
|
190
|
+
|
|
191
|
+
cloud: Any = trimesh.load(cloud_path, process=False)
|
|
192
|
+
colors = getattr(cloud, "colors", None)
|
|
193
|
+
if colors is None or len(colors) != len(getattr(cloud, "vertices", [])):
|
|
194
|
+
return None
|
|
195
|
+
mesh: Any = trimesh.load(mesh_path, process=False, force="mesh")
|
|
196
|
+
_, idx = cKDTree(np.asarray(cloud.vertices)).query(np.asarray(mesh.vertices), k=4)
|
|
197
|
+
rgb = np.asarray(colors)[idx][..., :3].astype(np.float64).mean(axis=1)
|
|
198
|
+
rgba = np.column_stack([rgb, np.full(len(rgb), 255.0)]).clip(0, 255).astype(np.uint8)
|
|
199
|
+
mesh.visual = trimesh.visual.ColorVisuals(mesh, vertex_colors=rgba)
|
|
200
|
+
mesh.export(out)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
158
204
|
def _render(run: _Run, mesh: Path, overwrite: bool) -> None:
|
|
159
|
-
from reverse3d.rendering.blender_render import RenderOptions, render_mesh
|
|
205
|
+
from reverse3d.rendering.blender_render import RenderOptions, preset_options, render_mesh
|
|
160
206
|
|
|
161
207
|
prof = profile_values(run.settings)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
208
|
+
look = str(run.details.get("scene", {}).get("render") or "studio")
|
|
209
|
+
options = preset_options(
|
|
210
|
+
look,
|
|
211
|
+
RenderOptions(
|
|
212
|
+
width=prof.render_width,
|
|
213
|
+
height=prof.render_height,
|
|
214
|
+
samples=prof.render_samples,
|
|
215
|
+
device="gpu" if wants_gpu(run.settings) else "cpu",
|
|
216
|
+
),
|
|
167
217
|
)
|
|
218
|
+
run.details["render_look"] = look
|
|
168
219
|
try:
|
|
169
220
|
result = run.step(
|
|
170
221
|
"render",
|
|
@@ -194,6 +245,14 @@ def _measure(run: _Run, mesh: Path) -> MeshStats:
|
|
|
194
245
|
_MIN_GROUP = 8
|
|
195
246
|
|
|
196
247
|
|
|
248
|
+
_DEFAULT_LOOK = {
|
|
249
|
+
"object": "studio",
|
|
250
|
+
"character": "portrait",
|
|
251
|
+
"architecture": "architecture",
|
|
252
|
+
"landscape": "aerial",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
197
256
|
def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
198
257
|
from reverse3d.reconstruction.photogrammetry import (
|
|
199
258
|
PhotogrammetryOptions,
|
|
@@ -204,9 +263,11 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
204
263
|
from reverse3d.vision.feature_matcher import analyze_image_set
|
|
205
264
|
|
|
206
265
|
scene: str
|
|
266
|
+
subject, look = opt.subject, opt.render_preset
|
|
207
267
|
if opt.scene == "auto":
|
|
208
268
|
detection = run.step("scene", lambda: detect_scene(src, run.settings))
|
|
209
269
|
scene, confidence, predicted = detection.kind, detection.confidence, True
|
|
270
|
+
subject, look = detection.subject, detection.render
|
|
210
271
|
elif opt.scene in SCENE_PRESETS:
|
|
211
272
|
scene, confidence, predicted = opt.scene, 1.0, False
|
|
212
273
|
else:
|
|
@@ -219,6 +280,8 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
219
280
|
"label": SCENE_LABEL_ES[scene],
|
|
220
281
|
"confidence": confidence,
|
|
221
282
|
"predicted": predicted,
|
|
283
|
+
"subject": subject,
|
|
284
|
+
"render": look or _DEFAULT_LOOK[scene],
|
|
222
285
|
}
|
|
223
286
|
analysis = run.step("analyze", lambda: analyze_image_set(src, run.settings))
|
|
224
287
|
run.details["analysis"] = analysis.to_dict()
|
|
@@ -310,6 +373,15 @@ def _photos_to_model(run: _Run, src: Path, opt: RecipeOptions) -> MeshStats:
|
|
|
310
373
|
"Point cloud created but no mesh could be built.",
|
|
311
374
|
hint='Install the meshing extra: pip install "reverse3d[recon]" (Python ≤ 3.12).',
|
|
312
375
|
)
|
|
376
|
+
cloud = photo.dense_points_path or photo.sparse_points_path
|
|
377
|
+
if not upright and cloud:
|
|
378
|
+
try:
|
|
379
|
+
colored = _color_from_cloud(
|
|
380
|
+
Path(mesh_path), Path(cloud), run.out / "reconstruction" / "mesh_color.ply"
|
|
381
|
+
)
|
|
382
|
+
mesh_path = str(colored) if colored else mesh_path
|
|
383
|
+
except Exception as exc: # colour is a bonus
|
|
384
|
+
logger.info("Colouring from the point cloud skipped: %s", exc)
|
|
313
385
|
model = run.out / "model.stl"
|
|
314
386
|
_clean(
|
|
315
387
|
run,
|
|
@@ -510,7 +582,7 @@ def run_recipe(
|
|
|
510
582
|
source = prepare_heic_input(source, out) # iPhone HEIC photos -> JPEG
|
|
511
583
|
stats = _RECIPES[recipe](run, source, opt)
|
|
512
584
|
if opt.render:
|
|
513
|
-
_render(run, Path(run.outputs["model"]), opt.overwrite)
|
|
585
|
+
_render(run, Path(run.outputs.get("model_color") or run.outputs["model"]), opt.overwrite)
|
|
514
586
|
|
|
515
587
|
summary: dict[str, Any] = {
|
|
516
588
|
"recipe": recipe,
|
|
@@ -191,6 +191,65 @@ def _support_plane(
|
|
|
191
191
|
return n, d
|
|
192
192
|
|
|
193
193
|
|
|
194
|
+
def _vertex_colors(
|
|
195
|
+
mesh: trimesh.Trimesh,
|
|
196
|
+
views: list[_View],
|
|
197
|
+
images: list[tuple[np.ndarray, float]],
|
|
198
|
+
masks: list[np.ndarray] | None = None,
|
|
199
|
+
) -> np.ndarray:
|
|
200
|
+
"""Real colour of every vertex from the photos that see it head-on.
|
|
201
|
+
|
|
202
|
+
Each photo votes with weight cos(angle)^2 for the vertices that face it and
|
|
203
|
+
are not hidden (coarse depth buffer); vertices no photo sees get the median.
|
|
204
|
+
"""
|
|
205
|
+
verts = np.asarray(mesh.vertices)
|
|
206
|
+
normals = np.asarray(mesh.vertex_normals)
|
|
207
|
+
total = np.zeros((len(verts), 3))
|
|
208
|
+
weight = np.zeros(len(verts))
|
|
209
|
+
for i, ((small, scale), view) in enumerate(zip(images, views, strict=True)):
|
|
210
|
+
# Only pixels of the object itself give colour (the carved hull is a bit
|
|
211
|
+
# larger than the object, so its rim would otherwise pick up background).
|
|
212
|
+
inner = (
|
|
213
|
+
cv2.erode(masks[i], np.ones((5, 5), np.uint8)) > 0
|
|
214
|
+
if masks is not None and masks[i].shape == small.shape[:2]
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
center = -view.rotation.T @ view.translation
|
|
218
|
+
to_cam = center - verts
|
|
219
|
+
to_cam /= np.linalg.norm(to_cam, axis=1, keepdims=True)
|
|
220
|
+
facing = np.einsum("ij,ij->i", normals, to_cam)
|
|
221
|
+
uv, z = _project(verts, view)
|
|
222
|
+
u = uv[:, 0] * scale
|
|
223
|
+
v = uv[:, 1] * scale
|
|
224
|
+
h, w = small.shape[:2]
|
|
225
|
+
ok = (facing > 0.2) & (z > 0) & (u >= 0) & (v >= 0) & (u < w - 1) & (v < h - 1)
|
|
226
|
+
if not ok.any():
|
|
227
|
+
continue
|
|
228
|
+
# Coarse z-buffer: a vertex counts only if it is (about) the nearest in its cell.
|
|
229
|
+
cell = 4
|
|
230
|
+
cu = (u[ok] / cell).astype(np.int64)
|
|
231
|
+
cv_ = (v[ok] / cell).astype(np.int64)
|
|
232
|
+
zbuf = np.full(((h // cell) + 1, (w // cell) + 1), np.inf)
|
|
233
|
+
np.minimum.at(zbuf, (cv_, cu), z[ok])
|
|
234
|
+
depth_span = float(np.ptp(z[ok])) or 1.0
|
|
235
|
+
visible = z[ok] <= zbuf[cv_, cu] + 0.03 * depth_span
|
|
236
|
+
idx = np.flatnonzero(ok)[visible]
|
|
237
|
+
if inner is not None:
|
|
238
|
+
idx = idx[inner[v[idx].astype(np.int64), u[idx].astype(np.int64)]]
|
|
239
|
+
px = small[v[idx].astype(np.int64), u[idx].astype(np.int64)][:, ::-1].astype(np.float64)
|
|
240
|
+
wgt = facing[idx] ** 2
|
|
241
|
+
total[idx] += px * wgt[:, None]
|
|
242
|
+
weight[idx] += wgt
|
|
243
|
+
colors = np.zeros((len(verts), 4), np.uint8)
|
|
244
|
+
seen = weight > 0
|
|
245
|
+
rgb = np.zeros((len(verts), 3))
|
|
246
|
+
rgb[seen] = total[seen] / weight[seen, None]
|
|
247
|
+
rgb[~seen] = np.median(rgb[seen], axis=0) if seen.any() else 180
|
|
248
|
+
colors[:, :3] = np.clip(rgb, 0, 255).astype(np.uint8)
|
|
249
|
+
colors[:, 3] = 255
|
|
250
|
+
return colors
|
|
251
|
+
|
|
252
|
+
|
|
194
253
|
def visual_hull(
|
|
195
254
|
image_dir: Path,
|
|
196
255
|
model_text_dir: Path,
|
|
@@ -332,6 +391,8 @@ def visual_hull(
|
|
|
332
391
|
normal, offset = plane
|
|
333
392
|
height = (grid @ normal + offset).reshape(n, n, n)
|
|
334
393
|
volume[height < 0.015 * radius] = 0
|
|
394
|
+
# Drop structures thinner than a couple of voxels (stray spikes from a few views).
|
|
395
|
+
volume = ndimage.binary_opening(volume > 0.5, iterations=2).astype(np.float32)
|
|
335
396
|
labels, count = ndimage.label(volume > 0.5)
|
|
336
397
|
if count == 0:
|
|
337
398
|
raise ProcessingError("No object could be isolated in the photos.")
|
|
@@ -356,6 +417,11 @@ def visual_hull(
|
|
|
356
417
|
verts = (verts - 2) / (n - 1) * 2 * radius - radius + center
|
|
357
418
|
mesh = trimesh.Trimesh(verts, faces, process=True)
|
|
358
419
|
trimesh.smoothing.filter_taubin(mesh, lamb=0.5, nu=-0.53, iterations=15)
|
|
420
|
+
mesh.fix_normals()
|
|
421
|
+
# Real colours from the photos (kept by PLY export; the model gets them as GLB).
|
|
422
|
+
mesh.visual = trimesh.visual.ColorVisuals(
|
|
423
|
+
mesh, vertex_colors=_vertex_colors(mesh, views, images, masks)
|
|
424
|
+
)
|
|
359
425
|
|
|
360
426
|
# Stand it upright: +Z along the table normal (or the mean camera "up").
|
|
361
427
|
up = plane[0] if plane is not None else np.mean(cameras - center, axis=0)
|
|
@@ -136,8 +136,15 @@ def make_material(obj: bpy.types.Object, job: dict) -> None:
|
|
|
136
136
|
obj.data.materials.append(mat)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def add_light(
|
|
139
|
+
def add_light(
|
|
140
|
+
name: str,
|
|
141
|
+
location: tuple[float, float, float],
|
|
142
|
+
energy: float,
|
|
143
|
+
size: float,
|
|
144
|
+
color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
145
|
+
) -> None:
|
|
140
146
|
data = bpy.data.lights.new(name, type="AREA")
|
|
147
|
+
data.color = color
|
|
141
148
|
data.energy = energy
|
|
142
149
|
data.size = size
|
|
143
150
|
light = bpy.data.objects.new(name, data)
|
|
@@ -153,7 +160,37 @@ def setup_world(job: dict) -> None:
|
|
|
153
160
|
world.use_nodes = True
|
|
154
161
|
bg = world.node_tree.nodes.get("Background")
|
|
155
162
|
bg.inputs["Color"].default_value = _hex_to_rgba(job["background"])
|
|
156
|
-
|
|
163
|
+
outdoor = job.get("preset") in ("architecture", "aerial")
|
|
164
|
+
bg.inputs["Strength"].default_value = 1.0 if outdoor else 0.6
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def add_sun(elevation_deg: float, azimuth_deg: float, energy: float) -> None:
|
|
168
|
+
data = bpy.data.lights.new("sun", type="SUN")
|
|
169
|
+
data.energy = energy
|
|
170
|
+
data.angle = math.radians(1.5) # soft daylight shadows
|
|
171
|
+
sun = bpy.data.objects.new("sun", data)
|
|
172
|
+
bpy.context.collection.objects.link(sun)
|
|
173
|
+
sun.rotation_euler = (math.radians(90 - elevation_deg), 0.0, math.radians(azimuth_deg))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def setup_lights(r: float, job: dict) -> None:
|
|
177
|
+
"""Lighting of each look (see RENDER_PRESETS in blender_render.py)."""
|
|
178
|
+
preset = job.get("preset", "studio")
|
|
179
|
+
if preset in ("architecture", "aerial"):
|
|
180
|
+
add_sun(55 if preset == "architecture" else 65, 140, 4.0)
|
|
181
|
+
add_light("sky_fill", (-r * 4, -r * 3, r * 3), 150 * r * r, r * 4, (0.85, 0.9, 1.0))
|
|
182
|
+
elif preset == "portrait":
|
|
183
|
+
add_light("key", (r * 3, -r * 3, r * 2.5), 900 * r * r, r * 1.5, (1.0, 0.93, 0.85))
|
|
184
|
+
add_light("fill", (-r * 4, -r * 2, r * 1.5), 160 * r * r, r * 3)
|
|
185
|
+
add_light("rim", (-r * 1, r * 4, r * 3), 900 * r * r, r * 1.2, (0.75, 0.85, 1.0))
|
|
186
|
+
elif preset == "showroom":
|
|
187
|
+
add_light("top", (0, 0, r * 5), 1200 * r * r, r * 4)
|
|
188
|
+
add_light("key", (r * 4, -r * 3, r * 2), 600 * r * r, r * 3)
|
|
189
|
+
add_light("rim", (-r * 3, r * 4, r * 2), 700 * r * r, r * 2.5)
|
|
190
|
+
else: # studio
|
|
191
|
+
add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
|
|
192
|
+
add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
|
|
193
|
+
add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
|
|
157
194
|
|
|
158
195
|
|
|
159
196
|
def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
|
|
@@ -162,7 +199,7 @@ def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
|
|
|
162
199
|
rig.location = (0, 0, center_z)
|
|
163
200
|
|
|
164
201
|
cam_data = bpy.data.cameras.new("reverse3d_camera")
|
|
165
|
-
cam_data.lens = 50
|
|
202
|
+
cam_data.lens = float(job.get("lens_mm", 50))
|
|
166
203
|
cam = bpy.data.objects.new("reverse3d_camera", cam_data)
|
|
167
204
|
bpy.context.collection.objects.link(cam)
|
|
168
205
|
# camera.angle is the FOV of the longer image side; fit the sphere in the shorter one.
|
|
@@ -248,10 +285,7 @@ def main() -> None:
|
|
|
248
285
|
shade(obj, job["shading"])
|
|
249
286
|
make_material(obj, job)
|
|
250
287
|
setup_world(job)
|
|
251
|
-
|
|
252
|
-
add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
|
|
253
|
-
add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
|
|
254
|
-
add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
|
|
288
|
+
setup_lights(max(radius, 0.1), job)
|
|
255
289
|
rig = setup_camera(radius, center_z, job)
|
|
256
290
|
setup_ground(job)
|
|
257
291
|
setup_render(job)
|
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import tempfile
|
|
20
20
|
from dataclasses import asdict, dataclass, field
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import Literal
|
|
22
|
+
from typing import Any, Literal
|
|
23
23
|
|
|
24
24
|
from reverse3d.config import Settings
|
|
25
25
|
from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
|
|
@@ -63,6 +63,9 @@ class RenderOptions:
|
|
|
63
63
|
ground: bool = True
|
|
64
64
|
shading: Literal["auto", "smooth", "flat"] = "auto"
|
|
65
65
|
vertex_colors: bool = True
|
|
66
|
+
#: look of the shot: studio, showroom, portrait, architecture or aerial.
|
|
67
|
+
preset: str = "studio"
|
|
68
|
+
lens_mm: float = 50.0
|
|
66
69
|
|
|
67
70
|
def validate(self) -> None:
|
|
68
71
|
checks = [
|
|
@@ -82,12 +85,63 @@ class RenderOptions:
|
|
|
82
85
|
(0 <= self.azimuth_deg <= 360, "azimuth_deg must be in [0, 360]"),
|
|
83
86
|
(0 <= self.turntable_frames <= 360, "turntable_frames must be in [0, 360]"),
|
|
84
87
|
(self.shading in ("auto", "smooth", "flat"), "shading must be auto, smooth or flat"),
|
|
88
|
+
(self.preset in RENDER_PRESETS, f"preset must be one of {', '.join(RENDER_PRESETS)}"),
|
|
89
|
+
(10 <= self.lens_mm <= 300, "lens_mm must be in [10, 300]"),
|
|
85
90
|
]
|
|
86
91
|
for ok, message in checks:
|
|
87
92
|
if not ok:
|
|
88
93
|
raise InputValidationError(message)
|
|
89
94
|
|
|
90
95
|
|
|
96
|
+
#: Camera, lens and backdrop of each look (lights are set up in the Blender job).
|
|
97
|
+
RENDER_PRESETS: dict[str, dict[str, Any]] = {
|
|
98
|
+
# Products and parts: seamless light backdrop, three soft boxes.
|
|
99
|
+
"studio": {
|
|
100
|
+
"lens_mm": 50.0,
|
|
101
|
+
"elevation_deg": 25.0,
|
|
102
|
+
"azimuth_deg": 35.0,
|
|
103
|
+
"background": "#f4f5f7",
|
|
104
|
+
},
|
|
105
|
+
# Vehicles: longer lens, low angle, glossy paint, showroom grey.
|
|
106
|
+
"showroom": {
|
|
107
|
+
"lens_mm": 70.0,
|
|
108
|
+
"elevation_deg": 12.0,
|
|
109
|
+
"azimuth_deg": 40.0,
|
|
110
|
+
"background": "#e6e9ee",
|
|
111
|
+
"roughness": 0.22,
|
|
112
|
+
},
|
|
113
|
+
# People and animals: portrait lens, eye level, dark backdrop, warm key + cool rim.
|
|
114
|
+
"portrait": {
|
|
115
|
+
"lens_mm": 85.0,
|
|
116
|
+
"elevation_deg": 6.0,
|
|
117
|
+
"azimuth_deg": 25.0,
|
|
118
|
+
"background": "#1d222b",
|
|
119
|
+
},
|
|
120
|
+
# Buildings and interiors: wide lens, sun and sky.
|
|
121
|
+
"architecture": {
|
|
122
|
+
"lens_mm": 28.0,
|
|
123
|
+
"elevation_deg": 20.0,
|
|
124
|
+
"azimuth_deg": 40.0,
|
|
125
|
+
"background": "#bcd4ea",
|
|
126
|
+
},
|
|
127
|
+
# Terrain and forests: high aerial view in daylight.
|
|
128
|
+
"aerial": {
|
|
129
|
+
"lens_mm": 35.0,
|
|
130
|
+
"elevation_deg": 55.0,
|
|
131
|
+
"azimuth_deg": 30.0,
|
|
132
|
+
"background": "#cfe0ee",
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def preset_options(preset: str, base: RenderOptions | None = None) -> RenderOptions:
|
|
138
|
+
"""Render options for a look, keeping size, samples and device from ``base``."""
|
|
139
|
+
from dataclasses import replace
|
|
140
|
+
|
|
141
|
+
name = preset if preset in RENDER_PRESETS else "studio"
|
|
142
|
+
return replace(base or RenderOptions(), preset=name, **RENDER_PRESETS[name])
|
|
143
|
+
|
|
144
|
+
|
|
91
145
|
@dataclass
|
|
92
146
|
class RenderResult:
|
|
93
147
|
"""Paths of the rendered images and the backend that produced them."""
|
|
@@ -102,6 +102,12 @@ class SceneDetection:
|
|
|
102
102
|
recommended: tuple[int, int]
|
|
103
103
|
enough_photos: bool
|
|
104
104
|
advice: list[str] = field(default_factory=list)
|
|
105
|
+
#: what the AI classifier saw (product, person, animal, vehicle, building...), if available.
|
|
106
|
+
subject: str | None = None
|
|
107
|
+
subject_label: str | None = None
|
|
108
|
+
subject_scores: dict[str, float] = field(default_factory=dict)
|
|
109
|
+
#: render look: studio, showroom, portrait, architecture or aerial.
|
|
110
|
+
render: str = "studio"
|
|
105
111
|
|
|
106
112
|
def to_dict(self) -> dict[str, object]:
|
|
107
113
|
return asdict(self)
|
|
@@ -307,6 +313,26 @@ def score_scene(signals: SceneSignals) -> tuple[dict[str, float], list[str]]:
|
|
|
307
313
|
return {k: round(max(v, 0.0), 3) for k, v in scores.items()}, reasons
|
|
308
314
|
|
|
309
315
|
|
|
316
|
+
def _ai_subject(images: list[Path], settings: Settings) -> tuple[str | None, dict[str, float]]:
|
|
317
|
+
"""The AI classifier's subject for a sample of the photos (None if unavailable)."""
|
|
318
|
+
from reverse3d.errors import Reverse3DError
|
|
319
|
+
from reverse3d.vision.classifier import classify_photos
|
|
320
|
+
|
|
321
|
+
step = max(1, len(images) // _SAMPLE)
|
|
322
|
+
try:
|
|
323
|
+
scores = classify_photos(images[::step][:_SAMPLE], settings)
|
|
324
|
+
except Reverse3DError as exc:
|
|
325
|
+
logger.info("AI subject classifier unavailable: %s", exc.message)
|
|
326
|
+
return None, {}
|
|
327
|
+
return max(scores, key=scores.__getitem__), scores
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _subject_label(subject: str | None) -> str | None:
|
|
331
|
+
from reverse3d.vision.classifier import SUBJECT_LABEL_ES
|
|
332
|
+
|
|
333
|
+
return SUBJECT_LABEL_ES.get(subject) if subject else None
|
|
334
|
+
|
|
335
|
+
|
|
310
336
|
def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
311
337
|
"""Predict the scene kind of a photo folder (takes a few seconds)."""
|
|
312
338
|
directory = validate_input_dir(image_dir)
|
|
@@ -319,6 +345,20 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
|
319
345
|
ranked = sorted(scores.values(), reverse=True)
|
|
320
346
|
margin = ranked[0] - (ranked[1] if len(ranked) > 1 else 0.0)
|
|
321
347
|
confidence = round(min(0.97, 0.5 + margin * 1.5), 2)
|
|
348
|
+
subject, subject_scores = _ai_subject(images, settings)
|
|
349
|
+
render = (
|
|
350
|
+
"aerial" if kind == "landscape" else "architecture" if kind == "architecture" else "studio"
|
|
351
|
+
)
|
|
352
|
+
if subject is not None:
|
|
353
|
+
from reverse3d.vision.classifier import SUBJECT_RENDER, SUBJECT_SCENE
|
|
354
|
+
|
|
355
|
+
flight = signals.drone and signals.nadir_share > 0.3
|
|
356
|
+
if flight: # a drone looking down is terrain whatever the AI thinks it sees
|
|
357
|
+
subject = "terrain" if subject not in ("forest", "terrain") else subject
|
|
358
|
+
kind = SUBJECT_SCENE[subject] # type: ignore[assignment]
|
|
359
|
+
render = SUBJECT_RENDER[subject]
|
|
360
|
+
confidence = round(min(0.98, max(subject_scores.get(subject, 0.0), 0.5)), 2)
|
|
361
|
+
reasons.insert(0, f"ai_{subject}")
|
|
322
362
|
preset = SCENE_PRESETS[kind]
|
|
323
363
|
enough = signals.photos >= preset.min_photos
|
|
324
364
|
if not enough:
|
|
@@ -337,6 +377,10 @@ def detect_scene(image_dir: str | Path, settings: Settings) -> SceneDetection:
|
|
|
337
377
|
recommended=preset.recommended,
|
|
338
378
|
enough_photos=enough,
|
|
339
379
|
advice=advice,
|
|
380
|
+
subject=subject,
|
|
381
|
+
subject_label=_subject_label(subject),
|
|
382
|
+
subject_scores={k: round(v, 3) for k, v in subject_scores.items()},
|
|
383
|
+
render=render,
|
|
340
384
|
)
|
|
341
385
|
|
|
342
386
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""What was photographed: a small local AI (TinyCLIP, MIT) that names the subject.
|
|
2
|
+
|
|
3
|
+
The subject drives everything downstream: how the photos are reconstructed
|
|
4
|
+
(scene profile), how the model is rendered (studio, portrait, architecture or
|
|
5
|
+
aerial light) and which capture advice is given. TinyCLIP compares the photos
|
|
6
|
+
with short descriptions of each subject (zero-shot), so no training data is
|
|
7
|
+
needed and new subjects are a line of text.
|
|
8
|
+
|
|
9
|
+
Like the other AI models, it is allowlisted, pinned to an exact commit,
|
|
10
|
+
loaded from safetensors only and works offline after the first download.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from reverse3d.config import Settings
|
|
23
|
+
from reverse3d.errors import DependencyMissingError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
ALLOWED_CLASSIFIERS: dict[str, tuple[str, str]] = {
|
|
28
|
+
"wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M": ("clip", "MIT"),
|
|
29
|
+
}
|
|
30
|
+
DEFAULT_CLASSIFIER = "wkcn/TinyCLIP-ViT-40M-32-Text-19M-LAION400M"
|
|
31
|
+
|
|
32
|
+
#: subject -> descriptions (averaged). Order matters only for ties.
|
|
33
|
+
SUBJECTS: dict[str, tuple[str, ...]] = {
|
|
34
|
+
"product": (
|
|
35
|
+
"a photo of a product on a table",
|
|
36
|
+
"a photo of a bottle, cup or household object",
|
|
37
|
+
"a photo of an object on a desk",
|
|
38
|
+
),
|
|
39
|
+
"part": ("a photo of a mechanical part", "a photo of a tool or machine component"),
|
|
40
|
+
"figure": ("a photo of a statue or figurine", "a photo of a toy or sculpture"),
|
|
41
|
+
"person": ("a photo of a person", "a photo of a man or a woman standing"),
|
|
42
|
+
"animal": ("a photo of an animal", "a photo of a dog, cat or pet", "a photo of a wild animal"),
|
|
43
|
+
"vehicle": ("a photo of a car", "a photo of a vehicle or motorcycle"),
|
|
44
|
+
"building": ("a photo of a building exterior", "a photo of a house facade"),
|
|
45
|
+
"interior": ("a photo of a room interior", "a photo of the inside of a house"),
|
|
46
|
+
"forest": ("a photo of a forest or trees", "a photo of plants and vegetation"),
|
|
47
|
+
"terrain": ("an aerial photo of land or terrain", "a photo of a landscape or field"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#: How each subject is reconstructed (scene profile) ...
|
|
51
|
+
SUBJECT_SCENE: dict[str, str] = {
|
|
52
|
+
"product": "object",
|
|
53
|
+
"part": "object",
|
|
54
|
+
"figure": "object",
|
|
55
|
+
"vehicle": "object",
|
|
56
|
+
"person": "character",
|
|
57
|
+
"animal": "character",
|
|
58
|
+
"building": "architecture",
|
|
59
|
+
"interior": "architecture",
|
|
60
|
+
"forest": "landscape",
|
|
61
|
+
"terrain": "landscape",
|
|
62
|
+
}
|
|
63
|
+
#: ... and rendered.
|
|
64
|
+
SUBJECT_RENDER: dict[str, str] = {
|
|
65
|
+
"product": "studio",
|
|
66
|
+
"part": "studio",
|
|
67
|
+
"figure": "studio",
|
|
68
|
+
"vehicle": "showroom",
|
|
69
|
+
"person": "portrait",
|
|
70
|
+
"animal": "portrait",
|
|
71
|
+
"building": "architecture",
|
|
72
|
+
"interior": "architecture",
|
|
73
|
+
"forest": "aerial",
|
|
74
|
+
"terrain": "aerial",
|
|
75
|
+
}
|
|
76
|
+
SUBJECT_LABEL_ES: dict[str, str] = {
|
|
77
|
+
"product": "Objeto o producto",
|
|
78
|
+
"part": "Pieza mecánica",
|
|
79
|
+
"figure": "Figura o escultura",
|
|
80
|
+
"vehicle": "Vehículo",
|
|
81
|
+
"person": "Persona",
|
|
82
|
+
"animal": "Animal",
|
|
83
|
+
"building": "Edificio",
|
|
84
|
+
"interior": "Interior",
|
|
85
|
+
"forest": "Bosque o vegetación",
|
|
86
|
+
"terrain": "Terreno o paisaje",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classifier_available() -> bool:
|
|
91
|
+
"""True when the AI stack (torch + transformers) is installed."""
|
|
92
|
+
try:
|
|
93
|
+
import torch # noqa: F401
|
|
94
|
+
import transformers # noqa: F401
|
|
95
|
+
except ImportError:
|
|
96
|
+
return False
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _features(output: Any) -> Any:
|
|
101
|
+
"""Projected embeddings: a tensor in transformers 4, an output object in 5."""
|
|
102
|
+
return output if hasattr(output, "norm") else output.pooler_output
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@lru_cache(maxsize=1)
|
|
106
|
+
def _load(model_dir: str) -> tuple[Any, Any, Any]:
|
|
107
|
+
import torch
|
|
108
|
+
from transformers import CLIPModel, CLIPProcessor
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
from transformers.utils import logging as hf_logging
|
|
112
|
+
|
|
113
|
+
hf_logging.disable_progress_bar()
|
|
114
|
+
hf_logging.set_verbosity_error()
|
|
115
|
+
except ImportError: # pragma: no cover
|
|
116
|
+
pass
|
|
117
|
+
# model_dir is the local snapshot of a commit-pinned, allowlisted model.
|
|
118
|
+
model: Any = CLIPModel.from_pretrained( # nosec B615
|
|
119
|
+
model_dir, local_files_only=True, use_safetensors=True, trust_remote_code=False
|
|
120
|
+
)
|
|
121
|
+
processor = CLIPProcessor.from_pretrained(model_dir, local_files_only=True) # nosec B615
|
|
122
|
+
model.eval()
|
|
123
|
+
prompts = [p for texts in SUBJECTS.values() for p in texts]
|
|
124
|
+
with torch.no_grad():
|
|
125
|
+
tokens = processor(text=prompts, return_tensors="pt", padding=True)
|
|
126
|
+
text = _features(model.get_text_features(**tokens))
|
|
127
|
+
text = text / text.norm(dim=-1, keepdim=True)
|
|
128
|
+
# One averaged, normalised embedding per subject.
|
|
129
|
+
rows, start = [], 0
|
|
130
|
+
for texts in SUBJECTS.values():
|
|
131
|
+
emb = text[start : start + len(texts)].mean(0)
|
|
132
|
+
rows.append(emb / emb.norm())
|
|
133
|
+
start += len(texts)
|
|
134
|
+
return model, processor, torch.stack(rows)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def classify_photos(paths: list[Path], settings: Settings) -> dict[str, float]:
|
|
138
|
+
"""Probability of each subject for a set of photos (averaged over them).
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
DependencyMissingError: the AI extra or the model is not available.
|
|
142
|
+
"""
|
|
143
|
+
if not classifier_available():
|
|
144
|
+
raise DependencyMissingError(
|
|
145
|
+
"The AI subject classifier is not installed.", hint='pip install "reverse3d[depth]"'
|
|
146
|
+
)
|
|
147
|
+
import torch
|
|
148
|
+
from PIL import Image
|
|
149
|
+
|
|
150
|
+
from reverse3d.offline import force_offline_env, is_offline, resolve_model
|
|
151
|
+
|
|
152
|
+
model_dir = resolve_model(DEFAULT_CLASSIFIER, settings)
|
|
153
|
+
if is_offline(settings):
|
|
154
|
+
force_offline_env()
|
|
155
|
+
model, processor, subjects = _load(str(model_dir))
|
|
156
|
+
images = []
|
|
157
|
+
for path in paths:
|
|
158
|
+
with Image.open(path) as image:
|
|
159
|
+
rgb = image.convert("RGB")
|
|
160
|
+
rgb.thumbnail((448, 448))
|
|
161
|
+
images.append(rgb)
|
|
162
|
+
with torch.no_grad():
|
|
163
|
+
pixels = processor(images=images, return_tensors="pt")
|
|
164
|
+
feats = _features(model.get_image_features(**pixels))
|
|
165
|
+
feats = feats / feats.norm(dim=-1, keepdim=True)
|
|
166
|
+
logits = model.logit_scale.exp() * feats @ subjects.T
|
|
167
|
+
probs = logits.softmax(dim=-1).mean(0).cpu().numpy()
|
|
168
|
+
result = {name: float(p) for name, p in zip(SUBJECTS, np.asarray(probs), strict=True)}
|
|
169
|
+
logger.info("Subject: %s", max(result, key=result.__getitem__))
|
|
170
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reverse3d
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: AI-agent-ready toolkit for 3D reverse engineering: image analysis, photogrammetry, mesh repair, primitive fitting and headless rendering.
|
|
5
5
|
Author-email: Voxel Enterprise <info@voxel.lat>
|
|
6
6
|
Maintainer-email: Voxel Enterprise <info@voxel.lat>
|
|
@@ -215,10 +215,11 @@ Cada archivo modificado se **respalda antes** (`*.reverse3d-backup-<fecha>`). So
|
|
|
215
215
|
|
|
216
216
|
| bash (`install.sh`) | PowerShell (`install.ps1`) | Efecto |
|
|
217
217
|
|---------------------|----------------------------|--------|
|
|
218
|
-
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA
|
|
219
|
-
| `--lite` | `-Lite` | Todo
|
|
220
|
-
| `--minimal` | `-Minimal` |
|
|
218
|
+
| *(sin opciones)* | *(sin opciones)* | **Todo**: núcleo, MCP, render, mallado, IA local (con CUDA si hay NVIDIA), Blender y COLMAP |
|
|
219
|
+
| `--lite` | `-Lite` | Todo, con la IA local en la CPU (PyTorch CPU, ~250 MB en vez de ~2.5 GB) |
|
|
220
|
+
| `--minimal` | `-Minimal` | Núcleo + MCP + IA local, sin herramientas externas |
|
|
221
221
|
| `--no-tools` | `-NoTools` | No instalar Blender ni COLMAP |
|
|
222
|
+
| *(en todos los modos)* | *(en todos los modos)* | **La IA local se instala y se descarga siempre** (clasificador, segmentación y profundidad, unos 750 MB) para que todo funcione sin internet |
|
|
222
223
|
| `--no-auto-approve` | `-NoAutoApprove` | Que los agentes pregunten antes de cada herramienta |
|
|
223
224
|
| `--with recon,depth` | `-With recon,depth` | Extras a elección |
|
|
224
225
|
| `--device auto\|cpu\|gpu` | `-Device …` | Dispositivo por defecto |
|
|
@@ -51,6 +51,7 @@ reverse3d/reverse_eng/measure.py
|
|
|
51
51
|
reverse3d/reverse_eng/mesh_cleaner.py
|
|
52
52
|
reverse3d/reverse_eng/parametric.py
|
|
53
53
|
reverse3d/vision/__init__.py
|
|
54
|
+
reverse3d/vision/classifier.py
|
|
54
55
|
reverse3d/vision/feature_matcher.py
|
|
55
56
|
reverse3d/vision/mask_extractor.py
|
|
56
57
|
reverse3d/vision/segmenter.py
|
|
@@ -59,6 +60,7 @@ tests/test_authorship.py
|
|
|
59
60
|
tests/test_automation.py
|
|
60
61
|
tests/test_cli.py
|
|
61
62
|
tests/test_cli_automation.py
|
|
63
|
+
tests/test_color.py
|
|
62
64
|
tests/test_config.py
|
|
63
65
|
tests/test_geometry_fit.py
|
|
64
66
|
tests/test_heic.py
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import trimesh
|
|
7
|
+
|
|
8
|
+
from reverse3d.config import Settings
|
|
9
|
+
from reverse3d.recipes import _clean, _color_from_cloud, _has_vertex_colors, _Run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _two_tone_cloud(path: Path) -> Path:
|
|
13
|
+
"""Points on a sphere: red on top, blue at the bottom (like a COLMAP fused.ply)."""
|
|
14
|
+
pts = trimesh.creation.icosphere(subdivisions=5).vertices
|
|
15
|
+
rgb = np.where(pts[:, 2:3] > 0, [220, 30, 30], [30, 30, 220])
|
|
16
|
+
trimesh.PointCloud(pts, colors=np.column_stack([rgb, np.full(len(pts), 255)])).export(path)
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_mesh_takes_the_colours_of_its_point_cloud(tmp_path: Path) -> None:
|
|
21
|
+
plain = tmp_path / "mesh.ply"
|
|
22
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
23
|
+
assert not _has_vertex_colors(plain)
|
|
24
|
+
colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
|
|
25
|
+
assert colored is not None and _has_vertex_colors(colored)
|
|
26
|
+
mesh = trimesh.load(colored, process=False)
|
|
27
|
+
top = mesh.vertices[:, 2] > 0.3
|
|
28
|
+
assert mesh.visual.vertex_colors[top, 0].mean() > 150 # red on top
|
|
29
|
+
assert mesh.visual.vertex_colors[~top & (mesh.vertices[:, 2] < -0.3), 2].mean() > 150
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cleaning_a_coloured_mesh_also_writes_glb(tmp_path: Path, settings: Settings) -> None:
|
|
33
|
+
plain = tmp_path / "mesh.ply"
|
|
34
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
35
|
+
colored = _color_from_cloud(plain, _two_tone_cloud(tmp_path / "cloud.ply"), tmp_path / "c.ply")
|
|
36
|
+
assert colored is not None
|
|
37
|
+
run = _Run(recipe="photos_to_model", settings=settings, out=tmp_path)
|
|
38
|
+
_clean(run, colored, tmp_path / "model.stl", scale=1.0, overwrite=True)
|
|
39
|
+
assert {"model", "model_color", "model_glb"} <= set(run.outputs)
|
|
40
|
+
glb = trimesh.load(run.outputs["model_glb"], force="mesh")
|
|
41
|
+
assert len(np.unique(np.asarray(glb.visual.vertex_colors)[:, :3], axis=0)) > 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_plain_meshes_get_no_colour_outputs(tmp_path: Path, settings: Settings) -> None:
|
|
45
|
+
plain = tmp_path / "mesh.ply"
|
|
46
|
+
trimesh.creation.icosphere(subdivisions=3).export(plain)
|
|
47
|
+
run = _Run(recipe="mesh_report", settings=settings, out=tmp_path)
|
|
48
|
+
_clean(run, plain, tmp_path / "model.stl", scale=1.0, overwrite=True)
|
|
49
|
+
assert "model_glb" not in run.outputs
|
|
@@ -125,9 +125,11 @@ class TestModels:
|
|
|
125
125
|
|
|
126
126
|
def test_every_allowlisted_model_is_pinned(self, tmp_path: Path) -> None:
|
|
127
127
|
from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
|
|
128
|
+
from reverse3d.vision.classifier import ALLOWED_CLASSIFIERS
|
|
128
129
|
from reverse3d.vision.segmenter import ALLOWED_SEGMENTERS
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
allowed = set(ALLOWED_MODELS) | set(ALLOWED_SEGMENTERS) | set(ALLOWED_CLASSIFIERS)
|
|
132
|
+
assert allowed == set(offline.MODEL_REVISIONS)
|
|
131
133
|
assert all(len(sha) == 40 for sha in offline.MODEL_REVISIONS.values())
|
|
132
134
|
other = tmp_path / "_r3d_home" / "models" / ("models--" + SMALL.replace("/", "--"))
|
|
133
135
|
(other / "snapshots" / "unreviewed").mkdir(parents=True)
|
|
@@ -112,3 +112,55 @@ def test_presets_and_advice_cover_every_scene() -> None:
|
|
|
112
112
|
assert scene_advice(kind, preset.recommended[0])[0]
|
|
113
113
|
assert not SCENE_PRESETS["architecture"].fill_holes
|
|
114
114
|
assert SCENE_PRESETS["object"].fill_holes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fake_subject(monkeypatch: pytest.MonkeyPatch, subject: str) -> None:
|
|
118
|
+
from reverse3d import scenes
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(
|
|
121
|
+
scenes, "_ai_subject", lambda *_a: (subject, {subject: 0.9, "product": 0.1})
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
("subject", "kind", "render"),
|
|
127
|
+
[
|
|
128
|
+
("person", "character", "portrait"),
|
|
129
|
+
("animal", "character", "portrait"),
|
|
130
|
+
("vehicle", "object", "showroom"),
|
|
131
|
+
("building", "architecture", "architecture"),
|
|
132
|
+
("forest", "landscape", "aerial"),
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
def test_ai_subject_decides_scene_and_render(
|
|
136
|
+
tmp_path: Path,
|
|
137
|
+
settings: Settings,
|
|
138
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
139
|
+
subject: str,
|
|
140
|
+
kind: str,
|
|
141
|
+
render: str,
|
|
142
|
+
) -> None:
|
|
143
|
+
_fake_subject(monkeypatch, subject)
|
|
144
|
+
(tmp_path / "set").mkdir()
|
|
145
|
+
for i in range(4):
|
|
146
|
+
cv2.imwrite(str(tmp_path / "set" / f"{i}.jpg"), _facade())
|
|
147
|
+
detection = detect_scene(tmp_path / "set", settings)
|
|
148
|
+
assert (detection.kind, detection.render, detection.subject) == (kind, render, subject)
|
|
149
|
+
assert detection.reasons[0] == f"ai_{subject}" and detection.subject_label
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_drone_flight_is_terrain_whatever_the_ai_says(
|
|
153
|
+
tmp_path: Path, settings: Settings, monkeypatch: pytest.MonkeyPatch
|
|
154
|
+
) -> None:
|
|
155
|
+
_fake_subject(monkeypatch, "building")
|
|
156
|
+
for i in range(6):
|
|
157
|
+
_gps_jpeg(tmp_path / "vuelo" / f"DJI_{i:04d}.jpg", 8.98 + i * 0.0005, -79.52)
|
|
158
|
+
# Gimbal pointing straight down, as DJI writes it in the XMP block.
|
|
159
|
+
path = tmp_path / "vuelo" / f"DJI_{i:04d}.jpg"
|
|
160
|
+
path.write_bytes(path.read_bytes() + b'<x drone-dji:GimbalPitchDegree="-90.0"/>')
|
|
161
|
+
detection = detect_scene(tmp_path / "vuelo", settings)
|
|
162
|
+
assert (detection.kind, detection.subject, detection.render) == (
|
|
163
|
+
"landscape",
|
|
164
|
+
"terrain",
|
|
165
|
+
"aerial",
|
|
166
|
+
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.7.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|