diff --git a/.gitignore b/.gitignore
index dea6ce1..6f68f83 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,12 +51,13 @@ refactor-plan.md
# Project Specific (Cleanup)
target/
-.venv_sharp/
.venv_360/
+.venv_4dgs/
.venv_upscale/
# upscayl-ncnn (downloaded at runtime, not committed)
bin/upscayl-bin
+bin/upscayl-bin.exe
models/upscayl/*.bin
models/upscayl/*.param
*.pth
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1aaa146..910d4c0 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## [2.0.0-win] - Windows / CUDA fork
+
+### ðª Platform port â macOS/Apple Silicon â Windows/NVIDIA CUDA
+- **Device detection** (`app/core/system.py`): replaced Apple Silicon/MPS detection with CUDA detection via `nvidia-smi` (`get_device()` â `cuda`/`cpu`). Added Windows memory probing (`GlobalMemoryStatusEx`) and `.exe`/`COLMAP.bat` binary resolution, incl. auto-detection of `C:\COLMAP`.
+- **GPU acceleration**: FFmpeg frame extraction now uses `-hwaccel cuda` (NVDEC); COLMAP SIFT extraction & matching pass `--SiftExtraction.use_gpu` / `--SiftMatching.use_gpu` when CUDA is present; Brush runs on wgpu with `WGPU_BACKEND=dx12`.
+- **Zero-touch engine install**: COLMAP (CUDA) and FFmpeg now auto-install on first launch â `ColmapEngineDep` downloads the `colmap-x64-windows-cuda.zip` release into `engines/colmap`, and `FfmpegEngineDep` installs FFmpeg (winget, else a static build into `engines/ffmpeg`). `resolve_binary` finds them and `run_command` adds COLMAP's `bin`/`lib` to PATH so bundled DLLs load. No GPU â non-CUDA COLMAP build is selected automatically.
+- **Windows launcher**: new `run.bat` replaces the macOS `run.command`.
+- **Installers**: dropped Homebrew/Xcode; system dependencies (FFmpeg) install via `winget`, COLMAP CUDA build is detected/guided, Node/CMake/Ninja via winget, Rust via `rustup-init.exe`. Removed the macOS-only `pyobjc` dependency.
+- **upscayl-bin**: Windows asset selection and `upscayl-bin.exe` discovery.
+
+### ð Removed
+- **Apple ML Sharp** (image/video â 3D) â MLX/Apple-Silicon only with no Windows equivalent. Removed the engine, installer, GUI tab, CLI `sharp` subcommand and related tests. The pipeline now focuses on **video â frames â COLMAP â Brush splat training â view**.
+
+### 𧪠Testing
+- Test suite updated for the new platform and trimmed of macOS-only cases â **189/189 pass**.
+
## [1.0.1] - 2026-06-18
### ð Bug Fixes
diff --git a/README.md b/README.md
index 36567ca..afe68f6 100644
--- a/README.md
+++ b/README.md
@@ -1,115 +1,87 @@
-# CorbeauSplat v1.0.0
+# CorbeauSplat â Windows / CUDA Edition
-**CorbeauSplat** is an all-in-one Gaussian Splatting automation tool designed specifically for **macOS Silicon**. It streamlines the entire workflow from raw video/images to a fully trained and viewable 3D scene (Gaussian Splat).
+> ð **Fork of the original [CorbeauSplat](https://github.com/freddewitt/CorbeauSplat) by [@freddewitt](https://github.com/freddewitt)** (a macOS / Apple-Silicon project). All credit for the original concept and pipeline goes to the upstream author â this edition ports it to **Windows + NVIDIA CUDA** and is focused on **video â frames â COLMAP â Brush splat training â view**.
-> ð **v1.0.0 â First Stable Release**
-> After extensive security hardening, architectural refactoring, and 200+ unit tests.
-
-
-
-[](https://www.buymeacoffee.com/freddewitt)
-
-
+**CorbeauSplat (Windows / CUDA Edition)** is an all-in-one Gaussian Splatting automation tool for **Windows with NVIDIA CUDA GPUs**. It streamlines the whole workflow from raw video/images to a fully trained and viewable 3D scene (Gaussian Splat).

## ð What it does
-This application provides a unified Graphical User Interface (GUI) to orchestrate the following steps:
-1. **Project Management**: Automatically organizes your outputs into structured project folders with images, sparse data, and checkpoints.
-2. **Sparse Reconstruction**: Automates **COLMAP** feature extraction, matching, and mapping. Supports **Glomap** as a modern alternative mapper.
-3. **Undistortion**: Automatically undistorts images for optimal training quality.
-4. **AI Upscaling**: Optionally enhances input images before reconstruction using **upscayl-ncnn** â a fast NCNN-based upscaler with 6 curated models (Real-ESRGAN x4+, 4xLSDIR, 4xNomos8kSC, and more). Installed automatically at first launch.
-5. **Training**: Integrates **Brush** to train Gaussian Splats directly on your Mac.
-6. **Visualization**: Includes a built-in tab running **SuperSplat** for immediate local viewing and editing of your PLY files.
-7. **ML Sharp (Image/Video to 3D)**: Uses **Apple ML Sharp** to generate a 3D model from a single image or a sequence of 3D models directly from a video.
-8. **4DGS Preparation (Experimental)**: A new module to prepare 4D Gaussian Splatting datasets (Multi-camera video -> Nerfstudio format).
-9. **360 Extractor (Experimental)**: Converts equirectangular 360° videos into optimal planar image sets (Cube Map, Ring, etc.) for photogrammetry, with AI operator masking.
-
-It is designed to be "click-and-run", handling dependency checks, process management, and **session persistence** for you.
-It also includes built-in full localization support for **French, English, German, Italian, Spanish, Arabic, Russian, Chinese, and Japanese**.
-
-## âïž A Note from the Author
-
-> This program was realized through **"vibecoding"** with the help of **Gemini 3 Pro**.
->
-> It was originally created to facilitate the technical workflow for a documentary film titled **"Le Corbeau"**. I am not a professional developer; I simply needed to automate a complex process by gathering the tools I use daily: COLMAP, the Brush app, and SuperSplat.
->
-> I share this code in all humility. I didn't originally plan to release it, but I thought that perhaps someone, somewhere on this earth, might find it useful.
->
-> As this software was built via "vibecoding" (AI-assisted coding), it is provided "as is" with no guarantees.
+A unified GUI (and CLI) to orchestrate:
+1. **Project Management**: Organises outputs into structured project folders (images, sparse data, checkpoints).
+2. **Frame Extraction**: Pulls frames from a video with **FFmpeg**, using **NVIDIA NVDEC (`-hwaccel cuda`)** when a GPU is present.
+3. **Sparse Reconstruction**: Automates **COLMAP** feature extraction, matching and mapping with **GPU-accelerated SIFT** (the CUDA build uses the GPU automatically). Defaults are tuned for video: **sequential** matching and optional **GPU bundle adjustment** (COLMAP 4.1.0). Supports **Glomap** as an alternative mapper.
+4. **Undistortion**: Optionally undistorts images for optimal training quality.
+5. **AI Upscaling** *(optional)*: Enhances input images with **upscayl-ncnn** before reconstruction.
+6. **Training**: Integrates **Brush** (Rust/wgpu, DirectX 12 / Vulkan) to train Gaussian Splats on your GPU.
+7. **Visualization**: Built-in **SuperSplat** tab for immediate local viewing/editing of `.ply` files.
+8. **Splat Cleanup**: A **Nettoyage / Cleaner** tab that loads a `.ply` and automatically removes sky, "floaters", near-transparent and isolated splats (variance/opacity/scale + outlier filtering), with a SuperSplat preview before saving. Also available via `python main.py clean -i in.ply -o out.ply`.
+8. **4DGS Preparation (Experimental)**: Prepares 4D Gaussian Splatting datasets (multi-camera video â Nerfstudio format).
+9. **360 Extractor (Experimental)**: Converts equirectangular 360° videos into planar image sets.
+
+It is designed to be "click-and-run", with dependency checks, process management and session persistence.
+Includes full localization for **French, English, German, Italian, Spanish, Arabic, Russian, Chinese and Japanese**.
## ð Prerequisites & Installation
### Requirements
-- **macOS** (Apple Silicon recommended)
-- **Python 3.13+** (Recommended for JIT/Performance) or Python 3.11 (Supported)
-- **Xcode Command Line Tools** (Required for compiling custom engines like Glomap or Brush)
-- **Homebrew** (for installing system dependencies like COLMAP and FFmpeg)
-- **Git**
+- **Windows 10/11 (x64)**
+- **NVIDIA GPU** with up-to-date drivers (for CUDA acceleration; CPU fallback works but is slow)
+- **Python 3.11+** ([python.org](https://www.python.org/downloads/) â tick *"Add Python to PATH"*)
+- **Git** (optional, for updates and source builds)
+
+That's it â **COLMAP (CUDA) and FFmpeg are installed automatically** on first launch (see below). No manual download/PATH setup required.
### Installation
-1. Clone this repository:
- ```bash
- git clone https://github.com/freddewitt/CorbeauSplat.git
- cd CorbeauSplat
- ```
-
-2. Run the launcher:
- ```bash
- ./run.command
- ```
- *The script will automatically detect missing dependencies (Python packages, Brush, SuperSplat, Rust, Node.js, etc.) and attempt to install them for you.*
+1. Clone or download this repository.
+2. Double-click **`run.bat`** (or run it from a terminal).
+
+ On first launch it automatically:
+ - creates a Python virtual environment and installs the Python dependencies;
+ - **downloads the CUDA COLMAP build** (`colmap-x64-windows-cuda.zip`) from GitHub releases into `engines\colmap\`;
+ - **installs FFmpeg** (via `winget` if available, otherwise a static build into `engines\ffmpeg\`);
+ - downloads/builds the other engines (Brush, SuperSplat, upscayl);
+ - launches the app.
+
+> All auto-downloaded engines live under `engines\` (git-ignored). To re-download everything cleanly, run `run.bat --clean`.
+>
+> If you already have COLMAP installed system-wide, CorbeauSplat will use that instead (it checks PATH and `C:\COLMAP`). No GPU? It falls back to the non-CUDA COLMAP build and CPU execution automatically.
+
+Optional flags:
+```bat
+run.bat --clean :: wipe venvs + engines + config and start fresh
+```
## ð How to Use
-1. **Configuration Tab**:
- - Select your input (Video or Folder of images).
- - Define a **Project Name** (your files will be saved in `[Output Folder]/[Project Name]`).
- - Click **"Create COLMAP Dataset"**.
-2. **Params Tab**: (Optional) Tweak advanced COLMAP settings or enable **Glomap**.
-3. **Upscale Tab**: (Optional)
- - Enable **"Enable Upscale"** in the Training tab to apply upscaling during dataset creation.
- - `upscayl-bin` is automatically downloaded and installed on first launch â no manual setup required.
- - Choose a model (e.g., Real-ESRGAN x4+ for photos, 4xLSDIR for ultra fidelity) and configure scale, format, and tile size.
- - Download additional models directly from the tab (4xLSDIR, 4xNomos8kSC, NMKD-Siax).
-4. **Brush Tab**:
- - **Auto-Refine**: Choose "Refine" mode to resume training from the latest checkpoint.
- - **Presets**: Use specific densification strategies (e.g., "Aggressive Densification").
- - Click **"Start Brush Training"**.
-5. **SuperSplat Tab**:
- - Load your trained `.ply` file.
- - Click **"Start Servers"** to launch the viewer locally.
-6. **4DGS Tab (Experimental)**:
- - Check **"Activate"** to install the required dependencies (Nerfstudio).
- - Select a folder containing your synced camera videos.
- - Click **"Start Process"** to generate a dataset ready for 4DGS training.
-7. **360 Extractor Tab (Experimental)**:
- - **Activate**: Install the dedicated environment (PySide6, YOLOv8).
- - **Convert**: Extract images from 360° videos with advanced layouts (Ring, Cube Map, Fibonacci).
- - **AI Masking**: Automatically mask the operator.
-8. **Apple Sharp Tab (Bonus)**:
- - Select a single source image or a **Video**.
- - Click **"Predict 3D Model"** or **"Start Conversion"** to generate a mesh sequence using machine learning.
+1. **Configuration Tab**: choose your input (a video or a folder of images), set a **Project Name**, then click **"Create COLMAP Dataset"**.
+2. **Params Tab**: *(optional)* tweak COLMAP settings or enable **Glomap**.
+3. **Upscale Tab**: *(optional)* enable upscaling and pick a model.
+4. **Brush Tab**: choose a preset (e.g. "Aggressive Densification"), set **Device** to `cuda`, then **"Start Brush Training"**.
+5. **SuperSplat Tab**: load your `.ply` and **"Start Servers"** to view it locally.
### âšïž Command Line Interface (CLI)
-CorbeauSplat exposes all its features via the command line.
+```bat
+python main.py pipeline -i video.mp4 -o C:\projects --type video --preset dense
+python main.py colmap -i C:\photos -o C:\projects
+python main.py brush -i C:\projects\scene -o C:\projects\scene --device cuda --preset dense
+python main.py view -i scene.ply
+python main.py upscale -i image.png -o C:\out --scale 4
+```
-ᅵ **[See CLI.md for full command line documentation](CLI.md)**
+Run `python main.py --help` for per-command options. No arguments launches the GUI.
## ð Acknowledgments & Credits
-This project stands on the shoulders of giants. A huge thank you to the creators of the core technologies used here:
-
* **COLMAP**: Structure-from-Motion and Multi-View Stereo. [GitHub](https://github.com/colmap/colmap)
-* **Brush**: An efficient Gaussian Splatting trainer for macOS. [GitHub](https://github.com/ArthurBrussee/brush)
-* **SuperSplat**: An amazing web-based Splat editor by PlayCanvas. [GitHub](https://github.com/playcanvas/supersplat)
-* **360Extractor**: Advanced 360° video extraction tool. [GitHub](https://github.com/nicolasdiolez/360Extractor)
-* **Apple ML Sharp**: Machine Learning tools for Swift. [GitHub](https://github.com/apple/ml-sharp)
-* **Nerfstudio**: The modular NeRF and Splatting framework (used for 4DGS data prep). [GitHub](https://github.com/nerfstudio-project/nerfstudio)
-* **upscayl-ncnn**: High-performance AI image upscaling using NCNN. Powers the Upscale tab. [GitHub](https://github.com/upscayl/upscayl-ncnn)
+* **Brush**: An efficient cross-platform Gaussian Splatting trainer (Rust/wgpu). [GitHub](https://github.com/ArthurBrussee/brush)
+* **SuperSplat**: Web-based Splat editor by PlayCanvas. [GitHub](https://github.com/playcanvas/supersplat)
+* **Glomap**: Global Structure-from-Motion. [GitHub](https://github.com/colmap/glomap)
+* **Nerfstudio**: NeRF and Splatting framework (used for 4DGS data prep). [GitHub](https://github.com/nerfstudio-project/nerfstudio)
+* **upscayl-ncnn**: High-performance AI image upscaling (NCNN). [GitHub](https://github.com/upscayl/upscayl-ncnn)
## ð License
-This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. This is the most permissive open-source license, allowing you to use, modify, and distribute this software freely.
+MIT â see [LICENSE](LICENSE). Originally created for the documentary *"Le Corbeau"*; ported to Windows/CUDA.
diff --git a/app/cli/__init__.py b/app/cli/__init__.py
index 5358105..84dad18 100644
--- a/app/cli/__init__.py
+++ b/app/cli/__init__.py
@@ -2,29 +2,20 @@
"""CorbeauSplat CLI package â command-line interface and GUI launcher."""
import sys
-from .parser import get_parser
-from .commands import (
- run_colmap,
- run_brush,
- run_sharp,
- run_supersplat,
- run_upscale,
- run_4dgs,
- run_extract360,
- run_pipeline,
- DISPATCH,
- BRUSH_DEFAULTS,
- BRUSH_PRESETS,
-)
-from .launcher import _launch_gui
from app.core.system import check_dependencies
+from .launcher import _launch_gui
+from .parser import get_parser
+
def main():
parser = get_parser()
args = parser.parse_args()
- # No subcommand + no --gui â GUI par défaut
+ # No subcommand + no --gui â GUI par défaut.
+ # The GUI is the default path, so the heavy CLI engine stack (.commands pulls
+ # in ColmapEngine/BrushEngine/⊠and app.core.engine) is imported lazily in the
+ # CLI branch below â a GUI launch must not pay for importing it.
if not args.command and not args.gui:
_launch_gui()
return
@@ -33,6 +24,8 @@ def main():
_launch_gui()
return
+ from .commands import DISPATCH
+
missing_deps = check_dependencies()
if missing_deps:
print(f"Attention : dépendances manquantes : {', '.join(missing_deps)}")
diff --git a/app/cli/commands.py b/app/cli/commands.py
index 2ffb9e5..aaf9ee9 100644
--- a/app/cli/commands.py
+++ b/app/cli/commands.py
@@ -1,19 +1,17 @@
#!/usr/bin/env python3
"""CLI command handlers for CorbeauSplat."""
-import sys
import os
+import sys
import time
from pathlib import Path as _Path
+from app.core.brush_engine import BrushEngine, rename_latest_ply
+from app.core.engine import ColmapEngine
from app.core.i18n import tr
from app.core.params import ColmapParams
-from app.core.engine import ColmapEngine
-from app.core.brush_engine import BrushEngine
-from app.core.sharp_engine import SharpEngine
from app.core.superplat_engine import SuperSplatEngine
from app.core.system import get_brush_build_mode
-
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Brush defaults and presets
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
@@ -33,6 +31,19 @@
"refine_mode": False,
}
+_BLUR_FACTORS = {"light": 0.5, "medium": 0.7, "strong": 0.85}
+
+
+def _apply_robust(params):
+ """Stabilise COLMAP bundle adjustment on large scenes (anti-crash)."""
+ params.camera_model = "PINHOLE"
+ params.ba_refine_extra_params = False
+ params.ba_refine_principal_point = False
+ params.multiple_models = True
+ params.filter_blurry = True
+ return params
+
+
BRUSH_PRESETS = {
"fast": {
"total_steps": 7000, "refine_every": 100,
@@ -63,7 +74,7 @@ def run_colmap(args):
max_image_size=args.max_image_size,
max_num_features=args.max_num_features,
estimate_affine_shape=args.estimate_affine_shape,
- domain_size_pooling=not args.no_domain_size_pooling,
+ domain_size_pooling=args.domain_size_pooling,
max_ratio=args.max_ratio,
max_distance=args.max_distance,
cross_check=not args.no_cross_check,
@@ -74,9 +85,15 @@ def run_colmap(args):
ba_refine_extra_params=not args.no_refine_extra,
min_num_matches=args.min_num_matches,
matcher_type=args.matcher_type,
+ sequential_overlap=args.sequential_overlap,
+ loop_detection=not args.no_loop_detection,
undistort_images=args.undistort,
use_glomap=args.use_glomap,
+ filter_blurry=args.filter_blur,
+ blur_factor=_BLUR_FACTORS.get(args.blur_strength, 0.7),
)
+ if getattr(args, "robust", False):
+ _apply_robust(params)
print(tr("cli_start_colmap"))
print(tr("cli_input", args.input))
@@ -137,6 +154,8 @@ def run_brush(args):
try:
returncode = engine.train(args.input, args.output, params=params)
if returncode == 0:
+ # CLI parity with the GUI: honour --ply_name on the exported splat.
+ rename_latest_ply(args.output, params.get("ply_name"), log=print)
print(tr("msg_success"))
else:
print(tr("msg_error"))
@@ -146,64 +165,18 @@ def run_brush(args):
engine.stop()
-def run_sharp(args):
- engine = SharpEngine(logger_callback=print)
-
- params = {
- "checkpoint": args.checkpoint,
- "device": args.device,
- "verbose": args.verbose,
- }
-
- if args.mode == "image":
- print(tr("cli_start_sharp"))
- print(tr("cli_input", args.input))
- print(tr("cli_output", args.output))
-
- try:
- returncode = engine.predict(args.input, args.output, params=params)
- if returncode == 0:
- print(tr("msg_success"))
- else:
- print(tr("msg_error"))
- sys.exit(1)
- except KeyboardInterrupt:
- print(tr("cli_stopping"))
- engine.stop()
-
- else: # video mode
- _run_sharp_video(args, engine, params)
-
-
-def _run_sharp_video(args, engine, params):
- """CLI handler for Sharp video mode â delegates to shared SharpEngine.process_video_frames()."""
- video_path = _Path(args.input)
- output_dir = _Path(args.output)
- skip = max(1, args.skip_frames)
-
- print(f"Sharp vidéo : {video_path.name} (1 frame / {skip})")
- print(tr("cli_output", args.output))
-
- params["skip_frames"] = skip
+def run_clean(args):
+ from app.core.ply_cleaner import clean_ply
+ print(f"Nettoyage du splat : {args.input} (sévérité: {args.strength})")
try:
- success_count = engine.process_video_frames(
- video_path=str(video_path),
- output_dir=str(output_dir),
- params=params,
- log_callback=print,
- status_callback=lambda s: print(f" {s}"),
- progress_callback=None,
- cancel_check=None,
- )
- except KeyboardInterrupt:
- print(tr("cli_stopping"))
- engine.stop()
+ stats = clean_ply(args.input, args.output, strength=args.strength, log=print)
+ except Exception as e:
+ print(f"{tr('msg_error')}: {e}")
sys.exit(1)
- print(f"Terminé : {success_count} frames converties.")
- if success_count == 0:
- sys.exit(1)
+ print(f"â
{stats['kept']}/{stats['total']} splats conservés "
+ f"({stats['removed']} retirés) â {args.output}")
def run_supersplat(args):
@@ -393,10 +366,16 @@ def run_pipeline(args):
colmap_params = ColmapParams(
camera_model=args.camera_model,
matcher_type=args.matcher_type,
+ sequential_overlap=args.sequential_overlap,
+ loop_detection=not args.no_loop_detection,
max_image_size=args.max_image_size,
undistort_images=args.undistort,
use_glomap=args.use_glomap,
+ filter_blurry=args.filter_blur,
+ blur_factor=_BLUR_FACTORS.get(args.blur_strength, 0.7),
)
+ if getattr(args, "robust", False):
+ _apply_robust(colmap_params)
colmap_engine = ColmapEngine(
colmap_params, args.input, args.output, args.type, args.fps,
@@ -417,6 +396,13 @@ def run_pipeline(args):
sys.exit(1)
dataset_path = _Path(args.output) / args.project_name
+ # Train on the undistorted dense/ dataset when it exists â otherwise the
+ # --undistort step would be dead weight (Brush would silently read the
+ # original distorted images/ + sparse/0 from the project root).
+ dense_dir = dataset_path / "dense"
+ if args.undistort and (dense_dir / "images").is_dir() and (dense_dir / "sparse").is_dir():
+ dataset_path = dense_dir
+ print("Images non-distordues détectées : entraînement sur dense/.")
print(f"\nDataset prêt : {dataset_path}")
# ââ Ãtape 2 : Brush âââââââââââââââââââââââââââââââââââââââââââââââââââââââ
@@ -453,6 +439,7 @@ def run_pipeline(args):
sys.exit(0)
if returncode == 0:
+ rename_latest_ply(str(dataset_path), brush_params.get("ply_name"), log=print)
print(f"\nPipeline terminé. Splat disponible dans : {dataset_path}")
else:
print(f"\nBrush a retourné une erreur (code {returncode}).")
@@ -467,7 +454,7 @@ def run_pipeline(args):
"pipeline": run_pipeline,
"colmap": run_colmap,
"brush": run_brush,
- "sharp": run_sharp,
+ "clean": run_clean,
"view": run_supersplat,
"upscale": run_upscale,
"4dgs": run_4dgs,
diff --git a/app/cli/launcher.py b/app/cli/launcher.py
index 3fe79b5..b7b15a2 100644
--- a/app/cli/launcher.py
+++ b/app/cli/launcher.py
@@ -1,38 +1,24 @@
#!/usr/bin/env python3
-"""GUI launcher helpers for CorbeauSplat."""
+"""GUI launcher helpers for CorbeauSplat (Windows)."""
import sys
from pathlib import Path as _Path
-def _set_macos_dock_icon(icon_path: _Path):
- try:
- from AppKit import NSApplication, NSImage
- ns_image = NSImage.alloc().initWithContentsOfFile_(str(icon_path))
- if ns_image:
- NSApplication.sharedApplication().setApplicationIconImage_(ns_image)
- except Exception:
- pass
-
-
def _launch_gui():
- from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QIcon
- from PyQt6.QtCore import QTimer
+ from PyQt6.QtWidgets import QApplication
+
from app.gui.main_window import ColmapGUI
app = QApplication(sys.argv)
assets = _Path(__file__).resolve().parent.parent.parent / "assets"
- png_path = assets / "icon.png"
- icns_path = assets / "icon.icns"
- icon_file = png_path if png_path.exists() else icns_path
+ png_path = assets / "icon.png"
+ ico_path = assets / "icon.ico"
+ icon_file = ico_path if ico_path.exists() else png_path
if icon_file.exists():
app.setWindowIcon(QIcon(str(icon_file)))
- dock_src = icns_path if icns_path.exists() else png_path
- if dock_src.exists():
- QTimer.singleShot(0, lambda: _set_macos_dock_icon(dock_src))
-
window = ColmapGUI()
window.show()
sys.exit(app.exec())
diff --git a/app/cli/parser.py b/app/cli/parser.py
index a84cfca..0ce7b46 100644
--- a/app/cli/parser.py
+++ b/app/cli/parser.py
@@ -15,7 +15,6 @@ def get_parser():
" python3 main.py pipeline -i video.mp4 -o ~/projets --type video --preset dense\n"
" python3 main.py colmap -i video.mp4 -o ~/projets\n"
" python3 main.py brush -i ~/projets/scene -o ~/projets/scene --preset dense\n"
- " python3 main.py sharp -i photo.jpg -o ~/out\n"
" python3 main.py view -i splat.ply\n"
" python3 main.py upscale -i image.png -o ~/out --scale 4\n"
" python3 main.py 4dgs -i ~/videos -o ~/out\n"
@@ -53,8 +52,12 @@ def get_parser():
help="ModÚle de caméra COLMAP (défaut: SIMPLE_RADIAL)")
p.add_argument("--undistort", action="store_true", help="Undistortion aprÚs reconstruction")
p.add_argument("--use_glomap", action="store_true", help="Utiliser Glomap au lieu du mapper COLMAP")
- p.add_argument("--matcher_type", choices=["exhaustive","sequential","vocab_tree"], default="exhaustive",
- help="Stratégie de matching (défaut: exhaustive)")
+ p.add_argument("--matcher_type", choices=["exhaustive","sequential","vocab_tree"], default="sequential",
+ help="Stratégie de matching (défaut: sequential â rapide et correct pour vidéos)")
+ p.add_argument("--sequential_overlap", type=int, default=30,
+ help="Nb d'images voisines comparées en matching séquentiel (défaut: 30)")
+ p.add_argument("--no_loop_detection", action="store_true",
+ help="Désactiver la détection de boucles (anti-duplication des lieux revisités)")
p.add_argument("--max_image_size", type=int, default=3200,
help="Résolution max des images pour COLMAP (défaut: 3200)")
# Brush
@@ -65,11 +68,16 @@ def get_parser():
p.add_argument("--sh_degree", type=int, default=None, choices=range(1,5),
help="Degré Spherical Harmonics 1-4 (défaut: 3)")
p.add_argument("--device", default="auto",
- choices=["auto","mps","cuda","cpu"], help="Device Brush (défaut: auto)")
+ choices=["auto","cuda","cpu"], help="Device Brush (défaut: auto)")
p.add_argument("--with_viewer", action="store_true", help="Ouvrir le viewer interactif aprÚs entraînement")
p.add_argument("--max_resolution", type=int, default=None,
help="Résolution max entraînement 0=auto (défaut: 0)")
p.add_argument("--ply_name", default=None, help="Nom du fichier PLY de sortie")
+ p.add_argument("--filter_blur", action="store_true", help="Ãcarter les images floues avant COLMAP")
+ p.add_argument("--blur_strength", choices=["light", "medium", "strong"], default="medium",
+ help="Sévérité du filtre de flou (défaut: medium)")
+ p.add_argument("--robust", action="store_true",
+ help="Mode robuste grandes scÚnes (PINHOLE, anti-crash BA, filtre flou)")
# ââ colmap ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
p = subs.add_parser("colmap", help="Pipeline COLMAP (vidéo/images â dataset)")
@@ -89,10 +97,15 @@ def get_parser():
p.add_argument("--max_image_size", type=int, default=3200, help="Résolution max des images (défaut: 3200)")
p.add_argument("--max_num_features", type=int, default=8192, help="Nb max de features par image (défaut: 8192)")
p.add_argument("--estimate_affine_shape", action="store_true", help="Estimer la forme affine des features")
- p.add_argument("--no_domain_size_pooling", action="store_true", help="Désactiver le domain size pooling")
+ p.add_argument("--domain_size_pooling", action="store_true",
+ help="Activer le domain size pooling (force le SIFT sur CPU â désactivé par défaut)")
# Feature matching
- p.add_argument("--matcher_type", choices=["exhaustive","sequential","vocab_tree"], default="exhaustive",
- help="Stratégie de matching (défaut: exhaustive)")
+ p.add_argument("--matcher_type", choices=["exhaustive","sequential","vocab_tree"], default="sequential",
+ help="Stratégie de matching (défaut: sequential â rapide et correct pour vidéos)")
+ p.add_argument("--sequential_overlap", type=int, default=30,
+ help="Nb d'images voisines comparées en matching séquentiel (défaut: 30)")
+ p.add_argument("--no_loop_detection", action="store_true",
+ help="Désactiver la détection de boucles (anti-duplication des lieux revisités)")
p.add_argument("--max_ratio", type=float, default=0.8, help="Ratio max Lowe (défaut: 0.8)")
p.add_argument("--max_distance", type=float, default=0.7, help="Distance max (défaut: 0.7)")
p.add_argument("--no_cross_check", action="store_true", help="Désactiver le cross-check")
@@ -103,6 +116,12 @@ def get_parser():
p.add_argument("--no_refine_focal", action="store_true", help="Ne pas affiner la focale")
p.add_argument("--refine_principal", action="store_true", help="Affiner le point principal")
p.add_argument("--no_refine_extra", action="store_true", help="Ne pas affiner les params extra")
+ # Blur filtering
+ p.add_argument("--filter_blur", action="store_true", help="Ãcarter les images floues avant COLMAP")
+ p.add_argument("--blur_strength", choices=["light", "medium", "strong"], default="medium",
+ help="Sévérité du filtre de flou (défaut: medium)")
+ p.add_argument("--robust", action="store_true",
+ help="Mode robuste grandes scÚnes (PINHOLE, anti-crash BA, filtre flou)")
# ââ brush âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
p = subs.add_parser("brush", help="Entraînement Gaussian Splat (Brush)")
@@ -115,7 +134,7 @@ def get_parser():
p.add_argument("--sh_degree", type=int, default=None, choices=range(1,5),
help="Degré Spherical Harmonics 1-4 (défaut: 3)")
p.add_argument("--device", default="auto",
- choices=["auto","mps","cuda","cpu"], help="Device (défaut: auto)")
+ choices=["auto","cuda","cpu"], help="Device (défaut: auto)")
p.add_argument("--refine_mode", action="store_true", help="Mode Refine (reprend depuis dernier checkpoint)")
p.add_argument("--with_viewer", action="store_true", help="Ouvrir le viewer interactif")
p.add_argument("--ply_name", default=None, help="Nom du fichier PLY de sortie")
@@ -130,21 +149,6 @@ def get_parser():
p.add_argument("--checkpoint_interval", type=int, default=None, help="Sauvegarder tous les N iters (défaut: 7000)")
p.add_argument("--max_resolution", type=int, default=None, help="Résolution max entraînement 0=auto (défaut: 0)")
- # ââ sharp âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
- p = subs.add_parser("sharp", help="Single Image/Vidéo â 3D Splat (ML-Sharp)")
- p.add_argument("--input", "-i", required=True, help="Image, dossier d'images ou vidéo")
- p.add_argument("--output", "-o", required=True, help="Dossier de sortie")
- p.add_argument("--mode", choices=["image","video"], default="image",
- help="Mode : image unique ou vidéo (défaut: image)")
- p.add_argument("--checkpoint", "-c", default=None, help="Chemin vers un checkpoint .pt")
- p.add_argument("--device", default="default",
- choices=["default","mps","cpu","cuda"], help="Device (défaut: default)")
- p.add_argument("--skip_frames", type=int, default=1,
- help="[mode vidéo] Traiter 1 frame sur N (défaut: 1)")
- p.add_argument("--upscale", action="store_true",
- help="Upscaler les images avant prédiction (requiert upscayl-bin)")
- p.add_argument("--verbose", action="store_true", help="Afficher la sortie détaillée de Sharp")
-
# ââ view ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
p = subs.add_parser("view", help="Visualiser un .ply dans SuperSplat")
p.add_argument("--input", "-i", required=True, help="Fichier .ply ou dossier")
@@ -170,6 +174,13 @@ def get_parser():
p.add_argument("--compression", type=int, default=0,
help="Niveau de compression sortie 0-9 (défaut: 0)")
+ # ââ clean âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+ p = subs.add_parser("clean", help="Nettoyer un splat .ply (ciel/floaters/bruit)")
+ p.add_argument("--input", "-i", required=True, help="Fichier .ply à nettoyer")
+ p.add_argument("--output", "-o", required=True, help="Fichier .ply de sortie")
+ p.add_argument("--strength", choices=["light", "medium", "strong"], default="medium",
+ help="Sévérité du nettoyage (défaut: medium)")
+
# ââ 4dgs ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
p = subs.add_parser("4dgs", help="Préparation dataset 4D Gaussian Splatting (Nerfstudio)")
p.add_argument("--input", "-i", required=True,
diff --git a/app/core/base_engine.py b/app/core/base_engine.py
index 2868703..f9a969b 100644
--- a/app/core/base_engine.py
+++ b/app/core/base_engine.py
@@ -1,10 +1,11 @@
+import logging
import os
-import sys
import signal
import subprocess
-import logging
+import sys
+from collections.abc import Iterator
from pathlib import Path
-from typing import Iterator
+
from .system import get_device, resolve_project_root
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
@@ -13,19 +14,19 @@ class IProcessRunner:
"""Interface abstraite pour l'exécution d'un processus systéme (DIP & Testabilité)"""
def start(self, cmd: list, env: dict = None, **kwargs):
raise NotImplementedError()
-
+
def poll(self):
raise NotImplementedError()
-
+
def wait(self, timeout=None):
raise NotImplementedError()
-
+
def terminate(self):
raise NotImplementedError()
-
+
def stdout_iter(self) -> Iterator[str]:
raise NotImplementedError()
-
+
def get_returncode(self) -> int:
raise NotImplementedError()
@@ -33,47 +34,59 @@ class SubprocessRunner(IProcessRunner):
"""Implémentation concrÚte de l'OS via subprocess"""
def __init__(self):
self._process = None
-
+
def start(self, cmd: list, env: dict = None, **kwargs):
base_kwargs = {
'stdout': subprocess.PIPE,
'stderr': subprocess.STDOUT,
'text': True,
+ 'bufsize': 1, # line-buffered: flush COLMAP/bundle-adjustment progress
+ # promptly so the long global-BA phase doesn't look frozen
}
base_kwargs.update(kwargs)
-
+
# Sécurisation du process group pour permettre de kill l'arbre process
if sys.platform != "win32" and 'preexec_fn' not in base_kwargs:
base_kwargs['preexec_fn'] = os.setsid
-
+
self._process = subprocess.Popen(cmd, env=env, **base_kwargs)
return self._process
-
+
def poll(self):
if self._process: return self._process.poll()
return None
-
+
def wait(self, timeout=None):
if self._process: return self._process.wait(timeout)
return None
-
+
def terminate(self):
if not self._process: return
try:
if sys.platform != "win32":
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
+ self._process.wait(timeout=5)
else:
- self._process.terminate()
- self._process.wait(timeout=5)
+ # On Windows a `cmd /c npx ...` wrapper spawns child node/colmap
+ # processes that process.terminate() does NOT kill, leaving them
+ # orphaned (e.g. holding the SuperSplat port). Kill the whole tree.
+ subprocess.run(
+ ["taskkill", "/F", "/T", "/PID", str(self._process.pid)],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ self._process.wait(timeout=5)
except (ProcessLookupError, PermissionError, OSError, subprocess.TimeoutExpired):
- self._process.kill()
- self._process.wait()
-
+ try:
+ self._process.kill()
+ self._process.wait(timeout=5)
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+
def stdout_iter(self) -> Iterator[str]:
if getattr(self._process, 'stdout', None):
for line in self._process.stdout:
yield line
-
+
def get_returncode(self) -> int:
if self._process: return self._process.returncode
return -1
@@ -89,9 +102,9 @@ def __init__(self, name, logger_callback=None, process_runner: IProcessRunner =
self.device = get_device()
self.project_root = resolve_project_root()
self.stop_requested = False
-
+
self.logger = logging.getLogger(self.name)
-
+
# SOLID-DIP : Injection abstraite pour tests (mockable)
self.runner = process_runner or SubprocessRunner()
self.process = None # Retro-compatibilité temporaire
@@ -113,28 +126,34 @@ def _execute_command(self, cmd: list, env: dict = None, line_callback=None, **kw
Retourne le returncode (0 si succÚs, -1 si annulé ou erreur).
"""
if self.stop_requested: return -1
-
+
self.log(f"Exec: {' '.join(map(str, cmd))}")
try:
self.runner.start(cmd, env=env, **kwargs)
self.process = getattr(self.runner, '_process', None) # Legacy mapping
-
+
for line in self.runner.stdout_iter():
if self.stop_requested:
self.runner.terminate()
return -1
-
+
stripped = line.strip()
if stripped:
if line_callback:
line_callback(stripped)
else:
self.log(stripped)
-
+
return self.runner.wait()
except Exception as e:
self.logger.error("Exception in _execute_command", exc_info=True)
self.log(f"Exception: {e}", level=logging.ERROR)
+ # Never leave the child running if streaming/parsing threw â otherwise a
+ # COLMAP/Brush process would be orphaned (zombie holding the GPU/port).
+ try:
+ self.runner.terminate()
+ except Exception:
+ self.logger.debug("terminate() after exception failed", exc_info=True)
return -1
def _kill_process(self, process):
@@ -152,20 +171,22 @@ def _kill_process(self, process):
process.wait()
def validate_path(self, path):
- """Resolves and validates a path to prevent traversal using resolved containment"""
+ """Resolves and validates a user-supplied path.
+
+ This is a desktop tool that operates on the user's own files, so any
+ absolute location (including other drives, e.g. ``I:\\projects``) is
+ allowed â the previous home/project containment broke legitimate output
+ folders. We still reject ``..`` parent-traversal segments, which are the
+ real escape vector for crafted/relative inputs.
+ """
if not path:
return None
try:
- p = Path(path).resolve()
- allowed_bases = [self.project_root.resolve(), Path.home().resolve()]
- for base in allowed_bases:
- try:
- p.relative_to(base)
- return p
- except ValueError:
- pass
- self.log(f"SECURITY WARNING: Path access outside allowed boundaries: {p}")
- return None
+ raw = Path(path)
+ if ".." in raw.parts:
+ self.log(f"SECURITY WARNING: path traversal rejected: {path}")
+ return None
+ return raw.resolve()
except (TypeError, ValueError, OSError) as e:
self.log(f"ERROR: Invalid path attempt : {path} ({e})")
return None
diff --git a/app/core/brush_engine.py b/app/core/brush_engine.py
index 7712bd3..f06bbab 100755
--- a/app/core/brush_engine.py
+++ b/app/core/brush_engine.py
@@ -1,25 +1,37 @@
import os
-import logging
-from pathlib import Path
-from typing import Optional, Dict, Any, Callable, List, Tuple
+from collections.abc import Callable
+from typing import Any
from .base_engine import BaseEngine
from .system import resolve_binary
+
class BrushEngine(BaseEngine):
"""Engine for executing the Brush training pipeline.
Provides path validation, secure command construction, and structured logging.
"""
+ # Custom-args whitelist â every entry verified against the real Brush v0.3.0
+ # CLI (clap). A flag Brush doesn't know makes it ABORT with "unexpected
+ # argument", so phantom flags here are booby traps: --refine-pose,
+ # --test-split, --save-iterations and --log-level do NOT exist in any Brush
+ # release (eval split is --eval-split-every; logging is the RUST_LOG env).
ALLOWED_FLAGS = {
- "--save-iterations", "--log-level", "--test-split",
"--start-iter", "--refine-every", "--growth-grad-threshold",
"--growth-select-fraction", "--growth-stop-iter", "--max-splats",
- "--eval-every", "--export-every", "--max-resolution", "--refine-pose"
+ "--eval-every", "--eval-split-every", "--eval-save-to-disk",
+ "--export-every", "--export-name", "--max-resolution",
+ "--max-frames", "--subsample-frames", "--subsample-points", "--seed",
+ # quality levers (photorealism): perceptual + structural loss weights,
+ # opacity/scale regularisation, and the main learning rates
+ "--ssim-weight", "--lpips-loss-weight", "--opac-loss-weight",
+ "--scale-loss-weight", "--match-alpha-weight", "--mean-noise-weight",
+ "--lr-mean", "--lr-mean-end", "--lr-coeffs-dc", "--lr-coeffs-sh-scale",
+ "--lr-opac", "--lr-scale", "--lr-scale-end", "--lr-rotation",
}
- def __init__(self, logger_callback: Optional[Callable] = None) -> None:
+ def __init__(self, logger_callback: Callable | None = None) -> None:
"""Initialize the Brush engine.
Parameters
@@ -32,7 +44,8 @@ def __init__(self, logger_callback: Optional[Callable] = None) -> None:
self.process = None
def build_command(self, input_path: str, output_path: str,
- params: Optional[Dict[str, Any]] = None) -> Tuple[List[str], Dict[str, str]]:
+ params: dict[str, Any] | None = None,
+ backend_override: str | None = None) -> tuple[list[str], dict[str, str]]:
"""Build the Brush command list and environment from parameters.
Parameters
@@ -63,11 +76,14 @@ def build_command(self, input_path: str, output_path: str,
cmd.append("--with-viewer")
env = os.environ.copy()
device = params.get("device", self.device)
- if device == "mps":
- env["WGPU_BACKEND"] = "metal"
- env["WGPU_POWER_PREF"] = "high_performance"
- elif device == "cuda":
- env["WGPU_BACKEND"] = "vulkan"
+ # Brush runs on wgpu; on Windows/NVIDIA the DX12 and Vulkan backends both
+ # target CUDA-class GPUs. We pin DX12 (most reliable on Windows) and let
+ # wgpu pick the high-performance (discrete) adapter.
+ if device in ("cuda", "auto"):
+ # Vulkan is the default on NVIDIA: the DX12 backend reports a compute
+ # workgroup limit (768) below what Brush's reduce kernels need (1024),
+ # which crashes it. DX12 stays as the automatic fallback (see train).
+ env["WGPU_BACKEND"] = backend_override or params.get("wgpu_backend") or "vulkan"
env["WGPU_POWER_PREF"] = "high_performance"
for param_name, flag in [
@@ -105,7 +121,7 @@ def build_command(self, input_path: str, output_path: str,
cmd.append(str(input_path))
return cmd, env
- def train(self, input_path: str, output_path: str, params: Optional[Dict[str, Any]] = None) -> int:
+ def train(self, input_path: str, output_path: str, params: dict[str, Any] | None = None) -> int:
"""Run the Brush training process.
Parameters
@@ -129,6 +145,85 @@ def train(self, input_path: str, output_path: str, params: Optional[Dict[str, An
raise ValueError("Chemins invalides ou non sécurisés détectés.")
if not self.brush_bin:
raise RuntimeError("Exécutable 'brush' non trouvé.")
- cmd, env = self.build_command(str(safe_input), str(safe_output), params)
- self.log(f"Lancement Brush: {' '.join(cmd)}")
- return self._execute_command(cmd, env=env)
+
+ params = params or {}
+ device = params.get("device", self.device)
+ # Brush (wgpu + burn-fusion) can crash on a backend that reports compute
+ # limits below what its kernels need (DX12 caps workgroup invocations at
+ # 768 < the 1024 Brush wants â wgpu Validation Error â burn-fusion panic).
+ # Switching the wgpu backend dodges it, so we try Vulkan first on NVIDIA
+ # and fall back to DX12 if that attempt crashes.
+ if device in ("cuda", "auto"):
+ primary = params.get("wgpu_backend") or "vulkan"
+ backends = [primary, "dx12" if primary != "dx12" else "vulkan"]
+ else:
+ backends = [None]
+
+ last_rc = -1
+ for idx, backend in enumerate(backends):
+ if getattr(self, "stop_requested", False):
+ break
+ cmd, env = self.build_command(str(safe_input), str(safe_output), params,
+ backend_override=backend)
+ if idx > 0:
+ self.log(f"â ïž Brush a planté (bug burn-fusion). Nouvelle tentative avec "
+ f"le backend GPU « {backend} »âŠ")
+ self.log(f"Lancement Brush: {' '.join(cmd)}")
+ panic = {"hit": False}
+
+ def _capture(line, _p=panic):
+ low = line.lower()
+ # burn-fusion panic, or the underlying wgpu compute-limit error
+ # that triggers it â both mean "retry on the other backend".
+ if ("panicked" in low or "ordering is bigger than operations" in low
+ or "wgpu error" in low or "validation error" in low):
+ _p["hit"] = True
+ self.log(line)
+
+ rc = self._execute_command(cmd, env=env, line_callback=_capture)
+ last_rc = rc
+ if rc == 0 or getattr(self, "stop_requested", False):
+ return rc
+ if not panic["hit"]:
+ return rc # a non-fusion failure â don't blindly retry
+ # fusion panic â loop to the alternate backend
+ return last_rc
+
+
+def rename_latest_ply(output_path: str, ply_name: str | None,
+ log: Callable | None = None) -> bool:
+ """Rename Brush's freshly exported .ply to the user-chosen name.
+
+ Brush always writes its own export filename; the GUI worker renames it
+ afterwards, but the CLI used to ignore --ply_name entirely. This gives the
+ CLI the same behaviour: find the most recently written .ply under
+ output_path and move it to . Best-effort and never fatal.
+ """
+ if not ply_name:
+ return False
+ import shutil
+ from pathlib import Path
+
+ out = Path(output_path)
+ name = Path(ply_name).name # strip any directory component
+ if not name.endswith(".ply"):
+ name += ".ply"
+
+ candidates = [p for p in out.rglob("*.ply")
+ if p.is_file() and p.name != name]
+ if not candidates:
+ if log:
+ log("Attention : aucun fichier PLY trouvé à renommer.")
+ return False
+
+ newest = max(candidates, key=lambda p: p.stat().st_mtime)
+ dest = out / name
+ try:
+ shutil.move(str(newest), str(dest))
+ if log:
+ log(f"Fichier PLY renommé en : {name}")
+ return True
+ except OSError as e:
+ if log:
+ log(f"Erreur renommage PLY : {e}")
+ return False
diff --git a/app/core/engine.py b/app/core/engine.py
index d502b17..cc43212 100755
--- a/app/core/engine.py
+++ b/app/core/engine.py
@@ -1,33 +1,104 @@
-import os
-import shutil
-import send2trash
-import platform
import json
-import subprocess
import logging
+import os
+import platform
+import re
+import shutil
import sqlite3
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
-from typing import Tuple, Any, Optional, Callable
+from typing import Any
+
from .base_engine import BaseEngine
-from .system import is_apple_silicon, get_optimal_threads, resolve_binary
from .i18n import tr
+from .system import get_optimal_threads, has_cuda, resolve_binary
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png'}
+_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv'}
+
+
+def _imread_unicode(path, flags):
+ """cv2.imread that tolerates non-ASCII paths on Windows (cv2 fails on them)."""
+ import cv2
+ import numpy as np
+ try:
+ data = np.fromfile(str(path), dtype=np.uint8)
+ if data.size == 0:
+ return None
+ return cv2.imdecode(data, flags)
+ except (OSError, ValueError):
+ return None
+
+
+def _imwrite_unicode(path, img) -> bool:
+ """cv2.imwrite that tolerates non-ASCII paths on Windows. Returns success.
+
+ Encodes to an in-memory buffer first, then unlinks the destination before
+ writing a fresh file â this both breaks any hardlink (so a hardlinked
+ original is never modified) and avoids leaving the file deleted if encoding
+ fails.
+ """
+ import cv2
+ p = Path(path)
+ suffix = p.suffix or ".png"
+ params = []
+ if suffix.lower() in (".jpg", ".jpeg"):
+ # JPEG can't store an alpha channel and defaults to lossy quality 95.
+ # Drop alpha (IMREAD_UNCHANGED may return 4 channels) and re-encode at
+ # max quality so an in-place resize doesn't silently degrade the frame
+ # or fail outright on a 4-channel image.
+ if img.ndim == 3 and img.shape[2] == 4:
+ img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
+ params = [cv2.IMWRITE_JPEG_QUALITY, 100]
+ ok, buf = cv2.imencode(suffix, img, params)
+ if not ok:
+ return False
+ try:
+ p.unlink(missing_ok=True)
+ buf.tofile(str(p))
+ return True
+ except OSError:
+ return False
def _first_available_model() -> str:
try:
- from app.upscayl_models import get_downloaded_models
from app.upscayl_manager import get_models_dir
+ from app.upscayl_models import get_downloaded_models
models = get_downloaded_models(get_models_dir())
return models[0].id if models else ""
except Exception:
return ""
+
+def select_blurry_files(scores: dict, factor: float, max_remove_frac: float = 0.2):
+ """Selects which files to discard as too blurry.
+
+ A file is blurry if its sharpness score (variance of Laplacian) is below
+ ``factor à median(scores)``. To avoid gutting the dataset, never removes
+ more than ``max_remove_frac`` of the files (only the blurriest are kept as
+ candidates if the cap is exceeded). Default cap is 20% â on shaky video a
+ lower-than-median threshold can otherwise flag a large fraction of frames.
+
+ Returns (rejected_files: list, threshold: float).
+ """
+ import statistics
+ if not scores or factor <= 0:
+ return [], 0.0
+ median = statistics.median(scores.values())
+ threshold = median * factor
+ rejected = [f for f, s in scores.items() if s < threshold]
+ cap = int(len(scores) * max_remove_frac)
+ if len(rejected) > cap:
+ rejected = sorted(rejected, key=lambda f: scores[f])[:cap]
+ return rejected, threshold
+
+
class ColmapEngine(BaseEngine):
"""Moteur d'exécution COLMAP indépendant de l'interface graphique"""
-
- def __init__(self, params: Any, input_path: str, output_path: str, input_type: str, fps: int, project_name: str = "Untitled", logger_callback: Optional[Callable] = None, progress_callback: Optional[Callable] = None, status_callback: Optional[Callable] = None, check_cancel_callback: Optional[Callable] = None):
+
+ def __init__(self, params: Any, input_path: str, output_path: str, input_type: str, fps: int, project_name: str = "Untitled", logger_callback: Callable | None = None, progress_callback: Callable | None = None, status_callback: Callable | None = None, check_cancel_callback: Callable | None = None):
"""Initialise le moteur COLMAP avec les paramÚtres de configuration."""
super().__init__("COLMAP", logger_callback)
self.params = params
@@ -36,28 +107,30 @@ def __init__(self, params: Any, input_path: str, output_path: str, input_type: s
self.input_type = input_type
self.fps = fps
self.project_name = project_name
- self.is_silicon = is_apple_silicon()
+ self.has_cuda = has_cuda()
self.num_threads = get_optimal_threads()
self._current_process = None
self.progress = progress_callback if progress_callback else lambda x: None
self.status = status_callback if status_callback else lambda x: None
self.check_cancel = check_cancel_callback if check_cancel_callback else lambda: False
self.logger = logging.getLogger(__name__)
-
+
# Resolve binaries
self.ffmpeg_bin = resolve_binary('ffmpeg') or 'ffmpeg'
self.colmap_bin = resolve_binary('colmap') or 'colmap'
self.glomap_bin = resolve_binary('glomap') or 'glomap'
-
- # Pre-load cv2 on the main thread to avoid Bus Error (SIGBUS)
+
+ # Pre-load cv2 on the main thread to avoid Bus Error (SIGBUS)
try:
import cv2
self._cv2_loaded = True
except ImportError:
self._cv2_loaded = False
-
- if self.is_silicon:
- self.log(f"Apple Silicon détecté - {self.num_threads} threads optimisés")
+
+ if self.has_cuda:
+ self.log(f"GPU NVIDIA CUDA détecté - {self.num_threads} threads, accélération GPU activée")
+ else:
+ self.log(f"Aucun GPU CUDA détecté - exécution CPU ({self.num_threads} threads)")
self.log(f"Binaires: {self.colmap_bin}, {self.ffmpeg_bin}, {self.glomap_bin}")
@property
@@ -69,34 +142,34 @@ def is_cancelled(self) -> bool:
"""Vérifie si l'utilisateur a demandé l'annulation."""
return self.check_cancel()
- def run(self) -> Tuple[bool, str]:
+ def run(self) -> tuple[bool, str]:
"""Exécute le pipeline complet de reconstruction."""
try:
setup_result = self._validate_and_setup_paths()
if not setup_result: return False, "Erreur de validation des chemins"
project_dir, images_dir, checkpoints_dir = setup_result
-
+
if not self._process_input(project_dir, images_dir):
if self.is_cancelled(): return False, tr("USER_CANCELLED")
return False, "Erreur lors de la preparation de l'entree"
pipeline_result, msg = self._run_reconstruction_pipeline(project_dir, images_dir)
return pipeline_result, msg
-
+
except Exception as e:
self.log(f"Erreur lors de l'exécution du pipeline: {e}")
self.logger.error("Exception in pipeline", exc_info=True)
if self.is_cancelled(): return False, "Arrete par l'utilisateur"
return False, "Une erreur est survenue lors du traitement."
- def _validate_and_setup_paths(self) -> Optional[Tuple[Path, Path, Path]]:
+ def _validate_and_setup_paths(self) -> tuple[Path, Path, Path] | None:
"""Valide les chemins d'entrée/sortie et prépare la structure des dossiers."""
safe_output = self.validate_path(str(self.output_path))
if not safe_output:
self.log("Chemin de sortie non sécurisé")
return None
self.output_path = safe_output
-
+
if ".." in self.project_name or "/" in self.project_name or "\\" in self.project_name:
self.log("Nom de projet invalide")
return None
@@ -104,13 +177,13 @@ def _validate_and_setup_paths(self) -> Optional[Tuple[Path, Path, Path]]:
project_dir = self.output_path / self.project_name
images_dir = project_dir / "images"
checkpoints_dir = project_dir / "checkpoints"
-
+
project_dir.mkdir(parents=True, exist_ok=True)
images_dir.mkdir(parents=True, exist_ok=True)
checkpoints_dir.mkdir(parents=True, exist_ok=True)
-
+
self.log(f"Préparation du projet dans : {project_dir}")
-
+
raw_input = str(self.input_path)
if "|" in raw_input:
self.log("Validation de multiples chemins d'entree...")
@@ -118,7 +191,7 @@ def _validate_and_setup_paths(self) -> Optional[Tuple[Path, Path, Path]]:
if not self.validate_path(p.strip()):
self.log(f"Chemin d'entrée non sécurisé : {p}")
return None
-
+
first_path = Path(raw_input.split("|")[0].strip())
if not first_path.exists():
self.log(f"Entrée introuvable: {first_path}")
@@ -138,7 +211,13 @@ def _process_input(self, project_dir: Path, images_dir: Path) -> bool:
self.status(tr("status_prep_images", "Préparation des visuels..."))
if not self._prepare_images(images_dir):
return False
-
+
+ # Discard blurry frames before reconstruction (best on the raw frames,
+ # i.e. before upscaling). Optional and never fatal.
+ if getattr(self.params, 'filter_blurry', False):
+ self.status(tr("status_blur_filter", "Filtrage des images floues..."))
+ self._filter_blurry_images(images_dir, getattr(self.params, 'blur_factor', 0.7))
+
upscale_conf = getattr(self, 'upscale_config', None)
if upscale_conf and upscale_conf.get("active", False):
self.status(tr("status_upscaling", "Upscaling des images..."))
@@ -147,45 +226,165 @@ def _process_input(self, project_dir: Path, images_dir: Path) -> bool:
if not self._check_and_normalize_resolution(images_dir):
return False
-
+
return True
- def _run_reconstruction_pipeline(self, project_dir: Path, images_dir: Path) -> Tuple[bool, str]:
+ # Params that change the contents of database.db (features + matches). If any
+ # differ from a previous run, the DB must NOT be reused.
+ _DB_SIG_KEYS = (
+ 'camera_model', 'single_camera', 'max_image_size', 'max_num_features',
+ 'estimate_affine_shape', 'domain_size_pooling',
+ 'matcher_type', 'max_ratio', 'max_distance', 'cross_check',
+ 'guided_matching', 'sequential_overlap', 'loop_detection',
+ )
+
+ def _db_sig_path(self, database_path: Path) -> Path:
+ return Path(str(database_path) + ".sig.json")
+
+ def _db_signature(self, images_dir: Path) -> dict:
+ sig = {k: getattr(self.params, k, None) for k in self._DB_SIG_KEYS}
+ sig['image_count'] = len(self._current_image_names(images_dir))
+ return sig
+
+ def _current_image_names(self, images_dir: Path) -> set:
+ """Relative posix names of the images COLMAP would ingest (matches the
+ filter used by _write_sorted_image_list and stored in the DB)."""
+ root = Path(images_dir)
+ if not root.exists():
+ return set()
+ return {
+ f.relative_to(root).as_posix()
+ for f in root.rglob('*')
+ if f.is_file()
+ and f.suffix.lower() in _IMAGE_EXTS
+ and not f.name.lower().endswith('.mask.png')
+ }
+
+ def _write_db_signature(self, database_path: Path, images_dir: Path) -> None:
+ try:
+ self._db_sig_path(database_path).write_text(
+ json.dumps(self._db_signature(images_dir)), encoding='utf-8'
+ )
+ except OSError as e:
+ self.log(f"(info) Impossible d'écrire la signature de reprise : {e}")
+
+ def _database_is_reusable(self, database_path: Path, images_dir: Path) -> bool:
+ """True only if database.db can be safely resumed from: it has features
+ AND geometrically-verified matches for EXACTLY the current image set, and
+ the extraction/matching params are unchanged since it was built.
+
+ Conservative by design â any missing data, mismatch, or error returns
+ False so the pipeline rebuilds from scratch (never reuse a stale DB).
+ """
+ if not database_path.exists():
+ return False
+ # 1. Params + image-count signature must match the stored one.
+ try:
+ stored = json.loads(self._db_sig_path(database_path).read_text(encoding='utf-8'))
+ except (OSError, ValueError):
+ return False
+ if stored != self._db_signature(images_dir):
+ return False
+ current = self._current_image_names(images_dir)
+ if not current:
+ return False
+ # 2. The DB must actually contain features + verified matches for exactly
+ # those images.
+ try:
+ con = sqlite3.connect(str(database_path))
+ try:
+ tables = {r[0] for r in con.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'")}
+ if not {'images', 'descriptors'} <= tables:
+ return False
+ db_names = {r[0] for r in con.execute("SELECT name FROM images")}
+ if db_names != current:
+ return False
+ n_images = con.execute("SELECT COUNT(*) FROM images").fetchone()[0]
+ n_desc = con.execute(
+ "SELECT COUNT(*) FROM descriptors WHERE rows > 0").fetchone()[0]
+ if n_desc < n_images:
+ return False
+ # verified matches (what the mapper consumes); fall back to raw
+ if 'two_view_geometries' in tables:
+ verified = con.execute(
+ "SELECT COUNT(*) FROM two_view_geometries WHERE rows > 0").fetchone()[0]
+ elif 'matches' in tables:
+ verified = con.execute(
+ "SELECT COUNT(*) FROM matches WHERE rows > 0").fetchone()[0]
+ else:
+ return False
+ return verified > 0
+ finally:
+ con.close()
+ except sqlite3.Error:
+ return False
+
+ def _run_reconstruction_pipeline(self, project_dir: Path, images_dir: Path) -> tuple[bool, str]:
"""Exécute les étapes de reconstruction COLMAP."""
database_path = project_dir / "database.db"
sparse_dir = project_dir / "sparse"
+ # The sparse model is the mapper's output â always rebuilt fresh.
if sparse_dir.exists():
shutil.rmtree(sparse_dir)
self.log(f"Reconstruction sparse precedente supprimee : {sparse_dir.name}")
sparse_dir.mkdir(exist_ok=True)
- # Always start from a fresh database to avoid SQLite schema incompatibilities
- # (especially between COLMAP and GLOMAP's bundled SQLite versions).
- for db_file in [database_path,
- database_path.with_suffix(".db-wal"),
- database_path.with_suffix(".db-shm")]:
- if db_file.exists():
- db_file.unlink(missing_ok=True)
- self.log(f"Base de données précédente supprimée : {db_file.name}")
+ # Resume: if a previous run already extracted features AND matched them for
+ # EXACTLY these images with the SAME params, skip extraction + matching and
+ # go straight to the mapper. Guarded conservatively â any mismatch redoes
+ # everything, so a stale DB can never silently corrupt the result.
+ if self._database_is_reusable(database_path, images_dir):
+ self.log("â»ïž Base COLMAP existante valide pour ces images (mêmes paramÚtres) "
+ "â extraction et matching ignorés, reprise directe au mapper.")
+ self.status("Reprise : base COLMAP réutilisée")
+ self.progress(75)
+ else:
+ # Start from a fresh database to avoid SQLite schema incompatibilities
+ # (especially between COLMAP and GLOMAP's bundled SQLite versions) and
+ # stale features.
+ for db_file in [database_path,
+ database_path.with_suffix(".db-wal"),
+ database_path.with_suffix(".db-shm"),
+ self._db_sig_path(database_path)]:
+ if db_file.exists():
+ db_file.unlink(missing_ok=True)
+ self.log(f"Base de données précédente supprimée : {db_file.name}")
+
+ self.progress(25)
+
+ if self.is_cancelled(): return False, tr("USER_CANCELLED")
+ self.status(tr("status_feature_extraction", "Analyse des images en cours..."))
+ if not self.feature_extraction(str(database_path), str(images_dir)):
+ return False, "Ãchec extraction features"
+ # Sort for sequential AND vocab_tree: the vocab-tree pass is followed
+ # by a sequential top-up pass (see feature_matching), whose adjacency
+ # relies on image IDs following temporal/filename order. The sort
+ # wipes matches, so it must happen before ANY matching.
+ if self.params.matcher_type in ('sequential', 'vocab_tree'):
+ self.log("Tri de la base de données COLMAP (ordre temporel des images)...")
+ self.status("Préparation du matching...")
+ self._sort_colmap_database_images(database_path)
+ self.log("Base triée. Démarrage du matching.")
+
+ self.progress(50)
+
+ if self.is_cancelled(): return False, tr("USER_CANCELLED")
+ self.status(tr("status_feature_matching", "Recherche des points communs..."))
+ # The matcher first loads every image's descriptors into RAM before it
+ # prints anything â on a large dataset that can be several minutes with
+ # no output. Warn so the run doesn't look frozen.
+ self.log("â³ Matching en cours â COLMAP charge les descripteurs en mémoire. "
+ "Cette premiÚre phase peut durer plusieurs minutes sans affichage, "
+ "c'est normal, ne fermez pas le programme.")
+ if not self.feature_matching(str(database_path)):
+ return False, "Ãchec matching"
+
+ # Stamp the DB so a later run can safely resume from it.
+ self._write_db_signature(database_path, images_dir)
- self.progress(25)
-
- if self.is_cancelled(): return False, tr("USER_CANCELLED")
- self.status(tr("status_feature_extraction", "Analyse des images en cours..."))
- if not self.feature_extraction(str(database_path), str(images_dir)):
- return False, "Ãchec extraction features"
- if self.params.matcher_type == 'sequential':
- self._sort_colmap_database_images(database_path)
-
- self.progress(50)
-
- if self.is_cancelled(): return False, tr("USER_CANCELLED")
- self.status(tr("status_feature_matching", "Recherche des points communs..."))
- if not self.feature_matching(str(database_path)):
- return False, "Ãchec matching"
-
self.progress(75)
-
+
if self.is_cancelled(): return False, tr("USER_CANCELLED")
# GLOMAP's bundled SQLite does not support WAL journal mode created by
@@ -197,9 +396,15 @@ def _run_reconstruction_pipeline(self, project_dir: Path, images_dir: Path) -> T
self.status(tr("status_reconstruction", "Création de la scÚne 3D..."))
if not self.mapper(str(database_path), str(images_dir), str(sparse_dir)):
return False, "Ãchec reconstruction"
-
+
+ # With multiple_models on, COLMAP writes sub-models (sparse/0, sparse/1, âŠ)
+ # in no particular size order. Brush and the undistorter use sparse/0, so
+ # make sure sparse/0 is the model with the MOST registered images â
+ # otherwise training can land on a tiny fragment (e.g. 11 of 3000 images).
+ self._promote_largest_sparse_model(sparse_dir)
+
self.progress(90)
-
+
if self.params.undistort_images:
if self.is_cancelled(): return False, tr("USER_CANCELLED")
dense_dir = project_dir / "dense"
@@ -207,27 +412,46 @@ def _run_reconstruction_pipeline(self, project_dir: Path, images_dir: Path) -> T
self.status(tr("status_undistorting", "Correction optique des images..."))
if not self.image_undistorter(str(images_dir), str(sparse_dir), str(dense_dir)):
return False, "Echec undistortion"
-
+ self._normalize_dense_sparse_layout(dense_dir)
+
self.progress(95)
-
+
if not self.is_cancelled():
self.status(tr("status_ready", "Traitement terminé !"))
self.create_brush_config(project_dir, images_dir, sparse_dir)
self.progress(100)
return True, f"Dataset cree: {project_dir}"
-
+
return False, "Arrete par l'utilisateur"
+ def _import_manifest_path(self, images_dir: Path) -> Path:
+ return images_dir.parent / "import_manifest.json"
+
+ def _load_import_manifest(self, images_dir: Path) -> dict:
+ """Maps absolute source path â imported target filename (resize-proof)."""
+ try:
+ data = json.loads(self._import_manifest_path(images_dir).read_text(encoding='utf-8'))
+ return data if isinstance(data, dict) else {}
+ except (OSError, ValueError):
+ return {}
+
+ def _save_import_manifest(self, images_dir: Path, manifest: dict) -> None:
+ try:
+ self._import_manifest_path(images_dir).write_text(
+ json.dumps(manifest), encoding='utf-8')
+ except OSError:
+ pass
+
def _prepare_images(self, images_dir: Path) -> bool:
"""GÚre l'extraction vidéo ou la copie d'images."""
if self.input_type == "video":
if self.is_cancelled(): return False
-
+
video_paths = []
if self.input_path.is_dir():
supported_exts = {'.mp4', '.mov', '.avi', '.mkv'}
video_paths = [
- f for f in self.input_path.rglob('*')
+ f for f in self.input_path.rglob('*')
if f.is_file() and f.suffix.lower() in supported_exts
]
video_paths.sort()
@@ -235,23 +459,27 @@ def _prepare_images(self, images_dir: Path) -> bool:
video_paths = [Path(p.strip()) for p in str(self.input_path).split("|") if p.strip()]
total_videos = len(video_paths)
-
+
if total_videos == 0:
self.log(f"Aucune vidéo trouvée dans: {self.input_path}")
return False
-
+
for i, video_path in enumerate(video_paths):
if self.is_cancelled(): return False
-
+
if not video_path.exists():
self.log(f"Attention: Video introuvable: {video_path}")
continue
-
+
base_name = video_path.stem
- prefix = "".join([c for c in base_name if c.isalnum() or c in ('_', '-')])
-
+ sanitized = "".join([c for c in base_name if c.isalnum() or c in ('_', '-')])
+ # Prefix with the enumeration index so two videos whose stems
+ # sanitize to the same string (or to an empty string) get distinct,
+ # non-empty prefixes and never overwrite each other's frames.
+ prefix = f"{i:03d}_{sanitized}" if sanitized else f"{i:03d}"
+
self.log(f"Extraction video ({i+1}/{total_videos}): {base_name}")
-
+
if not self.extract_frames_from_video(str(video_path), images_dir, prefix=prefix):
self.log(f"Echec extraction video: {base_name}")
return False
@@ -261,7 +489,7 @@ def _prepare_images(self, images_dir: Path) -> bool:
try:
raw_input = str(self.input_path)
src_files = []
-
+
if "|" in raw_input:
paths = [Path(p.strip()) for p in raw_input.split("|") if p.strip()]
for p in paths:
@@ -280,37 +508,130 @@ def _prepare_images(self, images_dir: Path) -> bool:
and f.suffix.lower() in _IMAGE_EXTS
and not f.name.lower().endswith('.mask.png')
]
-
+
total_files = len(src_files)
self.log(f"{total_files} images trouvées.")
-
+
if total_files == 0:
- return True
-
+ # Allow re-runs where the working folder is already populated.
+ already_present = images_dir.exists() and any(
+ f.is_file() and f.suffix.lower() in _IMAGE_EXTS
+ for f in images_dir.iterdir()
+ )
+ if already_present:
+ self.log("Aucune nouvelle image à copier â images déjà présentes, on continue.")
+ return True
+ self.log(
+ f"â Aucune image trouvée dans l'entrée : {self.input_path}\n"
+ f" Sélectionnez un dossier contenant des images (.jpg/.jpeg/.png), "
+ f"ou choisissez le mode Vidéo si l'entrée est une vidéo."
+ )
+ return False
+
+ # Map each source to a deterministic target by its absolute path
+ # (a manifest), so a source that was already imported â even after
+ # it was resized in place â is recognised and never re-imported
+ # under a "_1_" alias. Source content is NOT compared
+ # (the in-place resize changes it); provenance is the source path.
+ manifest = self._load_import_manifest(images_dir)
+ src_files = sorted(src_files, key=lambda p: (p.name.lower(), str(p)))
+ new_manifest = {}
+ used = set() # target names claimed this run
+ linked = skipped = 0
+ logged_mode = False
for i, file_path in enumerate(src_files):
if self.is_cancelled(): return False
- target_path = images_dir / file_path.name
- if target_path.exists():
+ key = str(file_path.resolve())
+ # Prefer the name a prior run gave this source; else its
+ # basename. Disambiguate only against names claimed THIS run
+ # (two genuinely different sources sharing a basename).
+ target_name = manifest.get(key) or file_path.name
+ if target_name in used:
counter = 1
while True:
- target_path = images_dir / f"{file_path.parent.name}_{counter}_{file_path.name}"
- if not target_path.exists():
+ cand = f"{file_path.parent.name}_{counter}_{file_path.name}"
+ if cand not in used:
+ target_name = cand
break
counter += 1
-
- shutil.copy2(file_path, target_path)
-
+ target = images_dir / target_name
+ if target.exists():
+ skipped += 1 # already imported (possibly resized) â adopt
+ else:
+ mode = self._link_or_copy(file_path, target)
+ linked += 1
+ if not logged_mode:
+ self.log(
+ "Images liées (hardlink â pas de duplication sur le disque)."
+ if mode == "link" else
+ "Images copiées (entrée sur un autre volume â duplication inévitable)."
+ )
+ logged_mode = True
+ new_manifest[key] = target_name
+ used.add(target_name)
+
if i % 10 == 0 or i == total_files - 1:
p = 5 + int((i / total_files) * 15)
self.progress(p)
- self.status(f"Copie des images : {i+1} / {total_files}")
-
- self.log(f"â
{total_files} images copiées vers {images_dir}")
+ self.status(f"Préparation des images : {i+1} / {total_files}")
+
+ # Self-heal: drop stale top-level images no current source claims
+ # (e.g. "_1_" duplicates from earlier buggy runs, or
+ # frames of a source that was removed). Subfolders (images_src,
+ # blurry, âŠ) and mask files are left untouched.
+ removed = 0
+ claimed = set(new_manifest.values())
+ for f in images_dir.iterdir():
+ if (f.is_file() and f.suffix.lower() in _IMAGE_EXTS
+ and not f.name.lower().endswith('.mask.png')
+ and f.name not in claimed):
+ try:
+ f.unlink()
+ removed += 1
+ except OSError:
+ pass
+
+ self._save_import_manifest(images_dir, new_manifest)
+
+ msg = f"â
{linked} images préparées"
+ if skipped:
+ msg += f", {skipped} déjà présentes"
+ if removed:
+ msg += f", {removed} doublons/obsolÚtes supprimés"
+ self.log(msg + ".")
return True
except Exception as e:
self.log(f"Erreur copie images: {e}")
return False
+ def _link_or_copy(self, src: Path, dst: Path) -> str:
+ """Hardlink srcâdst to avoid duplicating the dataset on disk; fall back to
+ a copy when a hardlink can't be made (e.g. source on another volume).
+
+ Hardlinks share the same data on disk, so the project's images/ folder
+ costs no extra space. In-place writers (resolution resize) break the
+ link first, so the user's originals are never modified.
+ """
+ try:
+ os.link(str(src), str(dst))
+ return "link"
+ except (OSError, NotImplementedError):
+ shutil.copy2(str(src), str(dst))
+ return "copy"
+
+ def _is_single_video_input(self) -> bool:
+ """True when the input is exactly one video file (not a dir, not a
+ '|'-joined list). Frames from one ffmpeg run share one resolution, so
+ callers can take resolution/format fast paths. Conservative: a folder of
+ videos returns False."""
+ raw = str(self.input_path)
+ if "|" in raw:
+ return False
+ try:
+ return self.input_path.is_file() and self.input_path.suffix.lower() in _VIDEO_EXTS
+ except OSError:
+ return False
+
def _convert_db_journal_mode(self, database_path: Path):
"""Switch the COLMAP database from WAL to DELETE journal mode.
@@ -319,9 +640,16 @@ def _convert_db_journal_mode(self, database_path: Path):
rollback-journal mode before GLOMAP reads the file.
"""
try:
- with sqlite3.connect(str(database_path)) as con:
- con.execute("PRAGMA journal_mode=DELETE")
+ con = sqlite3.connect(str(database_path))
+ try:
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
+ con.execute("PRAGMA journal_mode=DELETE")
+ con.commit()
+ finally:
+ # sqlite3's context manager commits but does NOT close â leaving the
+ # connection open keeps the -wal/-shm files locked on Windows, so the
+ # unlink below would fail. Close explicitly before deleting them.
+ con.close()
for wal_file in [database_path.parent / (database_path.name + "-wal"),
database_path.parent / (database_path.name + "-shm")]:
if wal_file.exists():
@@ -330,6 +658,84 @@ def _convert_db_journal_mode(self, database_path: Path):
except Exception as e:
self.log(f"Avertissement : conversion journal mode échouée : {e}")
+ def _filter_blurry_images(self, images_dir: Path, factor: float) -> None:
+ """Move blurry frames out of images_dir (sharpness = variance of Laplacian).
+
+ Rejected frames are moved to a sibling ``images_blurry`` folder rather
+ than deleted, so they can be inspected/restored. Never fatal.
+ """
+ self.log(f"\n{'='*60}\nFiltrage des images floues\n{'='*60}")
+ try:
+ import cv2
+ except ImportError:
+ self.log("â ïž OpenCV non disponible â filtrage du flou ignoré "
+ "(installez opencv-python-headless pour l'activer).")
+ return
+
+ files = sorted(
+ f for f in images_dir.iterdir()
+ if f.is_file() and f.suffix.lower() in _IMAGE_EXTS
+ and not f.name.lower().endswith('.mask.png')
+ )
+ if len(files) < 10:
+ self.log(f"Trop peu d'images ({len(files)}) â filtrage du flou ignoré.")
+ return
+
+ total = len(files)
+ self.log(f"Analyse de la netteté de {total} images sur {self.num_threads} threads...")
+
+ def _score_one(f):
+ # cv2 releases the GIL during imread/resize/Laplacian, so threading
+ # scales well. Downscaling to â€640px preserves the relative ranking.
+ img = _imread_unicode(f, cv2.IMREAD_GRAYSCALE)
+ if img is None:
+ return f, None
+ h, w = img.shape[:2]
+ longest = max(h, w)
+ if longest > 640:
+ s = 640.0 / longest
+ img = cv2.resize(img, (max(1, int(w * s)), max(1, int(h * s))),
+ interpolation=cv2.INTER_AREA)
+ return f, float(cv2.Laplacian(img, cv2.CV_64F).var())
+
+ scores = {}
+ done = 0
+ with ThreadPoolExecutor(max_workers=max(2, self.num_threads)) as ex:
+ futures = [ex.submit(_score_one, f) for f in files]
+ for fut in as_completed(futures):
+ if self.is_cancelled():
+ ex.shutdown(cancel_futures=True)
+ return
+ f, sc = fut.result()
+ if sc is not None:
+ scores[f] = sc
+ done += 1
+ if done % 200 == 0 or done == total:
+ self.status(f"Analyse netteté : {done}/{total}")
+ self.progress(int(done / total * 100))
+ if done % 1000 == 0 or done == total:
+ self.log(f" netteté analysée : {done}/{total}")
+
+ rejected, threshold = select_blurry_files(scores, factor)
+ if not rejected:
+ self.log(f"Aucune image floue détectée (seuil de netteté â {threshold:.0f}).")
+ return
+
+ rejected_dir = images_dir.parent / "images_blurry"
+ rejected_dir.mkdir(parents=True, exist_ok=True)
+ moved = 0
+ for f in rejected:
+ try:
+ shutil.move(str(f), str(rejected_dir / f.name))
+ moved += 1
+ except OSError as e:
+ self.log(f"â ïž Impossible de déplacer {f.name}: {e}")
+ self.log(
+ f"ðª Filtre flou : {moved}/{len(files)} images écartées vers "
+ f"'images_blurry' (seuil â {threshold:.0f}). Pour les réutiliser, "
+ f"remettez-les dans 'images' (ou désactivez le filtre)."
+ )
+
def _run_upscale(self, project_dir: Path, images_dir: Path) -> bool:
"""GÚre l'upscaling via upscayl-bin."""
self.log(f"\n{'='*60}\nUpscaling (upscayl-ncnn)\n{'='*60}")
@@ -340,57 +746,92 @@ def _run_upscale(self, project_dir: Path, images_dir: Path) -> bool:
upscaler = UpscaleEngine(logger_callback=self.log)
if not upscaler.is_installed():
- self.log("WARNING: upscayl-bin not found. Upscale skipped.")
+ self.log("â ïž upscayl-bin introuvable â upscale ignoré (les images originales sont conservées).")
return True
- images_sources_dir = project_dir / "images_src"
-
- if not images_sources_dir.exists():
- self.log(f"Moving originals to {images_sources_dir}...")
- shutil.move(str(images_dir), str(images_sources_dir))
- images_dir.mkdir(parents=True, exist_ok=True)
-
- model_id = self.upscale_config.get("model_id") or _first_available_model()
- scale = self.upscale_config.get("scale", 4)
- out_format = self.upscale_config.get("format", "png")
- tile = self.upscale_config.get("tile", 0)
- tta = self.upscale_config.get("tta", False)
- compression = self.upscale_config.get("compression", 0)
-
- self.log(f"Upscaling x{scale} with model '{model_id}'...")
- success, msg = upscaler.upscale_folder(
- input_dir=str(images_sources_dir),
- output_dir=str(images_dir),
- model_id=model_id,
- scale=scale,
- output_format=out_format,
- tile=tile,
- tta=tta,
- compression=compression,
- cancel_check=self.is_cancelled,
+ # Verify a model is actually available BEFORE touching the images.
+ # Upscale is an optional enhancement; a missing model must never
+ # abort dataset creation.
+ from app.upscayl_manager import get_models_dir
+ from app.upscayl_models import get_downloaded_models
+ downloaded = {m.id for m in get_downloaded_models(get_models_dir())}
+ model_id = self.upscale_config.get("model_id")
+ if model_id not in downloaded:
+ model_id = _first_available_model() # first downloaded model, or ""
+ if not model_id:
+ self.log(
+ "â ïž Aucun modÚle upscayl téléchargé â upscale ignoré. "
+ "Ouvrez l'onglet Upscale pour télécharger un modÚle "
+ "(l'upscale est optionnel et non requis pour le splatting)."
)
- if not success:
- self.log(f"Upscale failed: {msg}")
- return False
- self.log("Upscale complete.")
- else:
+ return True
+
+ images_sources_dir = project_dir / "images_src"
+ if images_sources_dir.exists():
self.log("'images_src' already exists â upscale already done.")
+ return True
+
+ self.log(f"Moving originals to {images_sources_dir}...")
+ shutil.move(str(images_dir), str(images_sources_dir))
+ images_dir.mkdir(parents=True, exist_ok=True)
+
+ scale = self.upscale_config.get("scale", 4)
+ out_format = self.upscale_config.get("format", "png")
+ tile = self.upscale_config.get("tile", 0)
+ tta = self.upscale_config.get("tta", False)
+ compression = self.upscale_config.get("compression", 0)
+ self.log(f"Upscaling x{scale} with model '{model_id}'...")
+ success, msg = upscaler.upscale_folder(
+ input_dir=str(images_sources_dir),
+ output_dir=str(images_dir),
+ model_id=model_id,
+ scale=scale,
+ output_format=out_format,
+ tile=tile,
+ tta=tta,
+ compression=compression,
+ cancel_check=self.is_cancelled,
+ )
+ if not success:
+ self.log(f"â ïž Upscale échoué ({msg}) â restauration des images originales.")
+ self._restore_originals(images_sources_dir, images_dir)
+ return True
+ self.log("Upscale complete.")
return True
-
+
except Exception as e:
- self.log(f"Erreur Upscale: {e}")
- return False
+ self.log(f"â ïž Erreur Upscale ({e}) â tentative de restauration des images originales.")
+ try:
+ src = project_dir / "images_src"
+ if src.exists() and not any(images_dir.iterdir()):
+ self._restore_originals(src, images_dir)
+ except OSError:
+ pass
+ return True # optional step: never abort the pipeline
+
+ def _restore_originals(self, src_dir: Path, images_dir: Path) -> None:
+ """Move original images back from src_dir to images_dir after a failed upscale."""
+ images_dir.mkdir(parents=True, exist_ok=True)
+ for f in src_dir.iterdir():
+ if f.is_file():
+ dest = images_dir / f.name
+ if not dest.exists():
+ shutil.move(str(f), str(dest))
+ shutil.rmtree(str(src_dir), ignore_errors=True)
+ self.log("Images originales restaurées â la reconstruction continue sans upscale.")
def _check_and_normalize_resolution(self, images_dir: Path) -> bool:
- """Vérifie et normalise la résolution des images."""
+ """Vérifie et normalise la résolution des images.
+
+ Reads only image dimensions from the file header (fast, no full decode)
+ in parallel. Frames from a single video are all the same size, so this
+ exits almost instantly.
+ """
self.log(f"\n{'='*60}\nVérification résolution images\n{'='*60}")
- if not getattr(self, '_cv2_loaded', False):
- self.log("â ïž OpenCV non disponible â vérification résolution ignorée.")
+ if not images_dir.exists():
return True
-
- import cv2
files = sorted([
f for f in images_dir.iterdir()
@@ -400,18 +841,46 @@ def _check_and_normalize_resolution(self, images_dir: Path) -> bool:
if len(files) < 2:
return True
- self.log(f"Analyse de {len(files)} images...")
+ from PIL import Image
+
+ # Fast path: all frames from a single ffmpeg extraction share one
+ # resolution by construction. Sampling the first frame turns an O(n)
+ # header scan into O(1). Multi-video / image-folder inputs (which may mix
+ # resolutions) keep the full scan below.
+ if self._is_single_video_input():
+ try:
+ with Image.open(files[0]) as im:
+ w, h = im.size
+ self.log(f"â
Source vidéo unique â résolution uniforme {w}Ã{h} px (échantillon, scan complet ignoré)")
+ return True
+ except Exception:
+ pass # fall through to the full scan on any read error
+
+ total = len(files)
+ self.log(f"Lecture des dimensions de {total} images (en-tête seulement, {self.num_threads} threads)...")
+
+ def _size_one(f):
+ try:
+ with Image.open(f) as im:
+ return f, im.size # (w, h) read from the header, no decode
+ except Exception:
+ return f, None
sizes = {}
- for f in files:
- if self.is_cancelled():
- return False
- img = cv2.imread(str(f), cv2.IMREAD_UNCHANGED)
- if img is None:
- self.log(f"â ïž Lecture impossible: {f.name}")
- continue
- h, w = img.shape[:2]
- sizes[f] = (w, h)
+ done = 0
+ with ThreadPoolExecutor(max_workers=max(2, self.num_threads)) as ex:
+ futures = [ex.submit(_size_one, f) for f in files]
+ for fut in as_completed(futures):
+ if self.is_cancelled():
+ ex.shutdown(cancel_futures=True)
+ return False
+ f, sz = fut.result()
+ if sz is not None:
+ sizes[f] = sz
+ done += 1
+ if done % 1000 == 0 or done == total:
+ self.status(f"Dimensions : {done}/{total}")
+ self.progress(int(done / total * 100))
if not sizes:
return True
@@ -422,49 +891,62 @@ def _check_and_normalize_resolution(self, images_dir: Path) -> bool:
self.log(f"â
Résolution uniforme: {w}Ã{h} px")
return True
- min_w = min(s[0] for s in unique_sizes)
- min_h = min(s[1] for s in unique_sizes)
- to_resize = [f for f, s in sizes.items() if s != (min_w, min_h)]
+ if not getattr(self, '_cv2_loaded', False):
+ self.log("â ïž Résolutions différentes mais OpenCV indisponible â redimensionnement ignoré.")
+ return True
+ import cv2
+
+ # Unify to the LARGEST resolution: the sharpest cameras keep their full
+ # detail. Downscaling everything to the smallest (previous behaviour)
+ # permanently threw away detail from the best images, hurting photorealism.
+ target_w = max(s[0] for s in unique_sizes)
+ target_h = max(s[1] for s in unique_sizes)
+ to_resize = [f for f, s in sizes.items() if s != (target_w, target_h)]
self.log(f"â ïž {len(unique_sizes)} résolutions différentes détectées.")
- self.log(f"Redimensionnement de {len(to_resize)} images â {min_w}Ã{min_h} px")
+ self.log(f"Redimensionnement de {len(to_resize)} images â {target_w}Ã{target_h} px")
- for i, f in enumerate(to_resize):
- if self.is_cancelled():
- return False
- img = cv2.imread(str(f), cv2.IMREAD_UNCHANGED)
+ def _resize_one(f):
+ img = _imread_unicode(f, cv2.IMREAD_UNCHANGED)
if img is None:
- self.log(f"â ïž Re-lecture impossible: {f.name}")
- continue
- resized = cv2.resize(img, (min_w, min_h), interpolation=cv2.INTER_AREA)
- cv2.imwrite(str(f), resized)
- del img, resized
- if (i + 1) % 10 == 0 or (i + 1) == len(to_resize):
- self.log(f"Redimensionnement: {i+1}/{len(to_resize)}")
- self.status(f"Ajustement taille : {i+1} / {len(to_resize)}")
-
- self.log(f"â
{len(to_resize)} images redimensionnées vers {min_w}Ã{min_h} px")
+ return f, None
+ h, w = img.shape[:2]
+ # INTER_AREA is best when shrinking, INTER_CUBIC when enlarging.
+ shrinking = w >= target_w and h >= target_h
+ interp = cv2.INTER_AREA if shrinking else cv2.INTER_CUBIC
+ resized = cv2.resize(img, (target_w, target_h), interpolation=interp)
+ return f, _imwrite_unicode(f, resized)
+
+ # cv2 decode/resize/encode release the GIL â resize in parallel.
+ n = len(to_resize)
+ done = 0
+ with ThreadPoolExecutor(max_workers=max(2, self.num_threads)) as ex:
+ futures = [ex.submit(_resize_one, f) for f in to_resize]
+ for fut in as_completed(futures):
+ if self.is_cancelled():
+ ex.shutdown(cancel_futures=True)
+ return False
+ f, ok = fut.result()
+ if ok is None:
+ self.log(f"â ïž Re-lecture impossible: {f.name}")
+ elif not ok:
+ self.log(f"â ïž Ãcriture impossible: {f.name}")
+ done += 1
+ if done % 10 == 0 or done == n:
+ self.status(f"Ajustement taille : {done} / {n}")
+
+ self.log(f"â
{n} images redimensionnées vers {target_w}Ã{target_h} px")
return True
- def extract_frames_from_video(self, video_path: str, images_dir: Path, prefix: Optional[str] = None) -> Optional[bool]:
+ def extract_frames_from_video(self, video_path: str, images_dir: Path, prefix: str | None = None) -> bool | None:
"""Extrait les frames d'une vidéo via FFmpeg."""
base_name = Path(video_path).stem
self.log(f"\n{'='*60}\nExtraction frames: {Path(video_path).name}\n{'='*60}")
images_dir.mkdir(parents=True, exist_ok=True)
-
- output_pattern = images_dir / (f'{prefix}_%04d.jpg' if prefix else 'frame_%04d.jpg')
-
- cmd = [self.ffmpeg_bin]
- if self.is_silicon:
- cmd.extend(['-hwaccel', 'videotoolbox'])
-
- cmd.extend([
- '-i', video_path,
- '-vf', f'fps={self.fps}',
- '-qscale:v', '2',
- str(output_pattern)
- ])
-
+
+ frame_stem = f'{prefix}_' if prefix else 'frame_'
+ output_pattern = images_dir / f'{frame_stem}%04d.jpg'
+
def _ffmpeg_parser(line_str: str):
if 'frame=' in line_str or 'error' in line_str.lower():
self.log(line_str)
@@ -474,34 +956,189 @@ def _ffmpeg_parser(line_str: str):
self.status(f"Extraction {base_name} : image {f_num}")
except (IndexError, ValueError) as e:
self.logger.debug("Failed to parse frame number: %s", e)
-
- try:
- returncode = self._execute_command(cmd, line_callback=_ffmpeg_parser)
- if self.is_cancelled(): return None
-
- if returncode == 0:
- num_frames = len([f for f in images_dir.iterdir() if f.suffix == '.jpg'])
+
+ def _build_cmd(decoder):
+ c = [self.ffmpeg_bin]
+ if self.has_cuda:
+ c.extend(['-hwaccel', 'cuda'])
+ if decoder: # NVDEC hardware decoder (cuvid)
+ c.extend(['-c:v', decoder])
+ c.extend([
+ '-i', video_path,
+ '-an', # no audio â faster, cleaner
+ '-vf', f'fps={self.fps}',
+ '-qscale:v', '1', # best JPEG quality (1=highest, 2â95%)
+ str(output_pattern),
+ ])
+ return c
+
+ def _clear_frames():
+ # Remove this prefix's frames before each attempt so a re-run (changed
+ # fps/source) or a failed GPU attempt never leaves stale frames behind.
+ for old in images_dir.glob(f'{frame_stem}*.jpg'):
+ try:
+ old.unlink()
+ except OSError:
+ pass
+
+ def _count_frames():
+ return len([
+ f for f in images_dir.iterdir()
+ if f.suffix == '.jpg' and f.name.startswith(frame_stem)
+ ])
+
+ # Try NVDEC GPU decode first when a matching cuvid decoder is available,
+ # then fall back to standard decode. The CPU attempt is always present, so
+ # a missing/incompatible GPU decoder can never break extraction.
+ decoder = self._cuvid_decoder_for(video_path) if self.has_cuda else None
+ attempts = ([decoder] if decoder else []) + [None]
+
+ for idx, dec in enumerate(attempts):
+ if self.is_cancelled():
+ return None
+ if dec:
+ self.log(f"Décodage vidéo : NVDEC GPU ({dec})")
+ elif idx > 0:
+ self.log("â ïž Décodage GPU indisponible/échoué â bascule en décodage CPU.")
+ _clear_frames()
+ try:
+ returncode = self._execute_command(_build_cmd(dec), line_callback=_ffmpeg_parser)
+ except Exception as e:
+ self.log(f"Erreur: {str(e)}")
+ returncode = -1
+ if self.is_cancelled():
+ return None
+ num_frames = _count_frames()
+ if returncode == 0 and num_frames > 0:
self.log(f"{num_frames} frames extraites")
return True
- else:
- self.log(f"Erreur lors de l'extraction")
- return None
- except Exception as e:
- self.log(f"Erreur: {str(e)}")
- return False
+ # otherwise fall through to the next attempt (CPU)
+
+ self.log("â ïž Aucune frame extraite de cette vidéo.")
+ return None
- def run_command(self, cmd: list, description: str, status_prefix: Optional[str] = None) -> bool:
- """Exécute une commande systÚme avec logging et callback de statut."""
+ def _cuvid_decoder_for(self, video_path: str) -> str | None:
+ """Returns the NVDEC (cuvid) decoder name for the input video's codec, or
+ None if it can't be determined or isn't available in this ffmpeg build."""
+ codec = self._probe_video_codec(video_path)
+ if not codec:
+ return None
+ cuvid_map = {
+ 'h264': 'h264_cuvid', 'hevc': 'hevc_cuvid', 'h265': 'hevc_cuvid',
+ 'mpeg1video': 'mpeg1_cuvid', 'mpeg2video': 'mpeg2_cuvid',
+ 'mpeg4': 'mpeg4_cuvid', 'vc1': 'vc1_cuvid', 'vp8': 'vp8_cuvid',
+ 'vp9': 'vp9_cuvid', 'av1': 'av1_cuvid', 'mjpeg': 'mjpeg_cuvid',
+ }
+ decoder = cuvid_map.get(codec.lower())
+ if decoder and decoder in self._available_cuvid_decoders():
+ return decoder
+ return None
+
+ def _probe_video_codec(self, video_path: str) -> str | None:
+ """Reads the first video stream's codec name via ffprobe (best-effort)."""
+ ffprobe = self._ffprobe_bin()
+ if not ffprobe:
+ return None
+ try:
+ import subprocess
+ out = subprocess.run(
+ [ffprobe, '-v', 'error', '-select_streams', 'v:0',
+ '-show_entries', 'stream=codec_name',
+ '-of', 'default=nokey=1:noprint_wrappers=1', str(video_path)],
+ capture_output=True, text=True, timeout=15,
+ )
+ name = out.stdout.strip().splitlines()
+ return name[0].strip() if name else None
+ except (OSError, subprocess.SubprocessError):
+ return None
+
+ def _ffprobe_bin(self) -> str | None:
+ """ffprobe path, derived from ffmpeg's (they ship together)."""
+ if not getattr(self, 'ffmpeg_bin', None):
+ return None
+ p = Path(self.ffmpeg_bin)
+ cand = p.with_name(p.name.replace('ffmpeg', 'ffprobe'))
+ if cand.exists():
+ return str(cand)
+ which = shutil.which('ffprobe')
+ return which
+
+ def _available_cuvid_decoders(self) -> set:
+ """Set of cuvid decoders this ffmpeg build exposes (probed once, cached)."""
+ cached = getattr(self, '_cuvid_decoders', None)
+ if cached is not None:
+ return cached
+ decoders = set()
+ try:
+ import subprocess
+ out = subprocess.run(
+ [self.ffmpeg_bin, '-hide_banner', '-decoders'],
+ capture_output=True, text=True, timeout=15,
+ )
+ for line in (out.stdout + out.stderr).splitlines():
+ for tok in line.split():
+ if tok.endswith('_cuvid'):
+ decoders.add(tok)
+ except (OSError, subprocess.SubprocessError):
+ pass
+ self._cuvid_decoders = decoders
+ return decoders
+
+ def run_command(self, cmd: list, description: str, status_prefix: str | None = None,
+ omp_threads: int = 1) -> bool:
+ """Exécute une commande systÚme avec logging et callback de statut.
+
+ omp_threads: value for OMP_NUM_THREADS. Defaults to 1 (see below) â
+ commands whose ONLY parallelism source is OpenMP (e.g. global_mapper /
+ GLOMAP, which has no --num_threads flag and derives its thread count
+ from OMP_NUM_THREADS) must pass omp_threads=self.num_threads, otherwise
+ they run essentially single-threaded.
+ """
self.log(f"\n{'='*60}\n{description}\n{'='*60}")
-
+
env = os.environ.copy()
- if self.is_silicon:
- env['OMP_NUM_THREADS'] = str(self.num_threads)
- env['VECLIB_MAXIMUM_THREADS'] = str(self.num_threads)
- env['OPENBLAS_NUM_THREADS'] = str(self.num_threads)
-
+ # Pin the inner BLAS/OpenMP pools to 1 thread. COLMAP already parallelizes
+ # at the task level via --*.num_threads (Ceres bundle-adjustment threads);
+ # letting BLAS *also* spawn N threads gives NÃN oversubscription, which
+ # thrashes the CPU and worsens the "Linear solver failure" retries during
+ # global BA on large scenes. One source of parallelism, not nested.
+ env['OMP_NUM_THREADS'] = str(max(1, omp_threads))
+ env['OPENBLAS_NUM_THREADS'] = '1'
+ env['MKL_NUM_THREADS'] = '1'
+
+ # Windows: the bundled colmap.exe loads DLLs from its own folder and a
+ # sibling lib/ directory. Make both discoverable on PATH so we can call
+ # colmap.exe directly (instead of COLMAP.bat, which needs a shell).
+ if os.name == 'nt' and self.colmap_bin:
+ bin_dir = Path(self.colmap_bin).parent
+ dll_dirs = [bin_dir, bin_dir.parent / 'lib', bin_dir / 'lib', bin_dir.parent / 'bin']
+ existing = [str(d) for d in dll_dirs if d.exists()]
+ if existing:
+ env['PATH'] = os.pathsep.join(existing) + os.pathsep + env.get('PATH', '')
+
def _colmap_parser(line_str: str):
self.log(line_str)
+ # "Linear solver failure" is a non-fatal bundle-adjustment warning:
+ # one optimisation step failed, the solver retries and the mapper
+ # keeps registering images. Annotate it once so it doesn't look like
+ # a crash to the user.
+ if "Linear solver failure" in line_str and not getattr(self, "_ba_warn_noted", False):
+ self._ba_warn_noted = True
+ self.log("â¹ïž (info) « Linear solver failure » est un avertissement NON bloquant : "
+ "une étape d'optimisation a échoué, COLMAP réessaie automatiquement et "
+ "continue. Tant que les images continuent de s'enregistrer, tout va bien.")
+ # Some COLMAP builds advertise --Mapper.ba_use_gpu but ship a Ceres
+ # compiled without CUDA/cuDSS, so BA silently runs on the CPU. Surface
+ # this once â it's the real reason the mapper is slow.
+ if (("compiled without CUDA support" in line_str
+ or "compiled without cuDSS support" in line_str)
+ and not getattr(self, "_ceres_cpu_noted", False)):
+ self._ceres_cpu_noted = True
+ self.log("â ïž Important : ce build de COLMAP a Ceres SANS CUDA/cuDSS â le bundle "
+ "adjustment tourne en réalité sur le CPU (le flag GPU est sans effet ici). "
+ "C'est la principale cause de lenteur du mapper. Pour accélérer : utilisez "
+ "GLOMAP (qui évite le BA global répété) ou un COLMAP compilé avec "
+ "Ceres+CUDA+cuDSS.")
if status_prefix:
if "Processed file" in line_str:
parts = line_str.split("Processed file")
@@ -512,35 +1149,61 @@ def _colmap_parser(line_str: str):
if len(parts) > 1:
self.status(f"{status_prefix} : bloc {parts[1].strip()}")
elif "Registering image" in line_str:
- parts = line_str.split("Registering image")
- if len(parts) > 1:
- img_info = parts[1].split('(')[0].strip()
- self.status(f"{status_prefix} : ajout image {img_info}")
+ # Show num_reg_frames (images successfully placed so far) â
+ # the real progress counter for the mapper â instead of the
+ # raw image id, so the user can see where they are.
+ m = re.search(r'num_reg_frames=(\d+)', line_str)
+ if m:
+ self.status(f"{status_prefix} : {m.group(1)} images placées")
+ else:
+ parts = line_str.split("Registering image")
+ if len(parts) > 1:
+ img_info = parts[1].split('(')[0].strip()
+ self.status(f"{status_prefix} : ajout image {img_info}")
elif "Bundle adjustment report" in line_str:
self.status(f"{status_prefix} : optimisation globale...")
elif "Undistorting image" in line_str:
parts = line_str.split("Undistorting image")
if len(parts) > 1:
self.status(f"{status_prefix} : image {parts[1].strip()}")
-
+
try:
returncode = self._execute_command(cmd, env=env, line_callback=_colmap_parser)
if self.is_cancelled(): return False
-
+
if returncode == 0:
self.log(f"{description} termine")
return True
else:
self.log(f"{description} echoue")
return False
-
+
except FileNotFoundError:
- self.log(f"COLMAP non trouve. Installez avec: brew install colmap")
+ self.log("COLMAP introuvable. Installez une build CUDA depuis "
+ "https://github.com/colmap/colmap/releases (ex. colmap-x64-windows-cuda.zip) "
+ "et ajoutez-le au PATH.")
return False
def feature_extraction(self, database_path: str, images_dir: str) -> bool:
"""Exécute l'extraction des features SIFT."""
image_list_path = self._write_sorted_image_list(images_dir)
+
+ # COLMAP's GPU SIFT is disabled when affine-shape or domain-size-pooling
+ # is on (it falls back to slow CPU extraction). Tell the user which path
+ # will run so a misconfiguration that loses the GPU is obvious.
+ cpu_only_opts = self.params.estimate_affine_shape or self.params.domain_size_pooling
+ if self.has_cuda and not cpu_only_opts:
+ self.log("SIFT : GPU (CUDA) â
")
+ elif self.has_cuda and cpu_only_opts:
+ self.log("â ïž SIFT sur CPU : 'Affine Shape' ou 'Domain Pooling' est activé "
+ "â désactivez-les pour utiliser le GPU.")
+ else:
+ self.log("SIFT : CPU (pas de GPU CUDA détecté)")
+
+ if self.params.camera_model == 'EQUIRECTANGULAR':
+ self.log("Mode 360 natif : modÚle de caméra EQUIRECTANGULAR "
+ "(requiert COLMAP ⥠4.1.0).")
+
cmd = [
self.colmap_bin, 'feature_extractor',
'--database_path', database_path,
@@ -553,11 +1216,14 @@ def feature_extraction(self, database_path: str, images_dir: str) -> bool:
'--SiftExtraction.estimate_affine_shape', '1' if self.params.estimate_affine_shape else '0',
'--SiftExtraction.domain_size_pooling', '1' if self.params.domain_size_pooling else '0',
]
+ # NOTE: a CUDA-enabled COLMAP uses the GPU for SIFT by default. We don't
+ # pass --SiftExtraction.use_gpu because some COLMAP builds (e.g. 4.x)
+ # reject that option name; the GPU is still used automatically.
if image_list_path:
cmd.extend(['--image_list_path', str(image_list_path)])
return self.run_command(cmd, "Extraction des features", status_prefix="Analyse")
- def _write_sorted_image_list(self, images_dir: str) -> Optional[Path]:
+ def _write_sorted_image_list(self, images_dir: str) -> Path | None:
"""Write a deterministic COLMAP image list so sequential matching follows frame order."""
image_root = Path(images_dir)
files = sorted(
@@ -589,11 +1255,17 @@ def _sort_colmap_database_images(self, database_path: Path) -> None:
("pose_priors", "corr_data_id"),
}
+ def _natural_key(name: str):
+ # frame_999.jpg must sort before frame_1000.jpg: split digit runs and
+ # compare them numerically. Plain SQL ORDER BY (lexicographic) breaks
+ # temporal adjacency past 9999 frames or with unpadded numbers.
+ return [int(tok) if tok.isdigit() else tok
+ for tok in re.split(r'(\d+)', name)]
+
try:
with sqlite3.connect(str(database_path)) as con:
- rows = con.execute(
- "SELECT image_id, name FROM images ORDER BY name"
- ).fetchall()
+ rows = con.execute("SELECT image_id, name FROM images").fetchall()
+ rows.sort(key=lambda r: _natural_key(r[1]))
id_map = {old_id: new_id for new_id, (old_id, _) in enumerate(rows, start=1)}
if all(old_id == new_id for old_id, new_id in id_map.items()):
self.log("Ordre des images COLMAP deja trie.")
@@ -664,61 +1336,404 @@ def _sort_colmap_database_images(self, database_path: Path) -> None:
except Exception as e:
self.log(f"Avertissement: tri de la base COLMAP echoue: {e}")
+ def _colmap_cmd_help(self, cmd_name: str) -> str:
+ """`colmap -h` output, cached per command (empty on failure).
+ Used to gate optional flags so we never pass an option a build rejects."""
+ cache = getattr(self, "_cmd_help_cache", None)
+ if cache is None:
+ cache = {}
+ self._cmd_help_cache = cache
+ if cmd_name not in cache:
+ text = ""
+ try:
+ import subprocess
+ out = subprocess.run([self.colmap_bin, cmd_name, '-h'],
+ capture_output=True, text=True, timeout=15)
+ text = out.stdout + out.stderr
+ except (OSError, subprocess.SubprocessError):
+ text = ""
+ cache[cmd_name] = text
+ return cache[cmd_name]
+
+ def _ensure_vocab_tree(self) -> Path | None:
+ """Path to a COLMAP vocabulary tree (for loop-closure / vocab-tree
+ matching), downloading it once into engines/ if needed. Returns None on
+ failure so matching can proceed without it.
+
+ COLMAP switched its vocabulary-tree index from FLANN to FAISS in May
+ 2025. The classic demuc.de / 3.11.1 `vocab_tree_flickr100K_words32K.bin`
+ files are FLANN-format and make COLMAP 4.1.0 abort with
+ "Failed to read faiss index". This fork pins COLMAP >= 4.1.0, so we
+ fetch a FAISS-converted tree instead. If the download fails we return
+ None and the caller silently proceeds without loop detection."""
+ dest = self.project_root / "engines" / "vocab_tree_flickr100K_words32K_faiss.bin"
+ if dest.exists() and dest.stat().st_size > 1_000_000:
+ return dest
+ # FAISS-format flickr100K/32K tree (converted from the official FLANN
+ # release); the legacy demuc.de .bin is FLANN and crashes COLMAP 4.1.0.
+ url = ("https://raw.githubusercontent.com/ZachMckennedyFWig/"
+ "ColmapFaissVocabTrees/main/vocab_tree_flickr100K_words32K.bin")
+ try:
+ import shutil
+ import urllib.request
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ self.log("Téléchargement du vocabulaire COLMAP FAISS (détection de "
+ "boucles, ~10 Mo, une seule fois)...")
+ req = urllib.request.Request(url, headers={"User-Agent": "CorbeauSplat"})
+ tmp = dest.with_suffix(".part")
+ with urllib.request.urlopen(req, timeout=180) as resp, open(tmp, "wb") as f:
+ shutil.copyfileobj(resp, f)
+ if tmp.stat().st_size < 1_000_000:
+ tmp.unlink(missing_ok=True)
+ raise OSError("fichier de vocabulaire trop petit (téléchargement incomplet)")
+ tmp.replace(dest)
+ return dest
+ except OSError as e: # URLError/HTTPError/timeouts all derive from OSError
+ self.log(f"(info) Vocabulaire COLMAP indisponible ({e}) â matching sans "
+ "détection de boucles.")
+ return None
+
+ def _matching_common_opts(self) -> list:
+ """SIFT-matching flags shared by every matcher type."""
+ return [
+ '--FeatureMatching.num_threads', str(self.num_threads),
+ '--SiftMatching.max_ratio', str(self.params.max_ratio),
+ '--SiftMatching.max_distance', str(self.params.max_distance),
+ '--SiftMatching.cross_check', '1' if self.params.cross_check else '0',
+ '--FeatureMatching.guided_matching', '1' if self.params.guided_matching else '0',
+ ]
+
def feature_matching(self, database_path: str) -> bool:
- """Exécute le matching des features."""
+ """Exécute le matching des features.
+
+ La détection de boucles et le matcher vocab-tree dépendent d'un arbre de
+ vocabulaire FAISS qui peut, selon le build COLMAP, échouer au chargement.
+ Le matching ne doit JAMAIS s'interrompre pour autant : en cas d'échec on
+ rejoue automatiquement une variante plus simple (sans détection de
+ boucles, puis exhaustive) afin de toujours produire des correspondances.
+ """
+ common = self._matching_common_opts()
+
if self.params.matcher_type == 'sequential':
- cmd = [
+ base = [
self.colmap_bin, 'sequential_matcher',
'--database_path', database_path,
- '--FeatureMatching.num_threads', str(self.num_threads),
- '--SiftMatching.max_ratio', str(self.params.max_ratio),
- '--SiftMatching.max_distance', str(self.params.max_distance),
- '--SiftMatching.cross_check', '1' if self.params.cross_check else '0',
- '--FeatureMatching.guided_matching', '1' if self.params.guided_matching else '0',
+ *common,
'--SequentialMatching.overlap', str(self.params.sequential_overlap),
'--SequentialMatching.quadratic_overlap', '1',
]
- description = "Matching Sequentiel"
- else:
- cmd = [
- self.colmap_bin, 'exhaustive_matcher',
- '--database_path', database_path,
- '--FeatureMatching.num_threads', str(self.num_threads),
- '--SiftMatching.max_ratio', str(self.params.max_ratio),
- '--SiftMatching.max_distance', str(self.params.max_distance),
- '--SiftMatching.cross_check', '1' if self.params.cross_check else '0',
- '--FeatureMatching.guided_matching', '1' if self.params.guided_matching else '0',
- ]
- description = "Matching Exhaustif"
-
- return self.run_command(cmd, description, status_prefix="Comparaison")
+ # Loop closure: detect revisited places so the same location isn't
+ # reconstructed twice. Needs a vocab tree; gated on the option existing.
+ loop_args = []
+ if getattr(self.params, 'loop_detection', True) \
+ and 'loop_detection' in self._colmap_cmd_help('sequential_matcher'):
+ vocab = self._ensure_vocab_tree()
+ if vocab:
+ loop_args = ['--SequentialMatching.loop_detection', '1',
+ '--SequentialMatching.vocab_tree_path', str(vocab)]
+ if loop_args:
+ self.log("Détection de boucles activée (anti-duplication des lieux revisités).")
+ if self.run_command(base + loop_args, "Matching Sequentiel",
+ status_prefix="Comparaison"):
+ return True
+ # Vocab tree incompatible / unreadable â never abort matching.
+ self.log("(repli) La détection de boucles a échoué (arbre de "
+ "vocabulaire incompatible ?) â nouvelle tentative SANS "
+ "détection de boucles.")
+ return self.run_command(base, "Matching Sequentiel", status_prefix="Comparaison")
+
+ if self.params.matcher_type == 'vocab_tree':
+ # Real vocab-tree matcher (good for unordered/large collections).
+ vocab = self._ensure_vocab_tree()
+ if vocab:
+ cmd = [
+ self.colmap_bin, 'vocab_tree_matcher',
+ '--database_path', database_path,
+ *common,
+ '--VocabTreeMatching.vocab_tree_path', str(vocab),
+ ]
+ # Retrieval breadth: the COLMAP default only matches each image
+ # against its 100 most-similar retrievals â on 2000+ image scenes
+ # weakly-covered views fall below that cutoff and never connect.
+ vt_help = self._colmap_cmd_help('vocab_tree_matcher')
+ if 'VocabTreeMatching.num_images' in vt_help:
+ cmd += ['--VocabTreeMatching.num_images', '150']
+ if 'VocabTreeMatching.num_nearest_neighbors' in vt_help:
+ cmd += ['--VocabTreeMatching.num_nearest_neighbors', '8']
+ if self.run_command(cmd, "Matching Vocab-Tree", status_prefix="Comparaison"):
+ # Top-up pass: video frames are temporally ordered, so a cheap
+ # sequential pass connects the neighbours the vocab-tree
+ # retrieval missed (matches ACCUMULATE in the same database).
+ # Best-effort â its failure never fails the matching stage.
+ seq_cmd = [
+ self.colmap_bin, 'sequential_matcher',
+ '--database_path', database_path,
+ *common,
+ '--SequentialMatching.overlap', str(self.params.sequential_overlap),
+ '--SequentialMatching.quadratic_overlap', '1',
+ ]
+ if not self.run_command(seq_cmd, "Matching Séquentiel (complément)",
+ status_prefix="Comparaison"):
+ self.log("(info) Passe séquentielle complémentaire échouée â "
+ "les matchs vocab-tree suffisent, on continue.")
+ return True
+ self.log("(repli) Matching vocab-tree échoué (arbre incompatible ?) "
+ "â bascule sur le matching exhaustif.")
+ else:
+ self.log("(repli) Arbre de vocabulaire indisponible â bascule sur "
+ "le matching exhaustif.")
+
+ cmd = [
+ self.colmap_bin, 'exhaustive_matcher',
+ '--database_path', database_path,
+ *common,
+ ]
+ return self.run_command(cmd, "Matching Exhaustif", status_prefix="Comparaison")
+
+ def _colmap_has_command(self, name: str) -> bool:
+ """True if the installed colmap exposes the subcommand `name` (probed via
+ `colmap help`, cached). Lets us use COLMAP 4.0+'s built-in global_mapper
+ without assuming it exists on older builds."""
+ cached = getattr(self, "_colmap_help_cmds", None)
+ if cached is None:
+ cached = ""
+ try:
+ import subprocess
+ out = subprocess.run([self.colmap_bin, 'help'],
+ capture_output=True, text=True, timeout=15)
+ cached = out.stdout + out.stderr
+ except (OSError, subprocess.SubprocessError):
+ cached = ""
+ self._colmap_help_cmds = cached
+ return name in cached
+
+ def _normalize_dense_sparse_layout(self, dense_dir: Path) -> None:
+ """Mirror the standard COLMAP layout inside dense/.
+
+ `colmap image_undistorter` writes the model files directly into
+ dense/sparse/ (no /0 sub-model dir), but downstream tools â Brush's
+ COLMAP loader included â expect sparse/0. Move the model files into
+ dense/sparse/0 so dense/ can be used as a drop-in training root.
+ Best-effort: on failure the original layout is kept."""
+ sparse = dense_dir / "sparse"
+ zero = sparse / "0"
+ try:
+ if not sparse.is_dir() or zero.exists():
+ return
+ model_files = [f for f in sparse.iterdir() if f.is_file()]
+ if not model_files:
+ return
+ zero.mkdir()
+ for f in model_files:
+ f.rename(zero / f.name)
+ self.log("ModÚle non-distordu réorganisé en dense/sparse/0 (layout standard).")
+ except OSError as e:
+ self.log(f"(info) Réorganisation dense/sparse impossible : {e}")
+
+ def _binary_cmd_help(self, binary: str, cmd_name: str) -> str:
+ """` -h` output, cached (empty on failure). Same idea
+ as _colmap_cmd_help but for a non-colmap binary (e.g. standalone glomap)."""
+ cache = getattr(self, "_bin_help_cache", None)
+ if cache is None:
+ cache = {}
+ self._bin_help_cache = cache
+ key = (str(binary), cmd_name)
+ if key not in cache:
+ text = ""
+ try:
+ import subprocess
+ out = subprocess.run([binary, cmd_name, '-h'],
+ capture_output=True, text=True, timeout=15)
+ text = out.stdout + out.stderr
+ except (OSError, subprocess.SubprocessError):
+ text = ""
+ cache[key] = text
+ return cache[key]
+
+ def _global_mapper_opts(self, help_txt: str, ran_calibration: bool = False) -> list:
+ """Probe-gated quality options for global_mapper/GLOMAP.
+
+ Unlike the incremental path, GLOMAP previously received ZERO options, so
+ the user's GUI settings (GPU BA, intrinsics refinement) were silently
+ dropped. Every flag is gated on the build's -h output so an unsupported
+ option is skipped instead of aborting. NB: we deliberately do NOT map
+ ba_global_max_num_iterations here â it is tuned low (30) for the
+ *repeated* incremental BA; the single global BA deserves GLOMAP's own
+ default iteration budget.
+ """
+ opts: list = []
+
+ def add(flag: str, value) -> None:
+ if flag.lstrip('-') in help_txt:
+ opts.extend([flag, str(value)])
+
+ if self.params.ba_use_gpu:
+ # Ceres falls back to CPU with a warning if built without CUDA â
+ # harmless, and honours the user's setting on capable builds.
+ add('--BundleAdjustment.use_gpu', 1)
+ add('--GlobalPositioning.use_gpu', 1)
+ try:
+ gpu_index = int(getattr(self.params, 'ba_gpu_index', -1))
+ except (TypeError, ValueError):
+ gpu_index = -1
+ if gpu_index >= 0:
+ add('--BundleAdjustment.gpu_index', gpu_index)
+ add('--GlobalPositioning.gpu_index', gpu_index)
+ add('--BundleAdjustment.optimize_intrinsics',
+ 1 if self.params.ba_refine_focal_length else 0)
+ add('--BundleAdjustment.optimize_principal_point',
+ 1 if self.params.ba_refine_principal_point else 0)
+ if ran_calibration:
+ # We already ran view_graph_calibrator as a separate step â don't
+ # pay for it twice inside global_mapper.
+ add('--skip_view_graph_calibration', 1)
+ return opts
+
+ def _global_mapper(self, database_path: str, images_dir: str, sparse_dir: Path) -> bool:
+ """Global SfM (a single global bundle adjustment instead of the repeated
+ per-image one) â much faster on large scenes. Prefers COLMAP 4.0+'s
+ built-in `global_mapper` (no extra install); falls back to a standalone
+ glomap binary, then to the incremental mapper."""
+ if self._colmap_has_command('global_mapper'):
+ # Calibrating focal lengths from the view graph first markedly
+ # improves global-mapper quality (it relies on decent intrinsics).
+ ran_vgc = self._colmap_has_command('view_graph_calibrator')
+ if ran_vgc:
+ self.status("Calibrage du view-graph...")
+ self.run_command(
+ [self.colmap_bin, 'view_graph_calibrator', '--database_path', database_path],
+ "Calibrage des intrinsÚques (view-graph)", status_prefix="Calibrage",
+ omp_threads=self.num_threads)
+ self.log("Reconstruction GLOBALE (colmap global_mapper) : un seul bundle "
+ "adjustment global au lieu du BA incrémental répété â bien plus rapide.")
+ cmd = [self.colmap_bin, 'global_mapper',
+ '--database_path', database_path,
+ '--image_path', images_dir,
+ '--output_path', str(sparse_dir)]
+ cmd += self._global_mapper_opts(self._colmap_cmd_help('global_mapper'),
+ ran_calibration=ran_vgc)
+ # global_mapper has NO --num_threads flag: its thread count comes from
+ # OMP_NUM_THREADS, so it must NOT get the default 1-thread pinning.
+ return self.run_command(cmd, "Reconstruction 3D (globale)",
+ status_prefix="Reconstruction globale",
+ omp_threads=self.num_threads)
+
+ if resolve_binary('glomap'):
+ self.log("Utilisation de GLOMAP (binaire séparé) pour la reconstruction globale...")
+ cmd = [self.glomap_bin, 'mapper',
+ '--database_path', database_path,
+ '--image_path', images_dir,
+ '--output_path', str(sparse_dir)]
+ cmd += self._global_mapper_opts(self._binary_cmd_help(self.glomap_bin, 'mapper'))
+ return self.run_command(cmd, "Reconstruction 3D (GLOMAP)",
+ status_prefix="Reconstruction GLOMAP",
+ omp_threads=self.num_threads)
+
+ self.log("â ïž Reconstruction globale demandée mais indisponible (ce COLMAP n'a pas "
+ "'global_mapper' et glomap n'est pas installé) â bascule sur le mapper "
+ "incrémental classique.")
+ return self._incremental_mapper(database_path, images_dir, sparse_dir)
def mapper(self, database_path: str, images_dir: str, sparse_dir: Path) -> bool:
"""Exécute la reconstruction 3D (Mapper)."""
if self.params.use_glomap:
- self.log("Utilisation de GLOMAP pour la reconstruction...")
- cmd = [
- self.glomap_bin, 'mapper',
- '--database_path', database_path,
- '--image_path', images_dir,
- '--output_path', str(sparse_dir)
- ]
- return self.run_command(cmd, "Reconstruction 3D (GLOMAP)", status_prefix="Reconstruction GLOMAP")
- else:
- cmd = [
- self.colmap_bin, 'mapper',
- '--database_path', database_path,
- '--image_path', images_dir,
- '--output_path', str(sparse_dir),
- '--Mapper.num_threads', str(self.num_threads),
- '--Mapper.min_model_size', str(self.params.min_model_size),
- '--Mapper.multiple_models', '1' if self.params.multiple_models else '0',
- '--Mapper.ba_refine_focal_length', '1' if self.params.ba_refine_focal_length else '0',
- '--Mapper.ba_refine_principal_point', '1' if self.params.ba_refine_principal_point else '0',
- '--Mapper.ba_refine_extra_params', '1' if self.params.ba_refine_extra_params else '0',
- '--Mapper.min_num_matches', str(self.params.min_num_matches),
- ]
- return self.run_command(cmd, "Reconstruction 3D (COLMAP)", status_prefix="Reconstruction 3D")
+ return self._global_mapper(database_path, images_dir, sparse_dir)
+ return self._incremental_mapper(database_path, images_dir, sparse_dir)
+
+ def _incremental_mapper(self, database_path: str, images_dir: str, sparse_dir: Path) -> bool:
+ """COLMAP incremental SfM."""
+ cmd = [
+ self.colmap_bin, 'mapper',
+ '--database_path', database_path,
+ '--image_path', images_dir,
+ '--output_path', str(sparse_dir),
+ '--Mapper.num_threads', str(self.num_threads),
+ '--Mapper.min_model_size', str(self.params.min_model_size),
+ '--Mapper.multiple_models', '1' if self.params.multiple_models else '0',
+ '--Mapper.ba_refine_focal_length', '1' if self.params.ba_refine_focal_length else '0',
+ '--Mapper.ba_refine_principal_point', '1' if self.params.ba_refine_principal_point else '0',
+ '--Mapper.ba_refine_extra_params', '1' if self.params.ba_refine_extra_params else '0',
+ '--Mapper.min_num_matches', str(self.params.min_num_matches),
+ ]
+ # Bound bundle-adjustment cost (the dominant mapper time on large
+ # scenes) â faster than COLMAP defaults, safe for a 3DGS target. Each
+ # flag is gated on the installed COLMAP actually advertising it: COLMAP
+ # 4.1.0 renamed/removed some of these (e.g. ba_global_*_ratio), and an
+ # unrecognized option makes the whole mapper abort.
+ ba_bounds = [
+ ('ba_global_max_num_iterations', self.params.ba_global_max_num_iterations),
+ ('ba_global_function_tolerance', self.params.ba_global_function_tolerance),
+ # Refinement caps â the biggest in-mapper lever on the global-BA step.
+ ('ba_global_max_refinements', self.params.ba_global_max_refinements),
+ ('ba_local_max_refinements', self.params.ba_local_max_refinements),
+ # How often global BA runs. COLMAP 4.1.0 RENAMED images_ratio â
+ # frames_ratio (rig/frame terminology); pass both â only the name
+ # the build advertises is used, so frequency control works on both
+ # 4.1.0 (frames) and 3.x (images). Without this it was silently
+ # dropped on 4.1.0, leaving global BA at the slow default cadence.
+ ('ba_global_frames_ratio', self.params.ba_global_images_ratio),
+ ('ba_global_images_ratio', self.params.ba_global_images_ratio),
+ ('ba_global_points_ratio', self.params.ba_global_points_ratio),
+ ('ba_local_max_num_iterations', self.params.ba_local_max_num_iterations),
+ ]
+ skipped = []
+ for name, val in ba_bounds:
+ if self._mapper_supports(name):
+ cmd += [f'--Mapper.{name}', str(val)]
+ else:
+ skipped.append(name)
+ if skipped:
+ self.log(f"(info) Options BA non disponibles sur ce COLMAP, ignorées : {', '.join(skipped)}")
+ # GPU bundle adjustment (COLMAP 4.1.0 "Caspar"). Only add the flag if
+ # the installed COLMAP actually supports it â otherwise an older
+ # build would abort with "unrecognized option". This is exactly the
+ # step that throws "Linear solver failure" on the CPU for big scenes.
+ if self.params.ba_use_gpu:
+ if self._mapper_supports_gpu_ba():
+ cmd += ['--Mapper.ba_use_gpu', '1']
+ if self.params.ba_gpu_index is not None and self.params.ba_gpu_index >= 0:
+ cmd += ['--Mapper.ba_gpu_index', str(self.params.ba_gpu_index)]
+ self.log("Bundle adjustment GPU demandé (--ba_use_gpu) â effectif seulement "
+ "si ce build de COLMAP a Ceres compilé avec CUDA/cuDSS (vérifié au runtime).")
+ else:
+ self.log("â ïž GPU bundle adjustment demandé mais ce COLMAP ne le "
+ "supporte pas (requiert COLMAP ⥠4.1.0) â fallback CPU. "
+ "Supprimez engines\\colmap et relancez run.bat pour mettre à jour.")
+ return self.run_command(cmd, "Reconstruction 3D (COLMAP)", status_prefix="Reconstruction 3D")
+
+ def _mapper_help_text(self) -> str:
+ """`colmap mapper -h` output, fetched once and cached (empty on failure).
+
+ Used to gate every optional --Mapper.* flag so we never pass an option a
+ given COLMAP build doesn't recognize (which aborts the whole mapper).
+ """
+ cached = getattr(self, "_mapper_help", None)
+ if cached is not None:
+ return cached
+ text = ""
+ try:
+ import subprocess
+ out = subprocess.run(
+ [self.colmap_bin, 'mapper', '-h'],
+ capture_output=True, text=True, timeout=15,
+ )
+ text = out.stdout + out.stderr
+ except (OSError, subprocess.SubprocessError):
+ text = ""
+ self._mapper_help = text
+ return text
+
+ def _mapper_supports(self, option: str) -> bool:
+ """True if `colmap mapper` advertises --Mapper. for this build.
+
+ If the help probe failed (empty text) we return False â better to skip a
+ speed-tuning flag than risk aborting the mapper on an unrecognized option.
+ """
+ return option in self._mapper_help_text()
+
+ def _mapper_supports_gpu_ba(self) -> bool:
+ """True if `colmap mapper` accepts --Mapper.ba_use_gpu (COLMAP ⥠4.1.0)."""
+ return self._mapper_supports('ba_use_gpu')
def image_undistorter(self, images_dir: str, sparse_dir: str, output_dir: str) -> bool:
"""Exécute l'undistortion des images."""
@@ -733,46 +1748,110 @@ def image_undistorter(self, images_dir: str, sparse_dir: str, output_dir: str) -
]
return self.run_command(cmd, "Undistortion des images", status_prefix="Correction optique")
+ def _count_registered_images(self, model_dir: Path) -> int:
+ """Number of registered images in a COLMAP sub-model. The binary
+ images.bin begins with a uint64 count, so this reads just 8 bytes;
+ falls back to counting images.txt entries."""
+ import struct
+ b = model_dir / "images.bin"
+ if b.exists():
+ try:
+ with open(b, "rb") as f:
+ head = f.read(8)
+ return struct.unpack(" None:
+ """Ensure sparse/0 is the sub-model with the most registered images.
+
+ COLMAP's multiple_models output ordering is not by size, so sparse/0 may
+ be a small fragment. Downstream (Brush, undistorter) always uses sparse/0,
+ so swap the largest model into that slot."""
+ try:
+ models = []
+ for d in sorted(sparse_dir.iterdir()):
+ if d.is_dir() and d.name.isdigit():
+ n = self._count_registered_images(d)
+ if n > 0:
+ models.append((n, d))
+ except OSError:
+ return
+ if not models:
+ return
+ models.sort(key=lambda m: m[0], reverse=True)
+ best_n, best = models[0]
+ if len(models) > 1:
+ self.log("Sous-modÚles COLMAP (images enregistrées) : "
+ + ", ".join(f"sparse/{p.name}={n}" for n, p in models))
+ if best.name == "0":
+ self.log(f"ModÚle principal : sparse/0 ({best_n} images).")
+ return
+ # Swap the largest model into sparse/0.
+ zero = sparse_dir / "0"
+ tmp = sparse_dir / "_swap_tmp"
+ try:
+ if tmp.exists():
+ shutil.rmtree(tmp)
+ best.rename(tmp) # largest â _swap_tmp
+ zero.rename(best) # old 0 â largest's freed name
+ tmp.rename(zero) # _swap_tmp â 0
+ self.log(f"â ïž La reconstruction s'est fragmentée â modÚle le plus complet "
+ f"(sparse/{best.name}, {best_n} images) promu en sparse/0 pour l'entraînement.")
+ except OSError as e:
+ self.log(f"(info) Impossible de réordonner les sous-modÚles COLMAP : {e}")
+
def create_brush_config(self, output_dir: Path, images_dir: Path, sparse_dir: Path):
"""GénÚre le fichier de configuration pour Brush."""
if self.params.undistort_images:
final_images_path = output_dir / "dense" / "images"
- final_sparse_path = output_dir / "dense" / "sparse"
+ # _normalize_dense_sparse_layout moved the model into sparse/0
+ final_sparse_path = output_dir / "dense" / "sparse" / "0"
self.log("Utilisation des images et reconstruction non-distordues pour Brush")
else:
final_images_path = images_dir
final_sparse_path = sparse_dir / "0"
-
+
config = {
"dataset_type": "colmap",
"images_path": str(final_images_path),
"sparse_path": str(final_sparse_path),
- "created_with": "CorbeauSplat macOS",
+ "created_with": "CorbeauSplat Windows",
"architecture": platform.machine(),
- "optimized_for": "Apple Silicon" if self.is_silicon else "x86_64",
+ "optimized_for": "CUDA" if self.has_cuda else "CPU",
"parameters": self.params.to_dict()
}
config_path = output_dir / "brush_config.json"
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
self.log(f"Configuration Brush créée: {config_path}")
-
+
def stop(self):
"""Arrête le processus en cours."""
super().stop()
@staticmethod
- def delete_project_content(target_path: Path) -> Tuple[bool, str]:
+ def delete_project_content(target_path: Path) -> tuple[bool, str]:
"""Supprime le contenu d'un dossier de projet de maniÚre sécurisée.
Only allows deletion if target_path is contained within project_root
or user home directory.
"""
from .system import resolve_project_root
-
+
safe_path = Path(target_path).resolve()
project_root = resolve_project_root().resolve()
-
+
# Validate containment: target must be inside project_root only
allowed = False
try:
@@ -780,18 +1859,19 @@ def delete_project_content(target_path: Path) -> Tuple[bool, str]:
allowed = True
except ValueError:
pass
-
+
if not allowed:
logger = logging.getLogger(__name__)
logger.warning("delete_project_content blocked: path outside allowed boundaries â %s", safe_path)
return False, "Suppression bloquée : le chemin n'est pas dans les limites autorisées."
-
+
if safe_path == project_root or safe_path == Path.home().resolve():
return False, "Tentative de suppression critique bloquée par sécurité."
if not target_path.exists():
return False, "Le dossier n'existe pas"
-
+
+ import send2trash # lazy: only the trash-delete path needs it, never startup
try:
for item in target_path.iterdir():
if item.name == "images":
diff --git a/app/core/export_engine.py b/app/core/export_engine.py
index ccaeb81..203e5ad 100644
--- a/app/core/export_engine.py
+++ b/app/core/export_engine.py
@@ -1,13 +1,17 @@
-import os
+import math
import shutil
import subprocess
+from collections.abc import Callable
from pathlib import Path
-from typing import Optional, Callable
from .base_engine import BaseEngine
from .ply_utils import (
- compress_scale, compress_rotation, compress_alpha,
- write_spz_header, write_spz_data, parse_ply_manual,
+ compress_alpha,
+ compress_rotation,
+ compress_scale,
+ parse_ply_manual,
+ write_spz_data,
+ write_spz_header,
)
@@ -16,7 +20,7 @@ class ExportEngine(BaseEngine):
SUPPORTED_FORMATS = ["spz", "glb", "obj", "ply", "xyz"]
- def __init__(self, logger_callback: Optional[Callable] = None) -> None:
+ def __init__(self, logger_callback: Callable | None = None) -> None:
super().__init__("Export", logger_callback)
def is_available(self) -> bool:
@@ -84,9 +88,8 @@ def _export_ply(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
elif opts.get('compress', False):
import gzip
output_file = output_dir / (input_file.name + '.gz')
- with open(input_file, 'rb') as f_in:
- with gzip.open(output_file, 'wb') as f_out:
- shutil.copyfileobj(f_in, f_out)
+ with open(input_file, 'rb') as f_in, gzip.open(output_file, 'wb') as f_out:
+ shutil.copyfileobj(f_in, f_out)
self.log(f"Compressé: {output_file}")
return True
else:
@@ -118,19 +121,19 @@ def _export_xyz(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
output_file = output_dir / f"{input_file.stem}.xyz"
include_colors = opts.get('include_colors', False)
delimiter = opts.get('delimiter', ' ')
-
+
try:
try:
from plyfile import PlyData
ply = PlyData.read(str(input_file))
vertex = ply['vertex']
has_colors = 'red' in vertex.data.dtype.names
-
+
with open(output_file, 'w') as fout:
for i in range(len(vertex)):
data = vertex[i]
x, y, z = float(data['x']), float(data['y']), float(data['z'])
-
+
if include_colors and has_colors:
r, g, b = int(data['red']), int(data['green']), int(data['blue'])
fout.write(f"{x}{delimiter}{y}{delimiter}{z}{delimiter}{r}{delimiter}{g}{delimiter}{b}\n")
@@ -138,7 +141,7 @@ def _export_xyz(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
fout.write(f"{x}{delimiter}{y}{delimiter}{z}\n")
except ImportError:
# Fallback: parse manually
- with open(input_file, 'r') as fin:
+ with open(input_file) as fin:
lines = fin.readlines()
with open(output_file, 'w') as fout:
@@ -170,14 +173,14 @@ def _export_obj(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
include_mtl = opts.get('include_materials', True)
include_colors = opts.get('include_vertex_colors', True)
scale = opts.get('scale', 1.0)
-
+
try:
try:
from plyfile import PlyData
ply = PlyData.read(str(input_file))
vertex = ply['vertex']
has_colors = 'red' in vertex.data.dtype.names
-
+
if include_mtl:
mtl_file = output_dir / f"{input_file.stem}.mtl"
with open(mtl_file, 'w') as fmtl:
@@ -202,7 +205,7 @@ def _export_obj(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
x = float(data['x']) * scale
y = float(data['y']) * scale
z = float(data['z']) * scale
-
+
vertex_count += 1
if include_colors and has_colors:
r, g, b = int(data['red'])/255, int(data['green'])/255, int(data['blue'])/255
@@ -211,10 +214,10 @@ def _export_obj(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
fout.write(f"v {x:.6f} {y:.6f} {z:.6f}\n")
fout.write(f"\n# {vertex_count} vertices\n")
-
+
except ImportError:
# Fallback: parse manually
- with open(input_file, 'r') as fin:
+ with open(input_file) as fin:
lines = fin.readlines()
has_colors = False
@@ -270,7 +273,7 @@ def _export_glb(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
output_file = output_dir / f"{input_file.stem}.glb"
method = opts.get('method', 'auto') # auto, trimesh, open3d, assimp
point_size = opts.get('point_size', 0.01)
-
+
if method == 'auto':
if self._try_export_glb_trimesh(input_file, output_file, opts):
return True
@@ -294,26 +297,26 @@ def _export_glb(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
def _try_export_glb_trimesh(self, input_file: Path, output_file: Path, opts: dict) -> bool:
"""Export using trimesh library."""
try:
- import trimesh
import numpy as np
+ import trimesh
from plyfile import PlyData
-
+
ply = PlyData.read(str(input_file))
vertex = ply['vertex']
-
+
points = np.column_stack([
vertex['x'], vertex['y'], vertex['z']
])
-
+
colors = None
if 'red' in vertex.data.dtype.names:
colors = np.column_stack([
vertex['red'], vertex['green'], vertex['blue']
])
-
+
# Create point cloud
cloud = trimesh.PointCloud(vertices=points, colors=colors)
-
+
# Export as GLB
cloud.export(str(output_file))
self.log(f"Exporté GLB via trimesh: {output_file}")
@@ -328,13 +331,13 @@ def _try_export_glb_open3d(self, input_file: Path, output_file: Path, opts: dict
"""Export using open3d library."""
try:
import open3d as o3d
-
+
pcd = o3d.io.read_point_cloud(str(input_file))
-
+
# open3d doesn't natively export GLB, convert via intermediate
temp_ply = output_file.parent / f"{input_file.stem}_temp.ply"
o3d.io.write_point_cloud(str(temp_ply), pcd)
-
+
# Then use trimesh for GLB
try:
import trimesh
@@ -345,7 +348,7 @@ def _try_export_glb_open3d(self, input_file: Path, output_file: Path, opts: dict
return True
except ImportError:
pass
-
+
temp_ply.unlink(missing_ok=True)
return False
except ImportError:
@@ -358,10 +361,10 @@ def _try_export_glb_assimp(self, input_file: Path, output_file: Path, opts: dict
"""Export using assimp command-line tool via intermediate OBJ."""
try:
from plyfile import PlyData
-
+
ply = PlyData.read(str(input_file))
vertex = ply['vertex']
-
+
temp_obj = input_file.parent / f"{input_file.stem}_temp.obj"
temp_mtl = input_file.parent / f"{input_file.stem}_temp.mtl"
@@ -412,7 +415,7 @@ def _export_spz(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
- Alphas: uint8 per point (sigmoid compressed, 1 byte each)
"""
output_file = output_dir / f"{input_file.stem}.spz"
-
+
# Options
quantize_positions = opts.get('quantize_positions', False)
compression_level = opts.get('compression_level', 'normal') # low, normal, high
@@ -448,19 +451,22 @@ def _export_spz(self, input_file: Path, output_dir: Path, opts: dict) -> bool:
# Scales (may not be present, default to small)
if 'scale_0' in data.dtype.names:
- s0 = max(0.001, float(data.get('scale_0', -2.0)))
- s1 = max(0.001, float(data.get('scale_1', -2.0)))
- s2 = max(0.001, float(data.get('scale_2', -2.0)))
+ # `data` is a numpy.void (structured scalar) â index by
+ # field name, it has no dict-style .get(). The guard above
+ # already guarantees the fields exist.
+ s0 = max(0.001, float(data['scale_0']))
+ s1 = max(0.001, float(data['scale_1']))
+ s2 = max(0.001, float(data['scale_2']))
scales.extend(compress_scale(s0, s1, s2))
else:
scales.extend([0, 0, 0])
# Rotations (quaternions) - may not be present
if 'rot_0' in data.dtype.names:
- r0 = float(data.get('rot_0', 1.0))
- r1 = float(data.get('rot_1', 0.0))
- r2 = float(data.get('rot_2', 0.0))
- r3 = float(data.get('rot_3', 0.0))
+ r0 = float(data['rot_0'])
+ r1 = float(data['rot_1'])
+ r2 = float(data['rot_2'])
+ r3 = float(data['rot_3'])
# Normalize quaternion
norm = math.sqrt(r0*r0 + r1*r1 + r2*r2 + r3*r3)
if norm > 0:
@@ -656,4 +662,4 @@ def _convert_obj_to_glb(self, obj_file: Path, glb_file: Path) -> bool:
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
- return False
\ No newline at end of file
+ return False
diff --git a/app/core/extractor_360_engine.py b/app/core/extractor_360_engine.py
index 01b1bb6..50c0aaf 100644
--- a/app/core/extractor_360_engine.py
+++ b/app/core/extractor_360_engine.py
@@ -1,9 +1,15 @@
import os
-import subprocess
from pathlib import Path
+
+from app.scripts.setup_dependencies import (
+ get_venv_360_python,
+ install_extractor_360,
+ resolve_project_root,
+ uninstall_extractor_360,
+)
+
from .base_engine import BaseEngine
from .i18n import tr
-from app.scripts.setup_dependencies import install_extractor_360, get_venv_360_python, uninstall_extractor_360, resolve_project_root
class Extractor360Engine(BaseEngine):
@@ -48,34 +54,34 @@ def run_extraction(self, input_path, output_dir, params, progress_callback=None,
# interval
if "interval" in params:
cmd.extend(["--interval", str(params["interval"])])
-
+
# format
if "format" in params:
cmd.extend(["--format", params["format"]])
-
+
# resolution
if "resolution" in params:
cmd.extend(["--resolution", str(params["resolution"])])
-
+
# camera-count
if "camera_count" in params:
cmd.extend(["--camera-count", str(params["camera_count"])])
-
+
# quality
if "quality" in params:
cmd.extend(["--quality", str(params["quality"])])
-
+
# layout
if "layout" in params:
cmd.extend(["--layout", params["layout"]])
-
+
# AI options
if params.get("ai_mask", False):
cmd.append("--ai-mask")
-
+
if params.get("ai_skip", False):
cmd.append("--ai-skip")
-
+
if params.get("adaptive", False):
cmd.append("--adaptive")
if "motion_threshold" in params:
@@ -90,7 +96,7 @@ def run_extraction(self, input_path, output_dir, params, progress_callback=None,
env = os.environ.copy()
# Isolate from the main app's PYTHONPATH to avoid package conflicts
env.pop("PYTHONPATH", None)
-
+
# Ensure all arguments are strings for subprocess
cmd_str = [str(arg) for arg in cmd]
diff --git a/app/core/four_dgs_engine.py b/app/core/four_dgs_engine.py
index 801230a..e40b59e 100644
--- a/app/core/four_dgs_engine.py
+++ b/app/core/four_dgs_engine.py
@@ -1,10 +1,8 @@
-import os
-import subprocess
-import shutil
import sys
from pathlib import Path
+
from .base_engine import BaseEngine
-from .system import resolve_binary, is_apple_silicon, get_optimal_threads, resolve_project_root
+from .system import get_optimal_threads, has_cuda, resolve_binary, resolve_project_root
# Path to the dedicated nerfstudio venv
_VENV_4DGS = resolve_project_root() / ".venv_4dgs"
@@ -32,13 +30,13 @@ class FourDGSEngine(BaseEngine):
def __init__(self, logger_callback=None, status_callback=None):
super().__init__("4DGS", logger_callback)
self.status = status_callback if status_callback else lambda x: None
-
+
# Resolve binaries
self.ffmpeg = resolve_binary("ffmpeg") or "ffmpeg"
self.colmap = resolve_binary("colmap") or "colmap"
self.venv_python = get_venv_4dgs_python()
self.ns_process_data = str(_get_ns_process_data_path())
-
+
def check_nerfstudio(self):
"""Vérifie si ns-process-data est disponible dans le venv dédié"""
ns_path = _get_ns_process_data_path()
@@ -53,23 +51,23 @@ def extract_frames(self, video_path, output_dir, fps=5):
out_p.mkdir(parents=True, exist_ok=True)
cmd = [self.ffmpeg]
- if is_apple_silicon():
- cmd.extend(["-hwaccel", "videotoolbox"])
-
+ if has_cuda():
+ cmd.extend(["-hwaccel", "cuda"])
+
cmd.extend([
"-i", str(video_path),
"-vf", f"fps={fps}",
"-q:v", "2", # Haute qualité jpeg
str(out_p / "%05d.jpg")
])
-
+
# Template Method : Délégation à _execute_command centralisé
return self._execute_command(cmd) == 0
def run_colmap(self, dataset_root):
"""Lance le pipeline COLMAP : Feature Extractor -> Matcher -> Mapper"""
if self.stop_requested: return False
-
+
root = Path(dataset_root)
db_path = root / "database.db"
images_path = root / "images"
@@ -84,11 +82,11 @@ def run_colmap(self, dataset_root):
"--database_path", str(db_path),
"--image_path", str(images_path),
"--ImageReader.camera_model", "OPENCV",
- "--ImageReader.single_camera", "1"
+ "--ImageReader.single_camera", "1"
]
-
+
if self._execute_command(cmd_extract) != 0: return False
-
+
self.log("--- COLMAP: Feature Matching ---")
self.status("Matching des features...")
cmd_match = [
@@ -97,7 +95,7 @@ def run_colmap(self, dataset_root):
]
if self._execute_command(cmd_match) != 0: return False
-
+
# 3. Mapper
self.log("--- COLMAP: Mapper (Sparse Reconstruction) ---")
self.status("Reconstruction 3D (Mapper)...")
@@ -107,12 +105,12 @@ def run_colmap(self, dataset_root):
"--image_path", str(images_path),
"--output_path", str(sparse_path)
]
-
+
threads = str(get_optimal_threads())
cmd_mapper.append(f"--Mapper.num_threads={threads}")
if self._execute_command(cmd_mapper) != 0: return False
-
+
return True
def process_dataset(self, videos_dir, output_dir, fps=5):
@@ -120,33 +118,33 @@ def process_dataset(self, videos_dir, output_dir, fps=5):
supported_ext = (".mp4", ".mov", ".avi", ".mkv")
videos_path = Path(videos_dir)
videos = sorted([f for f in videos_path.iterdir() if f.suffix.lower() in supported_ext])
-
+
if not videos:
self.log("Aucune vidéo trouvée.")
return False
-
+
self.log(f"Trouvé {len(videos)} vidéos. Début extraction...")
-
+
images_root = Path(output_dir) / "images"
images_root.mkdir(parents=True, exist_ok=True)
-
+
# 1. Extraction
for idx, vid_path in enumerate(videos):
if self.stop_requested: return False
cam_name = f"cam_{idx:02d}"
cam_dir = images_root / cam_name
-
+
self.log(f"Extraction {vid_path.name} -> {cam_name} ({fps} fps)...")
self.status(f"Extraction des frames ({vid_path.name})...")
if not self.extract_frames(vid_path, cam_dir, fps):
return False
-
+
self.log("Extraction terminée.")
-
+
if self.check_nerfstudio():
self.log("ns-process-data détecté (venv_4dgs). Lancement du processing Nerfstudio...")
self.status("Traitement Nerfstudio en cours...")
-
+
# Use the dedicated venv script
cmd_ns = [
self.ns_process_data, "images",
@@ -154,11 +152,11 @@ def process_dataset(self, videos_dir, output_dir, fps=5):
"--output-dir", str(output_dir),
"--verbose"
]
-
+
if self._execute_command(cmd_ns) != 0:
self.log("Echec ns-process-data.")
return False
-
+
return True
else:
self.log("Nerfstudio non trouvé. Lancement mode dégradé (COLMAP manuel uniquement).")
diff --git a/app/core/i18n.py b/app/core/i18n.py
index dec6ca0..5f4090d 100644
--- a/app/core/i18n.py
+++ b/app/core/i18n.py
@@ -1,45 +1,44 @@
import json
import logging
-import os
-from pathlib import Path
+
from app.core.system import resolve_project_root
logger = logging.getLogger(__name__)
class LanguageManager:
_instance = None
-
+
def __new__(cls):
if cls._instance is None:
- cls._instance = super(LanguageManager, cls).__new__(cls)
+ cls._instance = super().__new__(cls)
cls._instance.current_lang = "fr" # Default
cls._instance._translations = {}
cls._instance._observers = []
cls._instance.load_config()
cls._instance._load_translations()
return cls._instance
-
+
def add_observer(self, callback):
"""Add a callback to be notified when language changes"""
if callback not in self._observers:
self._observers.append(callback)
-
+
def _load_translations(self):
"""Load translations from JSON for the current language"""
try:
locales_dir = resolve_project_root() / "assets" / "locales"
lang_path = locales_dir / f"{self.current_lang}.json"
-
+
# Fallback to English if current lang doesn't exist
if not lang_path.exists():
lang_path = locales_dir / "en.json"
-
+
# Final fallback to French if nothing found (core default)
if not lang_path.exists():
lang_path = locales_dir / "fr.json"
-
+
if lang_path.exists():
- with open(lang_path, "r", encoding="utf-8") as f:
+ with open(lang_path, encoding="utf-8") as f:
self._translations = json.load(f)
else:
self._translations = {}
@@ -51,7 +50,7 @@ def load_config(self):
try:
config_file = resolve_project_root() / "config.json"
if config_file.exists():
- with open(config_file, "r") as f:
+ with open(config_file) as f:
config = json.load(f)
self.current_lang = config.get("language", "fr")
except (OSError, json.JSONDecodeError) as e:
@@ -62,7 +61,7 @@ def save_config(self):
config_file = resolve_project_root() / "config.json"
config = {}
if config_file.exists():
- with open(config_file, "r") as f:
+ with open(config_file) as f:
existing = json.load(f)
if isinstance(existing, dict):
config = existing
diff --git a/app/core/params.py b/app/core/params.py
index ce81063..7828e14 100644
--- a/app/core/params.py
+++ b/app/core/params.py
@@ -1,4 +1,5 @@
-from dataclasses import dataclass, asdict, fields
+from dataclasses import asdict, dataclass, fields
+
@dataclass
class ColmapParams:
@@ -9,7 +10,9 @@ class ColmapParams:
max_num_features: int = 8192
force_cpu: bool = False
estimate_affine_shape: bool = False
- domain_size_pooling: bool = True
+ # OFF by default: domain-size-pooling (and affine shape) disable COLMAP's
+ # GPU SIFT, falling back to slow CPU extraction. Keep off to use the CUDA GPU.
+ domain_size_pooling: bool = False
max_ratio: float = 0.8
max_distance: float = 0.7
cross_check: bool = True
@@ -19,15 +22,49 @@ class ColmapParams:
ba_refine_focal_length: bool = True
ba_refine_principal_point: bool = False
ba_refine_extra_params: bool = True
+ # GPU-accelerated bundle adjustment (COLMAP 4.1.0 "Caspar" solver). ON by
+ # default for speed â only passed to the mapper when the installed COLMAP
+ # supports --Mapper.ba_use_gpu, otherwise it is silently skipped (no crash).
+ # COLMAP auto-falls back to CPU for small scenes, so it's safe as a default.
+ ba_use_gpu: bool = True
+ ba_gpu_index: int = -1
+ # Bundle-adjustment cost bounds (faster than COLMAP defaults, still safe for a
+ # 3DGS target which tolerates sub-pixel pose error). The global BA is the
+ # dominant mapper cost on large scenes; these cap its iterations and how often
+ # it re-runs. Tunable via config.json if a specific scene needs the slower,
+ # higher-accuracy COLMAP defaults (50 / 0.0 / 1.1 / 1.1 / 25).
+ ba_global_max_num_iterations: int = 30 # COLMAP default 50
+ ba_global_function_tolerance: float = 1e-6 # COLMAP default 0.0 (run all iters)
+ ba_global_images_ratio: float = 1.2 # COLMAP default 1.1 (run global BA less often)
+ ba_global_points_ratio: float = 1.2 # COLMAP default 1.1
+ ba_local_max_num_iterations: int = 20 # COLMAP default 25
+ # Refinement passes: each global-BA trigger re-runs BA+filtering up to
+ # max_refinements times. This is the dominant cost of the "Retriangulation
+ # and Global bundle adjustment" step â capping it is the single biggest
+ # in-mapper speed lever. Local BA runs after every image, so its aggregate
+ # cost matters too.
+ ba_global_max_refinements: int = 3 # COLMAP default 5
+ ba_local_max_refinements: int = 1 # COLMAP default 2
min_num_matches: int = 15
- matcher_type: str = 'exhaustive' # exhaustive, sequential, vocab_tree
+ # Sequential is the fast + correct default for video frames (ordered input):
+ # O(n) instead of exhaustive's O(n²). The whole pipeline is video â frames.
+ matcher_type: str = 'sequential' # exhaustive, sequential, vocab_tree
sequential_overlap: int = 30
+ # Loop-closure detection for sequential matching: recognises when the camera
+ # REVISITS a place (common in video) and matches those frames, so the same
+ # location isn't reconstructed twice ("ghost"/duplicate buildings). Needs a
+ # vocabulary tree (auto-downloaded); harmless when off.
+ loop_detection: bool = True
undistort_images: bool = False
use_glomap: bool = False
-
+ # Blur filtering: discard frames whose sharpness (variance of Laplacian) falls
+ # below blur_factor à the median sharpness. 0 (or filter_blurry=False) disables.
+ filter_blurry: bool = False
+ blur_factor: float = 0.7
+
def to_dict(self):
return asdict(self)
-
+
@classmethod
def from_dict(cls, data):
# Filtrer les clés inconnues pour éviter les erreurs si le json est vieux
diff --git a/app/core/ply_cleaner.py b/app/core/ply_cleaner.py
new file mode 100644
index 0000000..d8fb03b
--- /dev/null
+++ b/app/core/ply_cleaner.py
@@ -0,0 +1,133 @@
+"""
+ply_cleaner.py â Automatic cleanup of Gaussian-Splat .ply files.
+
+Removes the common junk produced by photogrammetry-based splatting:
+ - near-transparent splats (low opacity â noise),
+ - oversized splats (giant gaussians, e.g. sky "shells"),
+ - spatial outliers / floaters far from the main point cloud.
+
+The geometry/colour of the kept splats is preserved exactly â we only drop
+whole splats, never alter the survivors. The original file is never modified
+in place; callers pass an explicit output path.
+
+numpy is imported lazily inside the compute functions (not at module load): this
+module is pulled in at GUI startup via cleaner_tab â resolve_params, which needs
+no numpy, so we keep numpy's ~100ms cold import off the time-to-window path.
+"""
+
+# Severity presets â (opacity_min on activated alpha, scale percentile, outlier percentile)
+# Higher percentile = keep more (gentler); lower = remove more (stronger).
+PRESETS = {
+ "light": {"opacity_min": 0.05, "scale_pct": 99.9, "outlier_pct": 99.9},
+ "medium": {"opacity_min": 0.10, "scale_pct": 99.5, "outlier_pct": 99.5},
+ "strong": {"opacity_min": 0.20, "scale_pct": 99.0, "outlier_pct": 99.0},
+}
+
+
+def _sigmoid(x):
+ import numpy as np
+ return 1.0 / (1.0 + np.exp(-x))
+
+
+def compute_keep_mask(x, y, z, opacity, s0, s1, s2,
+ opacity_min=0.10, scale_pct=99.5, outlier_pct=99.5):
+ """Compute a boolean keep-mask for a set of Gaussian splats.
+
+ Parameters are 1-D numpy arrays (one entry per splat). `opacity` is the raw
+ logit (pre-sigmoid) and `s0..s2` are log-scales, matching the 3DGS/Brush PLY
+ convention. Returns (keep_mask, stats_dict).
+ """
+ import numpy as np
+ x = np.asarray(x, dtype=np.float64)
+ y = np.asarray(y, dtype=np.float64)
+ z = np.asarray(z, dtype=np.float64)
+ opacity = np.asarray(opacity, dtype=np.float64)
+ n = len(x)
+
+ # 1. Opacity â drop near-invisible splats (noise).
+ alpha = _sigmoid(opacity)
+ m_op = alpha >= opacity_min
+
+ # 2. Scale â drop oversized gaussians (sky shells / big floaters).
+ sizes = np.maximum.reduce([
+ np.exp(np.asarray(s0, dtype=np.float64)),
+ np.exp(np.asarray(s1, dtype=np.float64)),
+ np.exp(np.asarray(s2, dtype=np.float64)),
+ ])
+ if scale_pct >= 100.0 or n == 0:
+ m_sc = np.ones(n, dtype=bool)
+ else:
+ scale_thr = np.percentile(sizes, scale_pct)
+ m_sc = sizes <= scale_thr
+
+ # 3. Spatial outliers â drop splats far from the cloud's robust centre.
+ if outlier_pct >= 100.0 or n == 0:
+ m_out = np.ones(n, dtype=bool)
+ else:
+ cx, cy, cz = np.median(x), np.median(y), np.median(z)
+ dist = np.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2)
+ dist_thr = np.percentile(dist, outlier_pct)
+ m_out = dist <= dist_thr
+
+ keep = m_op & m_sc & m_out
+ stats = {
+ "total": int(n),
+ "kept": int(keep.sum()),
+ "removed": int(n - keep.sum()),
+ "removed_opacity": int((~m_op).sum()),
+ "removed_scale": int((~m_sc).sum()),
+ "removed_outlier": int((~m_out).sum()),
+ }
+ return keep, stats
+
+
+def resolve_params(strength="medium", overrides=None):
+ """Returns the cleaning parameter dict for a preset name, applying overrides."""
+ params = dict(PRESETS.get(strength, PRESETS["medium"]))
+ if overrides:
+ params.update({k: v for k, v in overrides.items() if v is not None})
+ return params
+
+
+def clean_ply(input_path, output_path, strength="medium", overrides=None, log=None):
+ """Clean a Gaussian-splat PLY and write the result to output_path.
+
+ Returns a stats dict. Raises ValueError if the file is not a Gaussian splat.
+ """
+ from plyfile import PlyData, PlyElement
+
+ def _log(msg):
+ if log:
+ log(msg)
+
+ params = resolve_params(strength, overrides)
+ _log(f"Lecture de {input_path} ...")
+ ply = PlyData.read(str(input_path))
+
+ if "vertex" not in ply:
+ raise ValueError("PLY invalide : élément 'vertex' absent.")
+ data = ply["vertex"].data
+ names = set(data.dtype.names or ())
+ required = {"x", "y", "z", "opacity", "scale_0", "scale_1", "scale_2"}
+ missing = required - names
+ if missing:
+ raise ValueError(
+ "Ce PLY n'est pas un Gaussian Splat (champs manquants : "
+ + ", ".join(sorted(missing)) + ")."
+ )
+
+ _log(f"{len(data)} splats chargés. Analyse...")
+ keep, stats = compute_keep_mask(
+ data["x"], data["y"], data["z"], data["opacity"],
+ data["scale_0"], data["scale_1"], data["scale_2"],
+ **params,
+ )
+
+ cleaned = data[keep]
+ el = PlyElement.describe(cleaned, "vertex")
+ PlyData([el], text=False).write(str(output_path))
+ _log(
+ f"â
Nettoyage terminé : {stats['kept']}/{stats['total']} splats conservés "
+ f"({stats['removed']} retirés). Ãcrit dans {output_path}"
+ )
+ return stats
diff --git a/app/core/ply_utils.py b/app/core/ply_utils.py
index 5b6f5a0..ab3f47e 100644
--- a/app/core/ply_utils.py
+++ b/app/core/ply_utils.py
@@ -7,8 +7,6 @@
import math
import struct
from pathlib import Path
-from typing import Optional
-
# ââ SPZ compression (pure math, no dependencies) ââââââââââââââââââââââââââ
@@ -137,6 +135,13 @@ def parse_ply_manual(input_file: Path) -> tuple:
if np_dtypes:
data = np.fromfile(f, dtype=np_dtypes, count=num_vertices)
+ # `row` is a numpy.void (structured scalar) which has NO .get()
+ # method â use the field-name set to provide safe defaults.
+ field_names = set(data.dtype.names or ())
+
+ def _field(row, name, default):
+ return float(row[name]) if name in field_names else float(default)
+
for i in range(num_vertices):
row = data[i]
x, y, z = float(row['x']), float(row['y']), float(row['z'])
@@ -149,39 +154,43 @@ def parse_ply_manual(input_file: Path) -> tuple:
if has_scales:
scales.extend([
- int((math.log(max(float(row.get('scale_0', -2.0)), 1e-10)) + 10) / 20 * 255),
- int((math.log(max(float(row.get('scale_1', -2.0)), 1e-10)) + 10) / 20 * 255),
- int((math.log(max(float(row.get('scale_2', -2.0)), 1e-10)) + 10) / 20 * 255),
+ int((math.log(max(_field(row, 'scale_0', -2.0), 1e-10)) + 10) / 20 * 255),
+ int((math.log(max(_field(row, 'scale_1', -2.0), 1e-10)) + 10) / 20 * 255),
+ int((math.log(max(_field(row, 'scale_2', -2.0), 1e-10)) + 10) / 20 * 255),
])
else:
scales.extend([0, 0, 0])
if has_rots:
rotations.extend(compress_rotation(
- float(row.get('rot_0', 1.0)),
- float(row.get('rot_1', 0.0)),
- float(row.get('rot_2', 0.0)),
- float(row.get('rot_3', 0.0)),
+ _field(row, 'rot_0', 1.0),
+ _field(row, 'rot_1', 0.0),
+ _field(row, 'rot_2', 0.0),
+ _field(row, 'rot_3', 0.0),
))
else:
rotations.extend([127, 127, 127])
if has_alpha:
- alphas.append(compress_alpha(float(row.get('opacity', 1.0))))
+ alphas.append(compress_alpha(_field(row, 'opacity', 1.0)))
else:
alphas.append(255)
else:
- # ASCII PLY
- in_header = True
- for line in header_lines:
- if in_header:
- if line.strip().startswith("end_header"):
- in_header = False
+ # ASCII PLY â the header was already consumed above, so the vertex
+ # rows must be read from the remaining file body. Iterating
+ # header_lines (the previous behaviour) yielded nothing past
+ # end_header, so the parser silently returned zero points.
+ for raw in f:
+ try:
+ line = raw.decode('ascii')
+ except UnicodeDecodeError:
continue
-
parts = line.strip().split()
if len(parts) >= 3:
- positions.extend([float(parts[0]), float(parts[1]), float(parts[2])])
+ try:
+ positions.extend([float(parts[0]), float(parts[1]), float(parts[2])])
+ except ValueError:
+ continue # skip malformed/non-numeric rows
if has_colors and len(parts) >= 6:
colors.extend([int(parts[3]), int(parts[4]), int(parts[5]), 255])
else:
diff --git a/app/core/sharp_engine.py b/app/core/sharp_engine.py
deleted file mode 100644
index 4f56ed8..0000000
--- a/app/core/sharp_engine.py
+++ /dev/null
@@ -1,225 +0,0 @@
-import os
-import sys
-import subprocess
-import shutil
-from pathlib import Path
-from typing import Optional, Callable
-from .base_engine import BaseEngine
-from .system import resolve_project_root, is_apple_silicon
-
-class SharpEngine(BaseEngine):
- """Moteur d'execution pour Apple ML Sharp"""
-
- def __init__(self, logger_callback=None):
- super().__init__("Sharp", logger_callback)
- self.process = None
-
- def _get_sharp_cmd(self):
- # 1. Look for .venv_sharp dedicated environment
- root_dir = resolve_project_root()
- sharp_venv_bin = root_dir / ".venv_sharp" / "bin"
-
- # Check binary in venv_sharp
- sharp_bin = sharp_venv_bin / "sharp"
- if sharp_bin.exists() and os.access(sharp_bin, os.X_OK):
- return [str(sharp_bin)]
-
- # Check python in venv_sharp -> run module
- sharp_python = sharp_venv_bin / "python3"
- if sharp_python.exists():
- return [str(sharp_python), "-m", "sharp.cli"]
-
- # 2. Try to find 'sharp' in the same bin dir as python executable (venv main)
- # Fallback if dedicated venv failed
- venv_bin = Path(sys.executable).parent
- sharp_bin = venv_bin / "sharp"
- if sharp_bin.exists() and os.access(sharp_bin, os.X_OK):
- return [str(sharp_bin)]
-
- # 3. Check global PATH
- from shutil import which
- if which("sharp"):
- return ["sharp"]
-
- # 4. Fallback: Run module
- return [sys.executable, "-m", "sharp.cli"]
- def is_installed(self):
- """Vérifie si Sharp est disponible (venv_sharp ou local)"""
- # Check venv_sharp binary
- root_dir = resolve_project_root()
- sharp_venv_bin = root_dir / ".venv_sharp" / "bin" / "sharp"
- if sharp_venv_bin.exists(): return True
-
- from shutil import which
- import importlib.util
-
- # 1. Check binary
- if which("sharp"): return True
-
- # 2. Check module
- if importlib.util.find_spec("sharp") is not None:
- return True
-
- return False
-
- def predict(self, input_path, output_path, params=None):
- """
- Lance la prediction Sharp.
- params: dict of prediction parameters
- """
- params = params or {}
- cmd = self._get_sharp_cmd()
-
- cmd.extend(["predict"])
- # Prepare paths
- input_path = Path(input_path).resolve()
- output_path = Path(output_path).resolve()
-
- cmd.extend(["-i", str(input_path)])
- cmd.extend(["-o", str(output_path)])
-
- checkpoint = params.get("checkpoint")
- if checkpoint:
- cmd.extend(["-c", str(Path(checkpoint).resolve())])
-
- device = params.get("device", self.device)
- if device and device != "default":
- cmd.extend(["--device", device])
-
- if params.get("verbose"):
- cmd.append("--verbose")
-
- # Environnement
- env = os.environ.copy()
-
- # Ensure all args are strings for Popen
- cmd = [str(arg) for arg in cmd]
-
- self.log(f"Lancement Sharp: {' '.join(cmd)}")
-
- # GoF-Template Method : Délégation au runner
- return self._execute_command(cmd, env=env)
-
- def process_video_frames(self, video_path: str, output_dir: str,
- params: Optional[dict] = None,
- log_callback: Optional[Callable] = None,
- status_callback: Optional[Callable] = None,
- progress_callback: Optional[Callable] = None,
- cancel_check: Optional[Callable] = None) -> int:
- """Shared video frame extraction + Sharp prediction pipeline.
-
- Extracts frames from a video via ffmpeg, runs Sharp on each frame,
- collects resulting PLY files, and cleans up temporary data.
-
- Parameters
- ----------
- video_path: str
- Path to the input video file.
- output_dir: str
- Directory where output PLY files will be placed.
- params: dict, optional
- Sharp parameters (skip_frames, etc.).
- log_callback: callable, optional
- Called with each log message.
- status_callback: callable, optional
- Called with status updates.
- progress_callback: callable, optional
- Called with integer percentage (0-100).
- cancel_check: callable, optional
- Called before each frame; if returns True, processing stops.
-
- Returns
- -------
- int
- Number of successfully processed frames.
- """
- params = params or {}
- skip = max(1, int(params.get("skip_frames", 1)))
-
- vp = Path(video_path)
- out = Path(output_dir)
-
- frames_dir = out / "temp_frames"
- frames_dir.mkdir(parents=True, exist_ok=True)
-
- # Clean previous frames
- for f in frames_dir.glob("*.png"):
- f.unlink()
-
- # Extract frames via ffmpeg
- ffmpeg_bin = shutil.which("ffmpeg") or "ffmpeg"
- ffmpeg_cmd = [ffmpeg_bin]
- if is_apple_silicon():
- ffmpeg_cmd.extend(["-hwaccel", "videotoolbox"])
- ffmpeg_cmd.extend([
- "-y", "-i", str(vp),
- "-vf", f"select=not(mod(n\\,{skip}))",
- "-vsync", "vfr", "-q:v", "1",
- str(frames_dir / "frame_%04d.png"),
- ])
-
- if log_callback:
- log_callback(f"Running: {' '.join(ffmpeg_cmd)}")
-
- try:
- result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
- except FileNotFoundError:
- if log_callback:
- log_callback("Erreur : FFmpeg introuvable.")
- shutil.rmtree(frames_dir, ignore_errors=True)
- return 0
-
- if result.returncode != 0:
- if log_callback:
- log_callback(f"FFmpeg error: {result.stderr}")
- shutil.rmtree(frames_dir, ignore_errors=True)
- return 0
-
- frames = sorted(frames_dir.glob("*.png"))
- total_frames = len(frames)
-
- if total_frames == 0:
- if log_callback:
- log_callback("Aucune frame extraite.")
- shutil.rmtree(frames_dir, ignore_errors=True)
- return 0
-
- if log_callback:
- log_callback(f"Total frames extraites: {total_frames}")
-
- success_count = 0
- for idx, frame_path in enumerate(frames):
- if cancel_check and cancel_check():
- if log_callback:
- log_callback("--- Arrêté par l'utilisateur ---")
- break
-
- display_idx = idx + 1
- if status_callback:
- status_callback(f"Processing frame {display_idx}/{total_frames}")
- if log_callback:
- log_callback(f"Processing frame {display_idx}/{total_frames}: {frame_path.name}")
-
- frame_out_dir = out / frame_path.stem
- returncode = self.predict(str(frame_path), str(frame_out_dir), params)
-
- if returncode == 0:
- ply_files = list(frame_out_dir.rglob("*.ply"))
- if ply_files:
- dest_ply = out / f"{frame_path.stem}.ply"
- shutil.copy2(ply_files[0], dest_ply)
- if log_callback:
- log_callback(f"Saved: {dest_ply.name}")
- success_count += 1
-
- if progress_callback:
- progress_callback(int((display_idx / total_frames) * 100))
-
- if frame_out_dir.exists():
- shutil.rmtree(frame_out_dir)
-
- # Cleanup temp frames
- if frames_dir.exists():
- shutil.rmtree(frames_dir, ignore_errors=True)
-
- return success_count
diff --git a/app/core/superplat_engine.py b/app/core/superplat_engine.py
index 2a3563e..39f458d 100644
--- a/app/core/superplat_engine.py
+++ b/app/core/superplat_engine.py
@@ -1,17 +1,17 @@
+import http.server
+import logging
import os
+import socketserver
import subprocess
-import sys
import threading
-import http.server
-import socketserver
-import logging
+from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlparse
-from typing import Tuple, Optional, Callable
from .base_engine import BaseEngine
from .system import resolve_project_root
+
class SuperSplatEngine(BaseEngine):
"""Engine to manage the SuperSplat viewer and its accompanying data server.
@@ -21,7 +21,7 @@ class SuperSplatEngine(BaseEngine):
from :class:`BaseEngine`.
"""
- def __init__(self, logger_callback: Optional[Callable] = None) -> None:
+ def __init__(self, logger_callback: Callable | None = None) -> None:
"""Create a new ``SuperSplatEngine`` instance.
Parameters
@@ -30,9 +30,9 @@ def __init__(self, logger_callback: Optional[Callable] = None) -> None:
Callback used by the base class to forward log messages to the UI.
"""
super().__init__("SuperSplat", logger_callback)
- self.data_server_process: Optional[subprocess.Popen] = None
- self.data_server_thread: Optional[threading.Thread] = None
- self.httpd: Optional[socketserver.TCPServer] = None
+ self.data_server_process: subprocess.Popen | None = None
+ self.data_server_thread: threading.Thread | None = None
+ self.httpd: socketserver.TCPServer | None = None
def get_supersplat_path(self) -> Path:
"""Return the absolute path to the bundled SuperSplat distribution."""
@@ -41,7 +41,7 @@ def get_supersplat_path(self) -> Path:
# ---------------------------------------------------------------------
# SuperSplat viewer management
# ---------------------------------------------------------------------
- def start_supersplat(self, port: int = 3000) -> Tuple[bool, str]:
+ def start_supersplat(self, port: int = 3000) -> tuple[bool, str]:
"""Launch the SuperSplat viewer using ``npx serve``.
Parameters
@@ -62,7 +62,9 @@ def start_supersplat(self, port: int = 3000) -> Tuple[bool, str]:
# Ensure any previous instance is stopped before starting a new one.
self.stop_supersplat()
- cmd = ["npx", "serve", "dist", "-p", str(port), "--no-clipboard"]
+ # On Windows npx is a .cmd shim; route through cmd.exe so it is launchable.
+ npx = ["cmd", "/c", "npx"] if os.name == "nt" else ["npx"]
+ cmd = [*npx, "serve", "dist", "-p", str(port), "--no-clipboard"]
try:
self.runner.start(cmd, env=os.environ.copy(), cwd=str(splat_path))
@@ -87,7 +89,7 @@ def stop_supersplat(self) -> None:
# ---------------------------------------------------------------------
# Data server (CORSâenabled) management
# ---------------------------------------------------------------------
- def start_data_server(self, directory: str, port: int = 8000) -> Tuple[bool, str]:
+ def start_data_server(self, directory: str, port: int = 8000) -> tuple[bool, str]:
"""Start a lightweight HTTP server that serves files from *directory*.
The server binds only to ``127.0.0.1`` and adds a permissive CORS header so
@@ -116,17 +118,35 @@ def log_message(self, format, *args): # Suppress noisy default logging
class _ReuseAddrTCPServer(socketserver.TCPServer):
allow_reuse_address = True
+ bind_event = threading.Event()
+ bind_error: dict = {"e": None}
+
def run_server(): # pragma: no cover â runs in a background thread
from functools import partial
handler = partial(CORSRequestHandler, directory=str(dir_path))
try:
self.httpd = _ReuseAddrTCPServer(("127.0.0.1", port), handler)
+ except Exception as e:
+ # Bind failed (port busy, permission). Report it to the caller
+ # instead of claiming success on a server that never started.
+ bind_error["e"] = e
+ self.log(f"Erreur Data Server: {e}", level=logging.ERROR)
+ bind_event.set()
+ return
+ bind_event.set()
+ try:
self.httpd.serve_forever()
except Exception as e:
self.log(f"Erreur Data Server: {e}", level=logging.ERROR)
self.data_server_thread = threading.Thread(target=run_server, daemon=True)
self.data_server_thread.start()
+ # Wait for the bind attempt so we never report success on a dead server.
+ if not bind_event.wait(timeout=3.0):
+ return False, "Délai de démarrage du serveur de données dépassé"
+ if bind_error["e"] is not None:
+ return False, (f"Le serveur de données n'a pas pu démarrer "
+ f"(port {port} occupé ?) : {bind_error['e']}")
self.log(f"Serveur de données démarré sur http://localhost:{port}")
return True, f"Serveur de données démarré sur http://localhost:{port}"
diff --git a/app/core/system.py b/app/core/system.py
index 3e82347..e621436 100644
--- a/app/core/system.py
+++ b/app/core/system.py
@@ -1,145 +1,162 @@
-import platform
import os
+import platform
import shutil
-import subprocess
from pathlib import Path
+# Common install locations for COLMAP on Windows. The official pre-built
+# packages ship a `COLMAP.bat` shim alongside `colmap.exe` in a `bin/` folder.
+_WINDOWS_COLMAP_HINTS = (
+ r"C:\COLMAP",
+ r"C:\Program Files\COLMAP",
+ r"C:\Program Files\colmap",
+)
+
+
def resolve_project_root() -> Path:
"""Finds project root relative to this script (app/core/system.py)"""
return Path(__file__).resolve().parent.parent.parent
-def is_apple_silicon():
- """Détecte si on est sur Apple Silicon"""
- return platform.system() == 'Darwin' and platform.machine() == 'arm64'
-
-
-def get_optimal_threads():
- """Retourne le nombre optimal de threads pour Apple Silicon (P-cores) ou autres plateformes"""
- if is_apple_silicon():
- # Apple Silicon has heterogeneous P-cores (performance) + E-cores (efficiency).
- # For compute-heavy tasks (COLMAP, ffmpeg), we prefer P-cores only.
- # Try multiple sysctl keys in order of preference, as not all keys exist
- # on every macOS version or chip generation.
- for key in (
- "hw.perflevel0.logicalcpu", # P-core logical count (primary)
- "hw.perflevel0.logicalcpu_max", # P-core logical max (macOS 14+)
- "hw.perflevel0.physicalcpu", # P-core physical count
- "hw.physicalcpu", # total physical cores (P+E)
- ):
- try:
- result = subprocess.run(
- ["sysctl", "-n", key],
- capture_output=True, text=True, timeout=2
- )
- if result.returncode == 0:
- cores = int(result.stdout.strip())
- if cores > 0:
- # hw.physicalcpu includes E-cores; approximate P-only
- if key == "hw.physicalcpu":
- cores = max(1, cores // 2)
- return cores
- except (ValueError, subprocess.SubprocessError, OSError):
- continue
- # Absolute fallback: os.cpu_count() includes both P and E logical cores;
- # divide by 2 as a conservative P-core estimate (M1: 8â4â, M1Pro: 10â5â,
- # M1Max: 10â5â, M2Pro: 12â6â, M3Max: 16â8â, M4Max: 16â8â).
- cpu_count = os.cpu_count() or 8
- return max(1, cpu_count // 2)
+
+def is_windows() -> bool:
+ return os.name == "nt" or platform.system() == "Windows"
+
+
+def has_cuda() -> bool:
+ """Detects an NVIDIA CUDA GPU via the `nvidia-smi` utility."""
+ return shutil.which("nvidia-smi") is not None
+
+
+def get_optimal_threads() -> int:
+ """Returns a sensible thread count for compute-heavy work (COLMAP, ffmpeg)."""
return os.cpu_count() or 4
+
+def _windows_exe_candidates(name: str):
+ """Yields plausible executable file names for `name` on Windows."""
+ lowered = name.lower()
+ if lowered.endswith((".exe", ".bat", ".cmd")):
+ yield name
+ return
+ for ext in (".exe", ".bat", ".cmd"):
+ yield name + ext
+
+
+def _search_subtree(base: Path, exe_names):
+ """Returns the first matching executable found anywhere under `base`."""
+ if not base.exists():
+ return None
+ for exe in exe_names:
+ for found in base.rglob(exe):
+ if found.is_file():
+ return str(found)
+ return None
+
+
def resolve_binary(name):
"""
- Résoud le chemin d'un binaire en priorisant le dossier 'engines' local.
- Retourne le chemin absolu ou le nom si trouvé dans le PATH, sinon None.
+ Resolves the path of a binary, prioritising the local 'engines' folder
+ (including auto-extracted COLMAP/ffmpeg subtrees), then well-known Windows
+ install locations, then the system PATH.
+
+ Returns the absolute path (str) or None if not found.
"""
- # 1. Chercher dans le dossier engines à la racine du projet
engines_dir = resolve_project_root() / "engines"
-
- local_path = engines_dir / name
-
- # Cas binaire direct
- if local_path.exists() and os.access(local_path, os.X_OK):
- return str(local_path)
-
- # Cas macOS .app bundle pour COLMAP
+
+ # 1. Local engines/ folder â accept bare name and Windows extensions
+ candidate_names = [name]
+ if is_windows():
+ candidate_names = list(_windows_exe_candidates(name))
+ for cand in candidate_names:
+ local_path = engines_dir / cand
+ if local_path.exists() and os.access(local_path, os.X_OK):
+ return str(local_path)
+
+ # 2. Auto-installed engines extracted into a dedicated subfolder.
+ # COLMAP: prefer colmap.exe â COLMAP.bat cannot be launched directly via
+ # subprocess without a shell. The bundled DLLs are made discoverable
+ # separately (see ColmapEngine.run_command).
if name == "colmap":
- colmap_app = engines_dir / "COLMAP.app" / "Contents" / "MacOS" / "colmap"
- if colmap_app.exists() and os.access(colmap_app, os.X_OK):
- return str(colmap_app)
-
- # 2. Chercher dans le PATH systÚme
+ found = _search_subtree(engines_dir / "colmap", ("colmap.exe", "colmap"))
+ if found:
+ return found
+ if is_windows():
+ for hint in _WINDOWS_COLMAP_HINTS:
+ found = _search_subtree(Path(hint), ("colmap.exe",))
+ if found:
+ return found
+ elif name == "ffmpeg":
+ found = _search_subtree(engines_dir / "ffmpeg", ("ffmpeg.exe", "ffmpeg"))
+ if found:
+ return found
+
+ # 3. System PATH. On Windows prefer the .exe so the result is always
+ # launchable via subprocess without a shell.
+ if is_windows() and not name.lower().endswith((".exe", ".bat", ".cmd")):
+ which_exe = shutil.which(name + ".exe")
+ if which_exe:
+ return which_exe
return shutil.which(name)
+
def get_device() -> str:
- """Centralized device selection: mps, cuda, or cpu."""
- if is_apple_silicon():
- return "mps"
- if shutil.which("nvidia-smi") is not None:
+ """Centralized device selection: cuda when an NVIDIA GPU is present, else cpu."""
+ if has_cuda():
return "cuda"
return "cpu"
+
def get_memory_info() -> dict:
- """Returns memory info for UMA/caching strategies via sysctl + vm_stat.
+ """Returns total/available/percent memory using OS-native probes.
- On Apple Silicon (UMA), memory_pressure is the most reliable indicator
- since GPU and CPU share the same pool.
+ On Windows this uses GlobalMemoryStatusEx via ctypes. Falls back to a
+ best-effort estimate when the probe is unavailable.
"""
total = 0
available = 0
percent = 0.0
- # Total physical memory
- try:
- result = subprocess.run(
- ["sysctl", "-n", "hw.memsize"],
- capture_output=True, text=True, timeout=2
- )
- total = int(result.stdout.strip()) if result.returncode == 0 else 0
- except (ValueError, subprocess.SubprocessError, OSError):
- pass
-
- # Available memory: use vm_stat to get free + inactive + speculative pages.
- # On Apple Silicon UMA, compressed/inactive pages are effectively "available"
- # since the memory compressor frees them on demand.
- if total > 0:
+ if is_windows():
+ try:
+ import ctypes
+
+ class MEMORYSTATUSEX(ctypes.Structure):
+ _fields_ = [
+ ("dwLength", ctypes.c_ulong),
+ ("dwMemoryLoad", ctypes.c_ulong),
+ ("ullTotalPhys", ctypes.c_ulonglong),
+ ("ullAvailPhys", ctypes.c_ulonglong),
+ ("ullTotalPageFile", ctypes.c_ulonglong),
+ ("ullAvailPageFile", ctypes.c_ulonglong),
+ ("ullTotalVirtual", ctypes.c_ulonglong),
+ ("ullAvailVirtual", ctypes.c_ulonglong),
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
+ ]
+
+ stat = MEMORYSTATUSEX()
+ stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
+ if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
+ total = int(stat.ullTotalPhys)
+ available = int(stat.ullAvailPhys)
+ percent = float(stat.dwMemoryLoad)
+ except (OSError, AttributeError, ValueError):
+ pass
+
+ # Cross-platform fallback (also covers non-Windows dev/CI environments)
+ if total == 0:
try:
- result = subprocess.run(
- ["vm_stat"],
- capture_output=True, text=True, timeout=2
- )
- if result.returncode == 0:
- page_size = 16384 # Apple Silicon default page size
- pages = {}
- for line in result.stdout.splitlines():
- if ":" in line:
- key, val = line.split(":", 1)
- key = key.strip().strip('"')
- try:
- pages[key] = int(val.strip().rstrip("."))
- except ValueError:
- pass
- # Page size detection
- if "page size of" in result.stdout:
- for token in result.stdout.split():
- try:
- page_size = int(token)
- break
- except ValueError:
- pass
- free_pages = pages.get("Pages free", 0)
- inactive_pages = pages.get("Pages inactive", 0)
- speculative_pages = pages.get("Pages speculative", 0)
- available_pages = free_pages + inactive_pages + speculative_pages
- available_bytes = available_pages * page_size
- # Clamp to total (vm_stat can report more than hw.memsize
- # if compression reclaims pages from other categories)
- available = min(available_bytes, total)
+ page_size = os.sysconf("SC_PAGE_SIZE")
+ phys_pages = os.sysconf("SC_PHYS_PAGES")
+ avail_pages = os.sysconf("SC_AVPHYS_PAGES")
+ total = page_size * phys_pages
+ available = page_size * avail_pages
+ if total > 0:
percent = round(100.0 * (total - available) / total, 1)
- except (ValueError, subprocess.SubprocessError, OSError):
+ except (ValueError, OSError, AttributeError):
pass
return {"total": total, "available": available, "percent": percent}
+
def get_brush_build_mode() -> str:
"""Detect Brush build mode from engines/brush.version.
@@ -154,51 +171,14 @@ def get_brush_build_mode() -> str:
return "release"
-def is_amx_available() -> bool:
- """Detect whether the Apple Matrix coprocessor (AMX) is available.
-
- AMX is present on all Apple Silicon chips (M1 and later) and is used
- automatically by Accelerate.framework for BLAS/LAPACK operations.
- No user-space configuration is needed â this is purely informational
- for feature gating and logging.
- """
- if not is_apple_silicon():
- return False
- # All Apple Silicon chips have AMX blocks. The AMX instruction set
- # is accessed exclusively through Accelerate.framework (not directly
- # by user code), so there is no sysctl key to query. We return True
- # for any arm64 Darwin system.
- return True
-
-
-def has_neural_engine() -> bool:
- """Detect whether the Apple Neural Engine (ANE) is available.
-
- The Neural Engine is present on M1 and later Apple Silicon chips,
- as well as A12 Bionic and later iPhone/iPad SoCs. It is used
- transparently by CoreML when the model and compute unit selection
- allow it (`.appleNeuralEngine`).
-
- On macOS, there is no official sysctl key to query ANE presence,
- so we check for Apple Silicon as a proxy (all M-series chips have one).
- """
- if not is_apple_silicon():
- return False
- # M1 and later all include a Neural Engine. The exact core count
- # varies (M1: 16-core, M2: 16-core, M3: 16-core, M4: 16-core,
- # M1 Pro/Max: 16-core, M2 Pro/Max: 16-core, M3 Pro/Max: 16-core,
- # M4 Pro/Max: 16-core). No user-space API exposes the count.
- return True
-
-
def check_dependencies():
"""Vérifie si les dépendances nécessaires sont installées"""
missing = []
-
+
# Check ffmpeg
if resolve_binary('ffmpeg') is None:
missing.append('ffmpeg')
-
+
# Check colmap
if resolve_binary('colmap') is None:
missing.append('colmap')
diff --git a/app/core/upscale_engine.py b/app/core/upscale_engine.py
index ec3fc13..ca79461 100644
--- a/app/core/upscale_engine.py
+++ b/app/core/upscale_engine.py
@@ -3,9 +3,10 @@
No Python venv required. upscayl-bin is a standalone NCNN-based binary.
"""
-import tempfile
import shutil
+import tempfile
from pathlib import Path
+
from .base_engine import BaseEngine
@@ -38,9 +39,10 @@ def load_model(self, model_id="realesrgan-x4plus", scale=4,
return None
# If the selected model is a fixedâscale model (e.g., contains "x4"),
# and the user requested a different scale, try to pick a matching model.
- # This simple heuristic replaces the trailing "x4" with the desired scale.
+ # This simple heuristic replaces the "x4" token with "x{scale}" â e.g.
+ # realesrgan-x4plus â realesrgan-x2plus (NOT realesrgan-2plus).
if scale != 4 and "x4" in model_id:
- candidate = model_id.replace("x4", str(scale))
+ candidate = model_id.replace("x4", f"x{scale}")
# The actual model may not exist; we keep the original if the candidate
# is not found later by upscayl-bin, but we prefer the adjusted one.
model_id = candidate
diff --git a/app/gui/base_worker.py b/app/gui/base_worker.py
index 00ad694..c5832a7 100644
--- a/app/gui/base_worker.py
+++ b/app/gui/base_worker.py
@@ -1,12 +1,13 @@
from PyQt6.QtCore import QThread, pyqtSignal
+
class BaseWorker(QThread):
"""Classe de base pour les workers avec signaux standardisés"""
log_signal = pyqtSignal(str)
progress_signal = pyqtSignal(int)
status_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool, str)
-
+
def __init__(self):
super().__init__()
self.is_running = True
diff --git a/app/gui/main_window.py b/app/gui/main_window.py
index a99d939..452f15a 100755
--- a/app/gui/main_window.py
+++ b/app/gui/main_window.py
@@ -1,61 +1,61 @@
-import os
-import sys
-import json
from pathlib import Path
-from app.gui.managers import SessionManager, AppLifecycle
-from app.core.system import resolve_project_root
-from PyQt6.QtWidgets import (
- QMainWindow, QWidget, QVBoxLayout, QTabWidget, QMessageBox, QFileDialog, QApplication, QLabel
-)
-from PyQt6.QtGui import QColor
+
from PyQt6.QtCore import Qt
-from app.core.params import ColmapParams
-from app.core.engine import ColmapEngine
-from app.core.i18n import tr, add_language_observer
+from PyQt6.QtGui import QColor
+from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QMessageBox, QTabWidget, QVBoxLayout, QWidget
+from app import VERSION
+from app.core.engine import ColmapEngine
+from app.core.i18n import add_language_observer, tr
+from app.gui.managers import AppLifecycle, SessionManager
from app.gui.styles import set_dark_theme
+from app.gui.tabs.brush_tab import BrushTab
+from app.gui.tabs.cleaner_tab import CleanerTab
from app.gui.tabs.config_tab import ConfigTab
-from app.gui.tabs.params_tab import ParamsTab
+from app.gui.tabs.export_tab import ExportTab
+from app.gui.tabs.extractor_360_tab import Extractor360Tab
+from app.gui.tabs.four_dgs_tab import FourDGSTab
from app.gui.tabs.logs_tab import LogsTab
-from app.gui.tabs.brush_tab import BrushTab
-from app.gui.tabs.sharp_tab import SharpTab
+from app.gui.tabs.params_tab import ParamsTab
from app.gui.tabs.superplat_tab import SuperSplatTab
from app.gui.tabs.upscale_tab import UpscaleTab
-from app.gui.tabs.four_dgs_tab import FourDGSTab
-from app.gui.tabs.extractor_360_tab import Extractor360Tab
-from app.gui.tabs.export_tab import ExportTab
-from app.gui.workers import ColmapWorker, BrushWorker, SharpWorker, FourDGSWorker
-from app.gui.workers import SharpVideoWorker
-from app import VERSION
+from app.gui.workers import BrushWorker, ColmapWorker, FourDGSWorker
+
class ColmapGUI(QMainWindow):
def __init__(self):
super().__init__()
self.worker = None
self.brush_worker = None
- self.sharp_worker = None
-
+ self.fourdgs_worker = None
+
self.session_manager = SessionManager(self)
-
+
self.init_ui()
set_dark_theme(QApplication.instance())
add_language_observer(self.retranslate_ui)
self.session_manager.load()
-
+
+ # Persist settings whenever the user navigates between tabs (debounced),
+ # so a choice like the COLMAP matcher is saved as soon as they move on â
+ # not only on a clean close. Connected after load() to avoid saving the
+ # defaults over the just-loaded session during startup.
+ self.tabs.currentChanged.connect(lambda _idx: self.session_manager.save())
+
def init_ui(self):
"""Initialise l'interface"""
self.setWindowTitle(tr("app_title"))
self.setGeometry(100, 100, 1000, 800)
-
+
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
-
+
self.tabs = QTabWidget()
main_layout.addWidget(self.tabs)
-
+
# Init Tabs â order: Entraînement, Brush, SuperSplat, ML Sharp,
# 4DGS, 360 Extractor, Upscale, Params COLMAP, Logs
self.config_tab = ConfigTab()
@@ -67,9 +67,6 @@ def init_ui(self):
self.superplat_tab = SuperSplatTab()
self.tabs.addTab(self.superplat_tab, tr("tab_supersplat"))
- self.sharp_tab = SharpTab()
- self.tabs.addTab(self.sharp_tab, tr("tab_sharp"))
-
self.four_dgs_tab = FourDGSTab()
self.tabs.addTab(self.four_dgs_tab, tr("tab_four_dgs"))
@@ -82,18 +79,21 @@ def init_ui(self):
self.export_tab = ExportTab()
self.tabs.addTab(self.export_tab, tr("tab_export"))
+ self.cleaner_tab = CleanerTab()
+ self.tabs.addTab(self.cleaner_tab, tr("tab_cleaner", "Nettoyage"))
+
self.params_tab = ParamsTab()
self.tabs.addTab(self.params_tab, tr("tab_params"))
self.logs_tab = LogsTab()
self.tabs.addTab(self.logs_tab, tr("tab_logs"))
-
+
# Discreet Version Label (Status Bar)
version_label = QLabel(f"v{VERSION}")
version_label.setStyleSheet("color: #666666; font-size: 10px; padding: 2px;")
self.statusBar().addPermanentWidget(version_label)
self.statusBar().setStyleSheet("background-color: transparent;")
-
+
# Connect signals
self.config_tab.processRequested.connect(self.process)
self.config_tab.stopRequested.connect(self.stop_process)
@@ -101,17 +101,13 @@ def init_ui(self):
self.config_tab.quitRequested.connect(self.close)
self.config_tab.relaunchRequested.connect(self.restart_application)
self.config_tab.resetRequested.connect(self.reset_factory)
-
+
self.brush_tab.trainRequested.connect(self.train_brush)
self.brush_tab.stopRequested.connect(self.stop_brush)
self.brush_tab.restartRequested.connect(self.restart_application)
-
-
-
- self.sharp_tab.predictRequested.connect(self.run_sharp)
- self.sharp_tab.stopRequested.connect(self.stop_sharp)
self.upscale_tab.log_signal.connect(self.logs_tab.append_log)
+ self.cleaner_tab.log_signal.connect(self.logs_tab.append_log)
# Apply visual hierarchy to utility tabs
self.apply_tab_styling()
@@ -119,7 +115,7 @@ def init_ui(self):
def retranslate_ui(self):
"""Update window title and tab names when language changes"""
self.setWindowTitle(tr("app_title"))
-
+
# Tabs are identified by index, but we can match them with our members
tab_names = {
self.config_tab: tr("tab_config"),
@@ -128,17 +124,17 @@ def retranslate_ui(self):
self.superplat_tab: tr("tab_supersplat"),
self.upscale_tab: tr("tab_upscale"),
self.export_tab: tr("tab_export"),
- self.sharp_tab: tr("tab_sharp"),
+ self.cleaner_tab: tr("tab_cleaner", "Nettoyage"),
self.four_dgs_tab: tr("tab_four_dgs"),
self.extractor_360_tab: tr("tab_360"),
self.logs_tab: tr("tab_logs")
}
-
+
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if widget in tab_names:
self.tabs.setTabText(i, tab_names[widget])
-
+
# Re-apply styling (colors etc) as setTabText might reset them in some Qt versions
self.apply_tab_styling()
@@ -148,16 +144,16 @@ def apply_tab_styling(self):
self.config_tab,
self.upscale_tab,
self.export_tab,
- self.sharp_tab,
+ self.cleaner_tab,
self.four_dgs_tab,
self.extractor_360_tab,
self.logs_tab
]
-
+
tab_bar = self.tabs.tabBar()
# Light gray text for secondary/option tabs
- secondary_color = QColor("#aaaaaa")
-
+ secondary_color = QColor("#aaaaaa")
+
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if widget in secondary_tabs:
@@ -165,19 +161,40 @@ def apply_tab_styling(self):
else:
# Keep main tabs (Params, Brush, SuperSplat) in bright white
tab_bar.setTabTextColor(i, Qt.GlobalColor.white)
-
+
def get_current_params(self):
"""RécupÚre les paramÚtres actuels de l'onglet params et ajoute ceux de config"""
params = self.params_tab.get_params()
params.undistort_images = self.config_tab.get_undistort()
+ params.filter_blurry = self.config_tab.get_blur_filter()
+ params.blur_factor = self.config_tab.get_blur_factor()
+
+ # Robust mode: stabilise COLMAP's bundle adjustment on large scenes to
+ # avoid "Linear solver failure" crashes. Uses only known COLMAP options.
+ if self.config_tab.get_robust_mode():
+ if params.camera_model != "PINHOLE":
+ # Surface the override â it silently replaced the user's chosen
+ # model before. PINHOLE models NO lens distortion: on real-lens
+ # photos, enable undistortion (or disable robust mode) to avoid
+ # blur at the frame edges.
+ self.logs_tab.append_log(
+ f"â¹ïž Mode robuste : modÚle caméra {params.camera_model} â PINHOLE "
+ "(aucune distorsion modélisée). Sur des photos avec distorsion "
+ "optique, activez « images non-distordues » ou décochez le mode "
+ "robuste pour garder un modÚle avec distorsion.")
+ params.camera_model = "PINHOLE"
+ params.ba_refine_extra_params = False
+ params.ba_refine_principal_point = False
+ params.multiple_models = True
+ # NB: robust mode only pre-ticks the blur filter (see config_tab
+ # _on_robust_toggled); it does NOT force it, so unchecking the blur
+ # filter is respected.
return params
def get_upscale_config(self):
"""Combine global upscale settings with the toggle from Config Tab"""
upscale_params = self.upscale_tab.get_params()
upscale_params["active"] = self.config_tab.get_upscale()
- # For Sharp worker, it expects 'upscale' key
- upscale_params["upscale"] = upscale_params["active"]
return upscale_params
def get_extractor_360_config(self):
@@ -185,20 +202,20 @@ def get_extractor_360_config(self):
params = self.extractor_360_tab.get_params()
params["enabled"] = (self.config_tab.get_training_mode() == "360")
return params
-
+
def process(self):
"""Lance le traitement en fonction du mode sélectionné"""
input_path = self.config_tab.get_input_path()
output_path = self.config_tab.get_output_path()
-
+
if not input_path or not output_path:
QMessageBox.critical(self, tr("msg_error"), tr("err_no_paths"))
return
-
+
mode = self.config_tab.get_training_mode()
self.config_tab.set_processing_state(True)
self.logs_tab.clear_log()
-
+
if mode == "gsplat":
self.logs_tab.append_log(tr("msg_processing") + " (Gsplat)")
self.worker = ColmapWorker(
@@ -214,37 +231,12 @@ def process(self):
self.worker.status_signal.connect(self.config_tab.lbl_status.setText)
self.worker.finished_signal.connect(self.on_finished)
self.worker.start()
-
- elif mode == "sharp":
- self.logs_tab.append_log(tr("msg_processing") + " (ML Sharp)")
- sharp_params = self.sharp_tab.get_params()
-
- # Retrieve upscale settings but keep the 'upscale' checkbox value from sharp_tab!
- sharp_upscale_checked = sharp_params.get("upscale", False)
- sharp_params.update(self.get_upscale_config())
- sharp_params["upscale"] = sharp_upscale_checked
-
- input_type = self.config_tab.get_input_type()
- if input_type == "video":
- sharp_params["mode"] = "video"
- if hasattr(self.config_tab, "spin_sharp_skip"):
- sharp_params["skip_frames"] = self.config_tab.spin_sharp_skip.value()
- self.sharp_worker = SharpVideoWorker(input_path, output_path, sharp_params)
- else:
- sharp_params["mode"] = "image"
- self.sharp_worker = SharpWorker(input_path, output_path, sharp_params)
-
- self.sharp_worker.log_signal.connect(self.logs_tab.append_log)
- self.sharp_worker.progress_signal.connect(self.config_tab.progress_bar.setValue)
- self.sharp_worker.status_signal.connect(self.config_tab.lbl_status.setText)
- self.sharp_worker.finished_signal.connect(self.on_sharp_finished)
- self.sharp_worker.start()
-
+
elif mode == "360":
self.logs_tab.append_log(tr("msg_processing") + " (360 Extractor)")
ext_params = self.extractor_360_tab.get_params()
ext_params["enabled"] = True
-
+
self.worker = ColmapWorker(
self.get_current_params(),
input_path, output_path, "video",
@@ -258,7 +250,7 @@ def process(self):
self.worker.status_signal.connect(self.config_tab.lbl_status.setText)
self.worker.finished_signal.connect(self.on_finished)
self.worker.start()
-
+
elif mode == "4dgs":
self.logs_tab.append_log(tr("msg_processing") + " (4DGS)")
self.fourdgs_worker = FourDGSWorker(input_path, output_path, self.config_tab.get_fps())
@@ -267,28 +259,26 @@ def process(self):
self.fourdgs_worker.status_signal.connect(self.config_tab.lbl_status.setText)
self.fourdgs_worker.finished_signal.connect(self.on_finished)
self.fourdgs_worker.start()
-
+
def stop_process(self):
"""Arrête le processus en cours"""
if (self.worker and self.worker.isRunning()) or \
- (self.sharp_worker and self.sharp_worker.isRunning()) or \
(hasattr(self, 'fourdgs_worker') and self.fourdgs_worker and self.fourdgs_worker.isRunning()):
-
+
reply = QMessageBox.question(
self, tr("msg_warning"), tr("confirm_stop"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
-
+
if reply == QMessageBox.StandardButton.Yes:
self.logs_tab.append_log(tr("msg_stopping"))
if self.worker and self.worker.isRunning(): self.worker.stop()
- if self.sharp_worker and self.sharp_worker.isRunning(): self.sharp_worker.stop()
if hasattr(self, 'fourdgs_worker') and self.fourdgs_worker and self.fourdgs_worker.isRunning(): self.fourdgs_worker.stop()
-
+
def on_finished(self, success, message):
"""Fin du traitement"""
self.config_tab.set_processing_state(False)
-
+
if success:
self.logs_tab.append_log(tr("msg_success"))
if self.config_tab.get_auto_brush():
@@ -298,28 +288,34 @@ def on_finished(self, success, message):
QMessageBox.information(self, tr("msg_success"),
f"{message}\n\n{tr('success_open_brush')}")
else:
- if not (self.worker and self.worker.stopped_by_user):
+ # The finished worker may be the COLMAP/360 worker or the 4DGS one.
+ # Suppress the error dialog only when the user pressed Stop.
+ stopped_by_user = (
+ (self.worker and self.worker.stopped_by_user) or
+ (self.fourdgs_worker and self.fourdgs_worker.stopped_by_user)
+ )
+ if not stopped_by_user:
QMessageBox.warning(self, tr("msg_error"), f"{tr('msg_error')}:\n{message}")
-
+
def delete_dataset(self):
"""Supprime le contenu d'un dataset existant"""
output_dir_str = self.config_tab.get_output_path()
project_name = self.config_tab.get_project_name()
-
+
if not output_dir_str:
QMessageBox.warning(self, tr("msg_warning"), tr("err_no_paths"))
return
-
+
output_dir = Path(output_dir_str)
# 1. Target: output_dir/project_name
target_path = output_dir / project_name
-
+
# 2. Fallback: output_dir (if user pointed directly to it)
# We check if it looks like a dataset
if not target_path.exists():
if (output_dir / "database.db").exists() or (output_dir / "sparse").exists():
target_path = output_dir
-
+
if not target_path.exists():
QMessageBox.information(self, "Info", tr("err_path_not_exists"))
return
@@ -330,7 +326,7 @@ def delete_dataset(self):
(target_path / "sparse").exists() or
(target_path / "images").exists()
)
-
+
if not has_dataset:
reply = QMessageBox.question(
self, tr("msg_warning"),
@@ -343,7 +339,7 @@ def delete_dataset(self):
tr("confirm_delete_dataset", str(target_path)),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
-
+
if reply == QMessageBox.StandardButton.Yes:
try:
success, msg = ColmapEngine.delete_project_content(target_path)
@@ -354,20 +350,20 @@ def delete_dataset(self):
QMessageBox.critical(self, tr("msg_error"), f"Erreur: {msg}")
except Exception as e:
QMessageBox.critical(self, tr("msg_error"), f"Impossible de supprimer le dataset:\n{str(e)}")
-
+
def train_brush(self, force_auto=False):
"""Lance l'entrainement Brush"""
brush_params = self.brush_tab.get_params()
project_name = self.config_tab.get_project_name()
-
+
if not force_auto and brush_params.get("independent"):
# Mode Indépendant
input_path_str = brush_params.get("input_path")
-
+
if not input_path_str:
QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier Dataset valide.")
return
-
+
input_path = Path(input_path_str)
if not input_path.exists():
QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier Dataset valide.")
@@ -380,50 +376,60 @@ def train_brush(self, force_auto=False):
else:
output_path = input_path / "checkpoints"
output_path.mkdir(parents=True, exist_ok=True)
-
+
else:
# Mode Automatique (via Colmap output)
colmap_out_root_str = self.config_tab.get_output_path()
-
+
if not colmap_out_root_str:
QMessageBox.critical(self, tr("msg_error"), "Le dossier de sortie racine n'existe pas.")
return
-
+
colmap_out_root = Path(colmap_out_root_str)
if not colmap_out_root.exists():
QMessageBox.critical(self, tr("msg_error"), "Le dossier de sortie racine n'existe pas.")
return
-
+
# Le dataset est dans root/project_name
dataset_path = colmap_out_root / project_name
-
+
if not dataset_path.exists():
QMessageBox.critical(self, tr("msg_error"), f"Le dossier du projet n'existe pas:\n{dataset_path}\nAvez-vous lancé la création du dataset ?")
return
-
+
input_path = dataset_path
+ # If undistortion produced a dense/ dataset (images + sparse), train
+ # on IT â otherwise the undistort step is dead weight: Brush would
+ # read the original distorted images/ + sparse/0 from the project
+ # root and the lens-corrected copies would never be used.
+ dense_dir = dataset_path / "dense"
+ if (dense_dir / "images").is_dir() and (dense_dir / "sparse").is_dir():
+ input_path = dense_dir
+ self.logs_tab.append_log(
+ "Images non-distordues détectées : entraînement sur dense/ "
+ "(images corrigées de la distorsion optique).")
output_path = dataset_path / "checkpoints"
output_path.mkdir(parents=True, exist_ok=True)
-
+
self.brush_tab.set_processing_state(True)
self.logs_tab.append_log(tr("msg_brush_start", str(input_path)))
self.logs_tab.append_log(tr("msg_brush_out", str(output_path)))
-
+
self.brush_worker = BrushWorker(
input_path,
output_path,
brush_params,
project_name=project_name,
)
-
+
self.brush_worker.log_signal.connect(self.logs_tab.append_log)
self.brush_worker.finished_signal.connect(self.on_brush_finished)
-
+
self.brush_worker.start()
-
+
# Focus logs tab
self.tabs.setCurrentWidget(self.logs_tab)
-
+
def stop_brush(self):
"""Arrête Brush"""
if hasattr(self, 'brush_worker') and self.brush_worker and self.brush_worker.isRunning():
@@ -441,104 +447,6 @@ def on_brush_finished(self, success, message):
if not (self.brush_worker and self.brush_worker.stopped_by_user):
QMessageBox.warning(self, tr("brush_error_title"), tr("brush_error_body"))
-
-
- def run_sharp(self):
- """Lance Sharp"""
- params = self.sharp_tab.get_params()
-
- # Merge upscale settings for run_sharp as well
- sharp_upscale_checked = params.get("upscale", False)
- params.update(self.upscale_tab.get_params())
- params["upscale"] = sharp_upscale_checked
-
- mode = params.get("mode", "image")
-
- self.sharp_tab.set_processing_state(True)
- self.logs_tab.append_log(f"--- Lancement Apple ML Sharp (Mode: {mode}) ---")
-
- if mode == "image":
- input_path_str = params.get("input_path")
- output_path_str = params.get("output_path")
-
- if not input_path_str:
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier d'images valide.")
- self.sharp_tab.set_processing_state(False)
- return
-
- input_path = Path(input_path_str)
- if not input_path.exists():
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier d'images valide.")
- self.sharp_tab.set_processing_state(False)
- return
-
- if not output_path_str:
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier de sortie.")
- self.sharp_tab.set_processing_state(False)
- return
-
- output_path = Path(output_path_str)
-
- self.logs_tab.append_log(f"Input: {input_path}")
- self.logs_tab.append_log(f"Output: {output_path}")
-
- self.sharp_worker = SharpWorker(str(input_path), str(output_path), params)
-
- else: # video
- video_path_str = params.get("video_path")
- output_path_str = params.get("video_output_path")
-
- if not video_path_str:
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un fichier video.")
- self.sharp_tab.set_processing_state(False)
- return
-
- video_path = Path(video_path_str)
- if not video_path.exists():
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un fichier video existant.")
- self.sharp_tab.set_processing_state(False)
- return
-
- if not output_path_str:
- QMessageBox.critical(self, tr("msg_error"), "Veuillez selectionner un dossier de sortie.")
- self.sharp_tab.set_processing_state(False)
- return
-
- output_path = Path(output_path_str)
-
- self.logs_tab.append_log(f"Video Input: {video_path}")
- self.logs_tab.append_log(f"Output: {output_path}")
-
- self.sharp_worker = SharpVideoWorker(str(video_path), str(output_path), params)
-
- self.sharp_worker.log_signal.connect(self.logs_tab.append_log)
- if hasattr(self.sharp_worker, "progress_signal"):
- self.sharp_worker.progress_signal.connect(self.config_tab.progress_bar.setValue)
- self.sharp_worker.progress_signal.connect(self.sharp_tab.progress_bar.setValue)
- if hasattr(self.sharp_worker, "status_signal"):
- self.sharp_worker.status_signal.connect(self.config_tab.lbl_status.setText)
- self.sharp_worker.finished_signal.connect(self.on_sharp_finished)
- self.sharp_worker.start()
-
- self.tabs.setCurrentWidget(self.logs_tab)
-
- def stop_sharp(self):
- """Arrête Sharp"""
- if self.sharp_worker and self.sharp_worker.isRunning():
- self.sharp_worker.stop()
- self.logs_tab.append_log("Arrêt de Sharp demandé...")
-
- def on_sharp_finished(self, success, message):
- """Fin Sharp"""
- self.sharp_tab.set_processing_state(False)
- self.config_tab.set_processing_state(False)
- self.logs_tab.append_log(message)
-
- if success:
- QMessageBox.information(self, tr("sharp_done_title"), tr("sharp_done_body"))
- else:
- QMessageBox.warning(self, tr("sharp_error_title"), tr("sharp_error_body"))
-
def restart_application(self):
"""Redémarre l'application."""
AppLifecycle.restart(save_callback=lambda: self.session_manager.save(immediate=True))
@@ -553,5 +461,30 @@ def reset_factory(self, deep=False):
def closeEvent(self, event):
"""Appelé à la fermeture de la fenêtre"""
self.session_manager.save(immediate=True)
+
+ # Stop running worker threads before the window (and the QThreads) are
+ # destroyed â otherwise Qt aborts with "QThread: Destroyed while thread
+ # is still running".
+ for worker in (self.worker, self.brush_worker, self.fourdgs_worker,
+ getattr(self.cleaner_tab, "worker", None)):
+ try:
+ if worker and worker.isRunning():
+ worker.stop()
+ worker.wait(3000)
+ except RuntimeError:
+ pass
+
+ # Qt only delivers closeEvent to the top-level window, not child tabs,
+ # so stop the SuperSplat/preview subprocesses (node/http servers holding
+ # ports) explicitly here.
+ try:
+ self.superplat_tab.stop_server()
+ except (RuntimeError, AttributeError):
+ pass
+ try:
+ self.cleaner_tab.preview_engine.stop_all()
+ except (RuntimeError, AttributeError):
+ pass
+
event.accept()
diff --git a/app/gui/managers.py b/app/gui/managers.py
index 221c0fb..0da9a77 100644
--- a/app/gui/managers.py
+++ b/app/gui/managers.py
@@ -1,13 +1,15 @@
+import json
import logging
import os
-import sys
-import json
import subprocess
+import sys
from pathlib import Path
-from app.core.system import resolve_project_root
-from app.core.params import ColmapParams
-from PyQt6.QtWidgets import QApplication
+
from PyQt6.QtCore import QTimer
+from PyQt6.QtWidgets import QApplication
+
+from app.core.params import ColmapParams
+from app.core.system import resolve_project_root
logger = logging.getLogger(__name__)
@@ -34,18 +36,18 @@ def _do_save(self):
state = {
"language": self.mw.config_tab.combo_lang.currentData(),
}
-
+
tab_mapping = {
"config": self.mw.config_tab,
"colmap_params": self.mw.params_tab,
"brush_params": self.mw.brush_tab,
- "sharp_params": self.mw.sharp_tab,
"upscale_params": self.mw.upscale_tab,
"extractor_360_params": self.mw.extractor_360_tab,
"four_dgs_params": self.mw.four_dgs_tab,
"superplat_params": self.mw.superplat_tab,
+ "cleaner_params": self.mw.cleaner_tab,
}
-
+
for key, tab in tab_mapping.items():
if hasattr(tab, 'get_state'):
state[key] = tab.get_state()
@@ -64,22 +66,22 @@ def load(self):
session_file = self.get_session_file()
if not session_file.exists():
return
-
+
try:
- with open(session_file, 'r') as f:
+ with open(session_file) as f:
state = json.load(f)
-
+
tab_mapping = {
"config": self.mw.config_tab,
"colmap_params": self.mw.params_tab,
"brush_params": self.mw.brush_tab,
- "sharp_params": self.mw.sharp_tab,
"upscale_params": self.mw.upscale_tab,
"extractor_360_params": self.mw.extractor_360_tab,
"four_dgs_params": self.mw.four_dgs_tab,
"superplat_params": self.mw.superplat_tab,
+ "cleaner_params": self.mw.cleaner_tab,
}
-
+
for key, tab in tab_mapping.items():
if key in state:
if hasattr(tab, 'set_state'):
@@ -140,23 +142,23 @@ def restart(save_callback=None):
subprocess.Popen(args, cwd=str(root_dir), **kwargs)
QApplication.quit()
sys.exit(0)
-
+
@staticmethod
def reset_factory(deep=False):
QApplication.quit()
-
+
root_dir = resolve_project_root().resolve()
- run_cmd = root_dir / "run.command"
-
+ run_cmd = root_dir / "run.bat"
+
# Collect deletion targets (relative names only)
- targets_rel = [".venv", ".venv_sharp", ".venv_360"]
-
+ targets_rel = [".venv", ".venv_360", ".venv_4dgs"]
+
if deep:
targets_rel.append("engines")
targets_rel.append("config.json")
-
+
logger.info("Reset Factory %s initié sur: %s", "DEEP" if deep else "LIGHT", root_dir)
-
+
# Validate containment: every target must resolve inside project root
import shutil as _shutil
for rel in list(targets_rel):
@@ -178,7 +180,7 @@ def reset_factory(deep=False):
target.unlink()
except OSError as e:
logger.warning("Reset: could not remove %s â %s", target, e)
-
+
# Also clean deep sync-conflict files
if deep:
for p in root_dir.glob("config.sync-conflict-*"):
@@ -188,12 +190,12 @@ def reset_factory(deep=False):
p.unlink()
except (ValueError, OSError):
pass
-
- # Relaunch via run.command
+
+ # Relaunch via run.bat (Windows)
if run_cmd.exists():
logger.info("Reset: relaunching via %s", run_cmd)
- subprocess.Popen(["open", str(run_cmd)], start_new_session=True)
+ subprocess.Popen(["cmd", "/c", "start", "", str(run_cmd)], cwd=str(root_dir))
else:
- logger.warning("Reset: run.command not found at %s, relaunching main.py", run_cmd)
- subprocess.Popen([sys.executable, str(root_dir / "main.py"), "--gui"], start_new_session=True)
+ logger.warning("Reset: run.bat not found at %s, relaunching main.py", run_cmd)
+ subprocess.Popen([sys.executable, str(root_dir / "main.py"), "--gui"], cwd=str(root_dir))
sys.exit(0)
diff --git a/app/gui/styles.py b/app/gui/styles.py
index 80f4acc..1591f85 100644
--- a/app/gui/styles.py
+++ b/app/gui/styles.py
@@ -1,14 +1,15 @@
-from PyQt6.QtWidgets import QApplication
-from PyQt6.QtGui import QPalette, QColor
from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QColor, QPalette
+from PyQt6.QtWidgets import QApplication
+
def set_dark_theme(app_instance=None):
"""Applique un thÚme sombre"""
if app_instance is None:
app_instance = QApplication.instance()
-
+
dark_palette = QPalette()
-
+
# Couleurs de base
dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
dark_palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
@@ -23,7 +24,7 @@ def set_dark_theme(app_instance=None):
dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
dark_palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
-
+
app_instance.setPalette(dark_palette)
app_instance.setStyleSheet("""
QToolTip {
diff --git a/app/gui/tabs/brush_tab.py b/app/gui/tabs/brush_tab.py
index 1b94e94..6634c0c 100644
--- a/app/gui/tabs/brush_tab.py
+++ b/app/gui/tabs/brush_tab.py
@@ -1,35 +1,51 @@
-import os
import subprocess
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
- QGroupBox, QFormLayout, QSpinBox, QCheckBox, QComboBox, QDoubleSpinBox,
- QScrollArea, QFrame, QMessageBox
-)
from pathlib import Path
+
from PyQt6.QtCore import pyqtSignal
-from app.core.i18n import tr, add_language_observer
+from PyQt6.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QScrollArea,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
+)
+
+from app.core.i18n import add_language_observer, tr
from app.core.system import resolve_binary, resolve_project_root
-from app.gui.widgets.drop_line_edit import DropLineEdit
from app.gui.widgets.dialog_utils import get_existing_directory
+from app.gui.widgets.drop_line_edit import DropLineEdit
+from app.gui.widgets.wheel_guard import install_wheel_guard
+
class BrushTab(QWidget):
"""Onglet de configuration Brush"""
-
+
trainRequested = pyqtSignal()
stopRequested = pyqtSignal()
restartRequested = pyqtSignal()
-
+
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
+ install_wheel_guard(self)
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
# Layout principal (contient Status + Scroll + Boutons)
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(5, 5, 5, 5)
main_layout.setSpacing(5)
-
+
# 1. Status Check (Fixe en haut)
self.bin_path = resolve_binary("brush")
status_layout = QHBoxLayout()
@@ -41,44 +57,44 @@ def init_ui(self):
self.status_lbl.setText(tr("brush_not_found"))
self.status_lbl.setStyleSheet("color: #aa4444; font-weight: bold;")
status_layout.addWidget(self.status_lbl)
-
+
self.btn_reinstall_brush = QPushButton(tr("btn_reinstall_brush"))
self.btn_reinstall_brush.clicked.connect(self.on_reinstall_clicked)
-
+
self.combo_build_mode = QComboBox()
self.combo_build_mode.addItem(tr("brush_build_release"), "release")
self.combo_build_mode.addItem(tr("brush_build_compile"), "source")
-
+
status_layout.addStretch()
lbl_build_mode = QLabel(tr("brush_lbl_build_mode"))
status_layout.addWidget(lbl_build_mode)
status_layout.addWidget(self.combo_build_mode)
status_layout.addWidget(self.btn_reinstall_brush)
-
+
main_layout.addLayout(status_layout)
-
+
# 2. Zone de défilement pour les paramÚtres
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("QScrollArea { background-color: transparent; }")
-
+
container = QWidget()
container.setStyleSheet("background-color: transparent;")
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 10, 0) # Marge droite pour la scrollbar
-
+
# --- Contenu des paramÚtres ---
-
+
# A. Core Parameters Group (ParamÚtres principaux)
self.param_group = QGroupBox(tr("brush_params"))
param_layout = QFormLayout()
-
+
# Total Steps (Moved to top as requested)
self.spin_total_steps = self.create_spin(30000, 1000, 200000, 1000, tr("brush_lbl_steps"))
self.lbl_steps = QLabel(tr("brush_lbl_steps"))
param_layout.addRow(self.lbl_steps, self.spin_total_steps)
-
+
# SH Degree
self.sh_spin = QSpinBox()
self.sh_spin.setRange(1, 4)
@@ -86,20 +102,22 @@ def init_ui(self):
self.sh_spin.setMinimumWidth(100)
self.lbl_sh = QLabel(tr("brush_sh_degree"))
param_layout.addRow(self.lbl_sh, self.sh_spin)
-
+
# Device
self.device_combo = QComboBox()
- self.device_combo.addItems(["mps", "cuda", "cpu", "auto"])
+ self.device_combo.addItems(["cuda", "cpu", "auto"])
self.device_combo.setMinimumWidth(150)
self.lbl_device = QLabel(tr("brush_device"))
param_layout.addRow(self.lbl_device, self.device_combo)
-
+
# Custom Args
self.custom_args_edit = QLineEdit()
- self.custom_args_edit.setPlaceholderText("--refine_pose ...")
+ # NB: only real Brush v0.3.0 flags â an unknown flag makes Brush abort
+ # (--refine-pose, previously suggested here, does not exist in Brush).
+ self.custom_args_edit.setPlaceholderText("--ssim-weight 0.3 --lpips-loss-weight 0.05 ...")
self.lbl_args = QLabel(tr("brush_args"))
param_layout.addRow(self.lbl_args, self.custom_args_edit)
-
+
# Max Resolution Manual
res_layout = QHBoxLayout()
self.max_resolution_spin = QSpinBox()
@@ -108,10 +126,10 @@ def init_ui(self):
self.max_resolution_spin.setSpecialValueText(tr("brush_res_default"))
self.max_resolution_spin.setMinimumWidth(120)
self.max_resolution_spin.setToolTip(tr("brush_tip_res"))
-
+
self.res_warn_label = QLabel(tr("brush_res_warn"))
self.res_warn_label.setStyleSheet("color: #888888; font-size: 11px;")
-
+
self.lbl_res = QLabel(tr("brush_lbl_res"))
res_layout.addWidget(self.lbl_res)
res_layout.addWidget(self.max_resolution_spin)
@@ -123,19 +141,19 @@ def init_ui(self):
self.check_viewer = QCheckBox(tr("brush_viewer"))
self.check_viewer.setChecked(True)
param_layout.addRow("", self.check_viewer)
-
+
self.param_group.setLayout(param_layout)
layout.addWidget(self.param_group)
-
+
# B. Workflow Configuration
# 1. Independent Checkbox
self.check_independent = QCheckBox(tr("check_brush_independent"))
self.check_independent.toggled.connect(self.on_manual_toggled)
layout.addWidget(self.check_independent)
-
+
# 2. Training Mode
workflow_form = QFormLayout()
-
+
self.combo_mode = QComboBox()
self.combo_mode.addItem(tr("brush_mode_new"), "new")
self.combo_mode.addItem(tr("brush_mode_refine"), "refine")
@@ -154,14 +172,14 @@ def init_ui(self):
self.combo_preset.currentIndexChanged.connect(self.apply_preset)
self.lbl_preset = QLabel(tr("brush_lbl_preset"))
workflow_form.addRow(self.lbl_preset, self.combo_preset)
-
+
layout.addLayout(workflow_form)
-
+
# 4. Manual Dataset Path (Visible only if Independent)
self.lbl_paths_group = tr("brush_group_paths")
self.manual_group = QGroupBox(self.lbl_paths_group)
manual_layout = QFormLayout()
-
+
input_layout = QHBoxLayout()
self.input_path = DropLineEdit()
self.input_path.set_allowed_base_dirs([resolve_project_root(), Path.home()])
@@ -172,7 +190,7 @@ def init_ui(self):
input_layout.addWidget(self.btn_browse_input)
self.lbl_dataset = QLabel(tr("brush_lbl_input"))
manual_layout.addRow(self.lbl_dataset, input_layout)
-
+
output_layout = QHBoxLayout()
self.output_path = DropLineEdit()
self.output_path.set_allowed_base_dirs([resolve_project_root(), Path.home()])
@@ -183,12 +201,12 @@ def init_ui(self):
output_layout.addWidget(self.btn_browse_output)
self.lbl_export = QLabel(tr("brush_lbl_output"))
manual_layout.addRow(self.lbl_export, output_layout)
-
+
self.ply_name_edit = QLineEdit()
self.ply_name_edit.setPlaceholderText("output.ply")
self.lbl_ply_manual = QLabel(tr("brush_lbl_ply"))
manual_layout.addRow(self.lbl_ply_manual, self.ply_name_edit)
-
+
self.manual_group.setLayout(manual_layout)
layout.addWidget(self.manual_group)
@@ -202,16 +220,16 @@ def init_ui(self):
self.check_details = QCheckBox(tr("brush_check_details"))
self.check_details.toggled.connect(self.update_visibility)
layout.addWidget(self.check_details)
-
+
# C. Advanced Params Container (Visible only if check_details)
self.details_container = QWidget()
details_layout = QVBoxLayout(self.details_container)
details_layout.setContentsMargins(0, 0, 0, 0)
-
+
# Advanced Params Grid
grid_layout = QVBoxLayout()
-
- # Row 1: Start Iter
+
+ # Row 1: Start Iter
row1 = QHBoxLayout()
self.spin_start_iter = self.create_spin(0, 0, 200000, 1000, tr("brush_lbl_start"))
self.lbl_start = QLabel(tr("brush_lbl_start"))
@@ -219,7 +237,7 @@ def init_ui(self):
row1.addWidget(self.spin_start_iter)
row1.addStretch()
grid_layout.addLayout(row1)
-
+
# Row 2: Refine Every & Growth Stop
row2 = QHBoxLayout()
self.spin_refine = self.create_spin(200, 50, 5000, 50, tr("brush_lbl_refine"))
@@ -232,7 +250,7 @@ def init_ui(self):
row2.addWidget(self.lbl_stop)
row2.addWidget(self.spin_growth_stop)
grid_layout.addLayout(row2)
-
+
# Row 3: Threshold & Fraction
row3 = QHBoxLayout()
self.spin_threshold = self.create_double_spin(0.003, 0.0001, 0.1, 4, 0.0001, tr("brush_lbl_threshold"))
@@ -245,44 +263,44 @@ def init_ui(self):
row3.addWidget(self.lbl_fraction)
row3.addWidget(self.spin_fraction)
grid_layout.addLayout(row3)
-
+
# Row 4: Max Splats & Checkpoint Interval
row4 = QHBoxLayout()
self.spin_max_splats = self.create_spin(10000000, 100000, 100000000, 100000, tr("brush_lbl_max_splats"))
self.spin_checkpoint_interval = self.create_spin(7000, 0, 50000, 1000, tr("brush_lbl_ckpt_interval"))
-
+
self.lbl_max_splats = QLabel(tr("brush_lbl_max_splats"))
self.lbl_ckpt_interval = QLabel(tr("brush_lbl_ckpt_interval"))
-
+
row4.addWidget(self.lbl_max_splats)
row4.addWidget(self.spin_max_splats)
row4.addSpacing(10)
row4.addWidget(self.lbl_ckpt_interval)
row4.addWidget(self.spin_checkpoint_interval)
grid_layout.addLayout(row4)
-
+
details_layout.addLayout(grid_layout)
-
+
layout.addWidget(self.details_container)
layout.addStretch() # Pousse le contenu vers le haut
-
+
# Fin de la zone scrollable
scroll.setWidget(container)
main_layout.addWidget(scroll)
-
+
# 3. Actions (Fixe en bas)
action_layout = QHBoxLayout()
-
+
self.btn_train = QPushButton(tr("btn_train_brush"))
self.btn_train.setMinimumHeight(40)
self.btn_train.setStyleSheet("background-color: #2a82da; color: white; font-weight: bold; border-radius: 4px;")
self.btn_train.clicked.connect(self.trainRequested.emit)
if not self.bin_path:
self.btn_train.setEnabled(False)
-
+
action_layout.addWidget(self.btn_train)
-
+
self.btn_run_standalone = QPushButton(tr("btn_brush_standalone", "Lancer Brush uniquement"))
self.btn_run_standalone.setMinimumHeight(40)
self.btn_run_standalone.setStyleSheet("background-color: #555555; color: white; font-weight: bold; border-radius: 4px;")
@@ -290,16 +308,16 @@ def init_ui(self):
if not self.bin_path:
self.btn_run_standalone.setEnabled(False)
action_layout.addWidget(self.btn_run_standalone)
-
+
self.btn_stop = QPushButton(tr("btn_stop"))
self.btn_stop.setMinimumHeight(40)
self.btn_stop.setStyleSheet("background-color: #555555; color: white; font-weight: bold; border-radius: 4px;")
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stopRequested.emit)
action_layout.addWidget(self.btn_stop)
-
+
main_layout.addLayout(action_layout)
-
+
# Initial state update
self.update_visibility()
@@ -319,7 +337,7 @@ def create_double_spin(self, val, min_v, max_v, decimals, step, tooltip=""):
s.setSingleStep(step)
s.setToolTip(tooltip)
return s
-
+
def apply_preset(self, index):
data = self.combo_preset.currentData()
if data == "default":
@@ -346,7 +364,7 @@ def apply_preset(self, index):
self.spin_threshold.setValue(0.0005) # Aggressive
self.spin_fraction.setValue(0.6)
self.spin_growth_stop.setValue(40000) # Late stop
-
+
def on_manual_toggled(self, checked):
"""Force 'New' mode when entering manual mode"""
if checked:
@@ -368,11 +386,11 @@ def update_visibility(self):
# 1. Manual Path visibility
independent = self.check_independent.isChecked()
self.manual_group.setVisible(independent)
-
+
# 2. Advanced Details visibility
details = self.check_details.isChecked()
self.details_container.setVisible(details)
-
+
def set_processing_state(self, is_processing):
self.btn_train.setEnabled(not is_processing and bool(self.bin_path))
if hasattr(self, 'btn_run_standalone'):
@@ -397,7 +415,7 @@ def get_params(self):
"max_splats": self.spin_max_splats.value(),
"checkpoint_interval": self.spin_checkpoint_interval.value(),
"refine_mode": (self.combo_mode.currentData() == "refine"),
-
+
"sh_degree": self.sh_spin.value(),
"device": self.device_combo.currentText(),
"custom_args": self.custom_args_edit.text(),
@@ -410,14 +428,14 @@ def get_params(self):
"show_details": self.check_details.isChecked(),
"build_mode": self.combo_build_mode.currentData(),
}
-
+
def set_params(self, params):
"""Restaure les parametres"""
if not params: return
-
+
if "total_steps" in params: self.spin_total_steps.setValue(params["total_steps"])
elif "iterations" in params: self.spin_total_steps.setValue(params["iterations"]) # Fallback
-
+
if "start_iter" in params: self.spin_start_iter.setValue(params["start_iter"])
if "refine_every" in params: self.spin_refine.setValue(params["refine_every"])
if "growth_grad_threshold" in params: self.spin_threshold.setValue(params["growth_grad_threshold"])
@@ -428,21 +446,21 @@ def set_params(self, params):
if "refine_mode" in params:
idx = self.combo_mode.findData("refine" if params["refine_mode"] else "new")
if idx >= 0: self.combo_mode.setCurrentIndex(idx)
-
+
if "sh_degree" in params: self.sh_spin.setValue(params["sh_degree"])
if "device" in params: self.device_combo.setCurrentText(params["device"])
if "custom_args" in params: self.custom_args_edit.setText(params["custom_args"])
if "max_resolution" in params: self.max_resolution_spin.setValue(params["max_resolution"])
if "with_viewer" in params: self.check_viewer.setChecked(params["with_viewer"])
if "independent" in params: self.check_independent.setChecked(params["independent"])
-
+
# Details state
if "show_details" in params: self.check_details.setChecked(params["show_details"])
-
+
if "build_mode" in params:
idx = self.combo_build_mode.findData(params["build_mode"])
if idx >= 0: self.combo_build_mode.setCurrentIndex(idx)
-
+
# Manual paths
if "input_path" in params: self.input_path.setText(params["input_path"])
if "output_path" in params: self.output_path.setText(params["output_path"])
@@ -457,7 +475,7 @@ def retranslate_ui(self):
self.status_lbl.setText(tr("brush_detected", self.bin_path))
else:
self.status_lbl.setText(tr("brush_not_found"))
-
+
self.param_group.setTitle(tr("brush_params"))
self.lbl_steps.setText(tr("brush_lbl_steps"))
self.lbl_sh.setText(tr("brush_sh_degree"))
@@ -465,39 +483,39 @@ def retranslate_ui(self):
self.lbl_args.setText(tr("brush_args"))
self.lbl_res.setText(tr("brush_lbl_res"))
self.res_warn_label.setText(tr("brush_res_warn"))
-
+
self.max_resolution_spin.setSpecialValueText(tr("brush_res_default"))
self.max_resolution_spin.setToolTip(tr("brush_tip_res"))
self.check_viewer.setText(tr("brush_viewer"))
self.check_independent.setText(tr("check_brush_independent"))
-
+
self.btn_reinstall_brush.setText(tr("btn_reinstall_brush"))
-
+
self.combo_build_mode.setItemText(0, tr("brush_build_release"))
self.combo_build_mode.setItemText(1, tr("brush_build_compile"))
-
+
# ComboBoxes
self.combo_mode.setItemText(0, tr("brush_mode_new"))
self.combo_mode.setItemText(1, tr("brush_mode_refine"))
self.combo_mode.setToolTip(tr("brush_tip_mode"))
self.lbl_mode.setText(tr("brush_lbl_mode"))
-
+
self.combo_preset.setItemText(0, tr("brush_preset_default"))
self.combo_preset.setItemText(1, tr("brush_preset_fast"))
self.combo_preset.setItemText(2, tr("brush_preset_std"))
self.combo_preset.setItemText(3, tr("brush_preset_dense"))
self.lbl_preset.setText(tr("brush_lbl_preset"))
-
+
self.manual_group.setTitle(tr("brush_group_paths"))
self.lbl_dataset.setText(tr("brush_lbl_input"))
self.lbl_export.setText(tr("brush_lbl_output"))
self.lbl_ply_manual.setText(tr("brush_lbl_ply"))
-
+
self.btn_train.setText(tr("btn_train_brush") if self.btn_train.isEnabled() else tr("btn_stop"))
if hasattr(self, 'btn_run_standalone'):
self.btn_run_standalone.setText(tr("btn_brush_standalone", "Lancer Brush uniquement"))
self.btn_stop.setText(tr("btn_stop"))
-
+
self.check_details.setText(tr("brush_check_details"))
self.lbl_start.setText(tr("brush_lbl_start"))
self.lbl_refine.setText(tr("brush_lbl_refine"))
@@ -509,7 +527,7 @@ def retranslate_ui(self):
def get_state(self):
return self.get_params()
-
+
def set_state(self, state):
self.set_params(state)
@@ -526,13 +544,13 @@ def on_reinstall_clicked(self):
root = resolve_project_root()
brush_bin = root / "engines" / "brush"
brush_version = root / "engines" / "brush.version"
-
+
try:
if brush_bin.exists():
brush_bin.unlink()
if brush_version.exists():
brush_version.unlink()
-
+
QMessageBox.information(
self,
tr("btn_reinstall_brush"),
@@ -543,24 +561,24 @@ def on_reinstall_clicked(self):
QMessageBox.critical(self, tr("msg_error"), f"Erreur lors de la suppression de Brush: {e}")
def run_standalone(self):
- from app.core.system import resolve_binary
from app.core.brush_engine import BrushEngine
-
+ from app.core.system import resolve_binary
+
bin_path = resolve_binary("brush")
if not bin_path:
QMessageBox.critical(self, tr("msg_error"), tr("err_brush_missing", "Exécutable brush introuvable."))
return
-
+
input_path = self.input_path.text().strip()
output_path = self.output_path.text().strip()
-
+
if not input_path:
QMessageBox.warning(self, tr("msg_warning"), tr("err_brush_no_input", "Veuillez spécifier un dossier de dataset."))
return
if not output_path:
QMessageBox.warning(self, tr("msg_warning"), tr("err_brush_no_output", "Veuillez spécifier un dossier de sortie."))
return
-
+
try:
# Use BrushEngine.build_command() to respect build_mode, sh_degree, refine_every, etc.
engine = BrushEngine()
diff --git a/app/gui/tabs/cleaner_tab.py b/app/gui/tabs/cleaner_tab.py
new file mode 100644
index 0000000..6370987
--- /dev/null
+++ b/app/gui/tabs/cleaner_tab.py
@@ -0,0 +1,279 @@
+"""Onglet Nettoyage : charge un .ply, le nettoie (floaters/ciel/bruit),
+prévisualise dans SuperSplat puis sauvegarde."""
+import shutil
+import webbrowser
+from pathlib import Path
+from urllib.parse import quote
+
+from PyQt6.QtCore import QTimer, pyqtSignal
+from PyQt6.QtWidgets import (
+ QComboBox,
+ QDoubleSpinBox,
+ QFileDialog,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QProgressBar,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from app.core.i18n import add_language_observer, tr
+from app.core.ply_cleaner import resolve_params
+from app.core.superplat_engine import SuperSplatEngine
+from app.gui.widgets.dialog_utils import get_open_file_name
+from app.gui.widgets.drop_line_edit import DropLineEdit
+
+
+class CleanerTab(QWidget):
+ """Nettoyage automatique d'un Gaussian Splat (.ply)."""
+
+ log_signal = pyqtSignal(str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.preview_engine = SuperSplatEngine()
+ self.worker = None
+ self.cleaned_path = None
+ self.init_ui()
+ add_language_observer(self.retranslate_ui)
+
+ def init_ui(self):
+ layout = QVBoxLayout(self)
+
+ self.lbl_info = QLabel(tr(
+ "cleaner_info",
+ "Nettoie un splat .ply : retire le ciel, les « floaters », les splats "
+ "transparents et les points isolés. L'original n'est jamais modifié."
+ ))
+ self.lbl_info.setWordWrap(True)
+ layout.addWidget(self.lbl_info)
+
+ # ââ Fichier d'entrée âââââââââââââââââââââââââââââââââââââââââââââ
+ self.file_group = QGroupBox(tr("cleaner_group_file", "Fichier .ply"))
+ file_layout = QHBoxLayout()
+ self.input_path = DropLineEdit()
+ self.input_path.setPlaceholderText(tr("placeholder_ply", "Chemin vers un fichier .ply"))
+ self.input_path.fileDropped.connect(lambda p: self.input_path.setText(p.split("|")[0]))
+ file_layout.addWidget(self.input_path)
+ self.btn_browse = QPushButton(tr("btn_browse"))
+ self.btn_browse.clicked.connect(self.browse_input)
+ file_layout.addWidget(self.btn_browse)
+ self.file_group.setLayout(file_layout)
+ layout.addWidget(self.file_group)
+
+ # ââ Réglages âââââââââââââââââââââââââââââââââââââââââââââââââââââ
+ self.settings_group = QGroupBox(tr("cleaner_group_settings", "Réglages de nettoyage"))
+ settings_layout = QFormLayout()
+
+ self.combo_strength = QComboBox()
+ self.combo_strength.addItem(tr("blur_light", "Léger"), "light")
+ self.combo_strength.addItem(tr("blur_medium", "Moyen"), "medium")
+ self.combo_strength.addItem(tr("blur_strong", "Fort"), "strong")
+ self.combo_strength.setCurrentIndex(1)
+ self.combo_strength.currentIndexChanged.connect(self._apply_preset_to_fields)
+ self.lbl_strength = QLabel(tr("cleaner_strength", "Sévérité :"))
+ settings_layout.addRow(self.lbl_strength, self.combo_strength)
+
+ self.spin_opacity = QDoubleSpinBox()
+ self.spin_opacity.setRange(0.0, 1.0)
+ self.spin_opacity.setSingleStep(0.01)
+ self.spin_opacity.setDecimals(2)
+ self.lbl_opacity = QLabel(tr("cleaner_opacity", "Opacité min :"))
+ settings_layout.addRow(self.lbl_opacity, self.spin_opacity)
+
+ self.spin_scale = QDoubleSpinBox()
+ self.spin_scale.setRange(80.0, 100.0)
+ self.spin_scale.setSingleStep(0.1)
+ self.spin_scale.setDecimals(1)
+ self.lbl_scale = QLabel(tr("cleaner_scale", "Taille (percentile gardé) :"))
+ settings_layout.addRow(self.lbl_scale, self.spin_scale)
+
+ self.spin_outlier = QDoubleSpinBox()
+ self.spin_outlier.setRange(80.0, 100.0)
+ self.spin_outlier.setSingleStep(0.1)
+ self.spin_outlier.setDecimals(1)
+ self.lbl_outlier = QLabel(tr("cleaner_outlier", "Distance (percentile gardé) :"))
+ settings_layout.addRow(self.lbl_outlier, self.spin_outlier)
+
+ self.settings_group.setLayout(settings_layout)
+ layout.addWidget(self.settings_group)
+ self._apply_preset_to_fields()
+
+ # ââ Actions ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+ action_layout = QHBoxLayout()
+ self.btn_clean = QPushButton(tr("cleaner_btn_clean", "Analyser & Nettoyer"))
+ self.btn_clean.setMinimumHeight(40)
+ self.btn_clean.setStyleSheet("background-color: #2a82da; color: white; font-weight: bold; border-radius: 4px;")
+ self.btn_clean.clicked.connect(self.run_clean)
+ action_layout.addWidget(self.btn_clean)
+
+ self.btn_preview = QPushButton(tr("cleaner_btn_preview", "Prévisualiser"))
+ self.btn_preview.setMinimumHeight(40)
+ self.btn_preview.setEnabled(False)
+ self.btn_preview.clicked.connect(self.preview)
+ action_layout.addWidget(self.btn_preview)
+
+ self.btn_save = QPushButton(tr("cleaner_btn_save", "Sauvegarder sousâŠ"))
+ self.btn_save.setMinimumHeight(40)
+ self.btn_save.setEnabled(False)
+ self.btn_save.clicked.connect(self.save_as)
+ action_layout.addWidget(self.btn_save)
+ layout.addLayout(action_layout)
+
+ self.progress = QProgressBar()
+ self.progress.setRange(0, 0) # busy indicator
+ self.progress.setVisible(False)
+ layout.addWidget(self.progress)
+
+ self.stats_label = QLabel("")
+ self.stats_label.setWordWrap(True)
+ self.stats_label.setStyleSheet("color: #cccccc;")
+ layout.addWidget(self.stats_label)
+
+ layout.addStretch()
+
+ # ââ Settings helpers ââââââââââââââââââââââââââââââââââââââââââââââââ
+ def _apply_preset_to_fields(self):
+ params = resolve_params(self.combo_strength.currentData())
+ self.spin_opacity.setValue(params["opacity_min"])
+ self.spin_scale.setValue(params["scale_pct"])
+ self.spin_outlier.setValue(params["outlier_pct"])
+
+ def _overrides(self):
+ return {
+ "opacity_min": self.spin_opacity.value(),
+ "scale_pct": self.spin_scale.value(),
+ "outlier_pct": self.spin_outlier.value(),
+ }
+
+ # ââ Actions âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+ def browse_input(self):
+ path, _ = get_open_file_name(
+ self, tr("select_ply", "Sélectionner un fichier PLY"), "",
+ "Gaussian Splat (*.ply);;Tous (*.*)"
+ )
+ if path:
+ self.input_path.setText(path)
+
+ def run_clean(self):
+ from app.gui.workers import CleanerWorker
+
+ in_str = self.input_path.text().strip()
+ if not in_str:
+ QMessageBox.warning(self, tr("msg_warning"), tr("cleaner_no_input", "Sélectionnez un fichier .ply."))
+ return
+ in_path = Path(in_str)
+ if not in_path.exists():
+ QMessageBox.critical(self, tr("msg_error"), tr("cleaner_input_missing", "Fichier introuvable."))
+ return
+
+ self.cleaned_path = in_path.with_name(f"{in_path.stem}_cleaned.ply")
+ self.btn_clean.setEnabled(False)
+ self.btn_preview.setEnabled(False)
+ self.btn_save.setEnabled(False)
+ self.progress.setVisible(True)
+ self.stats_label.setText(tr("status_cleaning", "Nettoyage du splat..."))
+
+ self.worker = CleanerWorker(
+ str(in_path), str(self.cleaned_path),
+ strength=self.combo_strength.currentData(),
+ overrides=self._overrides(),
+ )
+ self.worker.log_signal.connect(self.log_signal.emit)
+ self.worker.finished_signal.connect(self.on_clean_done)
+ self.worker.start()
+
+ def on_clean_done(self, success, message):
+ self.progress.setVisible(False)
+ self.btn_clean.setEnabled(True)
+ if success and self.worker and self.worker.stats:
+ s = self.worker.stats
+ pct = (100.0 * s["removed"] / s["total"]) if s["total"] else 0.0
+ self.stats_label.setText(
+ tr("cleaner_stats", "Résultat :") + "\n"
+ + f" ⢠{s['kept']:,} / {s['total']:,} splats conservés "
+ + f"({s['removed']:,} retirés, {pct:.1f} %)\n"
+ + f" ⢠transparence : {s['removed_opacity']:,} "
+ + f"taille : {s['removed_scale']:,} "
+ + f"isolés : {s['removed_outlier']:,}\n"
+ + f" â {self.cleaned_path}"
+ )
+ self.btn_preview.setEnabled(True)
+ self.btn_save.setEnabled(True)
+ else:
+ self.stats_label.setText(f"â {message}")
+ QMessageBox.critical(self, tr("msg_error"), message)
+
+ def preview(self):
+ if not self.cleaned_path or not self.cleaned_path.exists():
+ return
+ port, data_port = 3000, 8000
+ ok, msg = self.preview_engine.start_supersplat(port)
+ if not ok:
+ QMessageBox.critical(self, tr("msg_error"), f"SuperSplat: {msg}")
+ return
+ ok_data, msg_data = self.preview_engine.start_data_server(str(self.cleaned_path.parent), data_port)
+ if not ok_data:
+ QMessageBox.critical(self, tr("msg_error"), f"Data Server: {msg_data}")
+ self.preview_engine.stop_all()
+ return
+ data_url = f"http://localhost:{data_port}/{self.cleaned_path.name}"
+ url = f"http://localhost:{port}?load={quote(data_url, safe=':/')}"
+ QTimer.singleShot(1500, lambda: webbrowser.open(url))
+ self.log_signal.emit(tr("cleaner_preview_msg", f"Aperçu : {url}"))
+
+ def save_as(self):
+ if not self.cleaned_path or not self.cleaned_path.exists():
+ return
+ dest, _ = QFileDialog.getSaveFileName(
+ self, tr("cleaner_btn_save", "Sauvegarder sousâŠ"),
+ str(self.cleaned_path), "Gaussian Splat (*.ply)"
+ )
+ if not dest:
+ return
+ try:
+ shutil.copy2(str(self.cleaned_path), dest)
+ self.log_signal.emit(tr("cleaner_saved", f"Sauvegardé : {dest}"))
+ QMessageBox.information(self, tr("msg_success"), f"{dest}")
+ except OSError as e:
+ QMessageBox.critical(self, tr("msg_error"), str(e))
+
+ def closeEvent(self, event):
+ self.preview_engine.stop_all()
+ super().closeEvent(event)
+
+ # ââ Persistence âââââââââââââââââââââââââââââââââââââââââââââââââââââ
+ def get_state(self):
+ return {
+ "input_path": self.input_path.text(),
+ "strength": self.combo_strength.currentData(),
+ }
+
+ def set_state(self, state):
+ if not state:
+ return
+ if "input_path" in state:
+ self.input_path.setText(state["input_path"])
+ if "strength" in state:
+ idx = self.combo_strength.findData(state["strength"])
+ if idx >= 0:
+ self.combo_strength.setCurrentIndex(idx)
+ self._apply_preset_to_fields()
+
+ def retranslate_ui(self):
+ self.lbl_info.setText(tr("cleaner_info"))
+ self.file_group.setTitle(tr("cleaner_group_file", "Fichier .ply"))
+ self.input_path.setPlaceholderText(tr("placeholder_ply"))
+ self.btn_browse.setText(tr("btn_browse"))
+ self.settings_group.setTitle(tr("cleaner_group_settings", "Réglages de nettoyage"))
+ self.lbl_strength.setText(tr("cleaner_strength", "Sévérité :"))
+ self.lbl_opacity.setText(tr("cleaner_opacity", "Opacité min :"))
+ self.lbl_scale.setText(tr("cleaner_scale", "Taille (percentile gardé) :"))
+ self.lbl_outlier.setText(tr("cleaner_outlier", "Distance (percentile gardé) :"))
+ self.btn_clean.setText(tr("cleaner_btn_clean", "Analyser & Nettoyer"))
+ self.btn_preview.setText(tr("cleaner_btn_preview", "Prévisualiser"))
+ self.btn_save.setText(tr("cleaner_btn_save", "Sauvegarder sousâŠ"))
diff --git a/app/gui/tabs/config_tab.py b/app/gui/tabs/config_tab.py
index 98cfcb9..d4884a1 100644
--- a/app/gui/tabs/config_tab.py
+++ b/app/gui/tabs/config_tab.py
@@ -1,14 +1,29 @@
+
+from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QLineEdit,
- QGroupBox, QRadioButton, QSpinBox, QCheckBox, QMessageBox, QComboBox,
- QProgressBar, QButtonGroup, QDialog, QFrame
+ QButtonGroup,
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QProgressBar,
+ QPushButton,
+ QRadioButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
-from PyQt6.QtCore import pyqtSignal, Qt
-from pathlib import Path
-from app.core.i18n import tr, set_language, get_current_lang, add_language_observer
-from app.core.system import resolve_project_root
-from app.gui.widgets.drop_line_edit import DropLineEdit
+
+from app.core.i18n import add_language_observer, get_current_lang, set_language, tr
from app.gui.widgets.dialog_utils import get_existing_directory, get_open_file_names
+from app.gui.widgets.drop_line_edit import DropLineEdit
+from app.gui.widgets.wheel_guard import install_wheel_guard
+
class ResetDialog(QDialog):
"""Dialogue de réinitialisation personnalisé pour de meilleurs boutons"""
@@ -17,16 +32,16 @@ def __init__(self, parent=None):
self.setWindowTitle(tr("btn_reset"))
self.setMinimumWidth(450)
self.result_deep = None
-
+
layout = QVBoxLayout(self)
layout.setSpacing(20)
-
+
# Titre / Description
lbl = QLabel(tr("confirm_reset"))
lbl.setWordWrap(True)
lbl.setStyleSheet("font-weight: bold; font-size: 14px; margin-bottom: 10px;")
layout.addWidget(lbl)
-
+
# Bouton Light
self.btn_light = QPushButton(tr("reset_light"))
self.btn_light.setMinimumHeight(60)
@@ -42,13 +57,13 @@ def __init__(self, parent=None):
}
QPushButton:hover { background-color: #2980b9; }
""")
-
+
desc_light = QLabel(tr("reset_light_desc"))
desc_light.setStyleSheet("color: #7f8c8d; font-size: 12px; margin-top: -15px; margin-left: 20px;")
-
+
layout.addWidget(self.btn_light)
layout.addWidget(desc_light)
-
+
# Bouton Deep
self.btn_deep = QPushButton(tr("reset_deep"))
self.btn_deep.setMinimumHeight(60)
@@ -64,36 +79,36 @@ def __init__(self, parent=None):
}
QPushButton:hover { background-color: #c0392b; }
""")
-
+
desc_deep = QLabel(tr("reset_deep_desc"))
desc_deep.setStyleSheet("color: #7f8c8d; font-size: 12px; margin-top: -15px; margin-left: 20px;")
-
+
layout.addWidget(self.btn_deep)
layout.addWidget(desc_deep)
-
+
# Séparateur
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
-
+
# Bouton Annuler
self.btn_cancel = QPushButton(tr("btn_cancel", tr("btn_quit", "Annuler")))
self.btn_cancel.setMinimumHeight(40)
layout.addWidget(self.btn_cancel)
-
+
# Connections
self.btn_light.clicked.connect(lambda: self.done_with(False))
self.btn_deep.clicked.connect(lambda: self.done_with(True))
self.btn_cancel.clicked.connect(self.reject)
-
+
def done_with(self, deep):
self.result_deep = deep
self.accept()
class ConfigTab(QWidget):
"""Onglet de configuration principale"""
-
+
# Signaux pour les actions globales qui necessitent l'orchestration du Main Window
processRequested = pyqtSignal()
stopRequested = pyqtSignal()
@@ -101,21 +116,22 @@ class ConfigTab(QWidget):
quitRequested = pyqtSignal()
relaunchRequested = pyqtSignal()
resetRequested = pyqtSignal(bool) # True if deep reset requested
-
+
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
+ install_wheel_guard(self)
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
layout = QVBoxLayout(self)
-
+
# Header + Language
header_layout = QHBoxLayout()
self.header_label = QLabel(tr("app_title"))
self.header_label.setStyleSheet("font-size: 18px; font-weight: bold;")
self.header_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
-
+
# Language Selector
self.combo_lang = QComboBox()
self.combo_lang.addItem("Français", "fr")
@@ -128,28 +144,28 @@ def init_ui(self):
self.combo_lang.addItem("äžæ", "zh")
self.combo_lang.addItem("æ¥æ¬èª", "ja")
self.combo_lang.setMinimumWidth(100)
-
+
# Select current language
current = get_current_lang()
index = self.combo_lang.findData(current)
if index >= 0:
self.combo_lang.setCurrentIndex(index)
-
+
self.combo_lang.currentIndexChanged.connect(self.change_language)
-
+
header_layout.addStretch(1)
header_layout.addWidget(self.header_label, 2)
header_layout.addStretch(1)
self.lbl_lang_change = QLabel(tr("lang_change") + ":")
header_layout.addWidget(self.lbl_lang_change)
header_layout.addWidget(self.combo_lang)
-
+
layout.addLayout(header_layout)
-
+
# Groupe d'entrée
self.input_group = QGroupBox(tr("group_input"))
input_layout = QVBoxLayout()
-
+
# Nom du Projet
name_layout = QHBoxLayout()
self.lbl_proj_name = QLabel(tr("label_project_name"))
@@ -163,28 +179,27 @@ def init_ui(self):
mode_layout = QHBoxLayout()
self.lbl_mode = QLabel(tr("label_training_mode"))
mode_layout.addWidget(self.lbl_mode)
-
+
self.combo_mode = QComboBox()
self.combo_mode.addItem(tr("mode_gsplat"), "gsplat")
- self.combo_mode.addItem(tr("mode_sharp"), "sharp")
self.combo_mode.addItem(tr("mode_360"), "360")
self.combo_mode.addItem(tr("mode_4dgs"), "4dgs")
mode_layout.addWidget(self.combo_mode)
mode_layout.addStretch()
input_layout.addLayout(mode_layout)
-
+
# Type d'entrée (images/vidéo) - Actif uniquement pour gsplat
type_layout = QHBoxLayout()
self.lbl_type = QLabel(tr("label_type"))
type_layout.addWidget(self.lbl_type)
-
+
self.type_button_group = QButtonGroup(self)
-
+
self.radio_images = QRadioButton(tr("radio_images"))
self.radio_images.setChecked(True)
self.type_button_group.addButton(self.radio_images)
type_layout.addWidget(self.radio_images)
-
+
self.radio_video = QRadioButton(tr("radio_video"))
self.type_button_group.addButton(self.radio_video)
type_layout.addWidget(self.radio_video)
@@ -196,35 +211,35 @@ def init_ui(self):
self.source_select_layout = QHBoxLayout()
self.lbl_source_select = QLabel(tr("label_source_select"))
self.source_select_layout.addWidget(self.lbl_source_select)
-
+
self.source_button_group = QButtonGroup(self)
-
+
self.radio_source_folder = QRadioButton(tr("radio_source_folder"))
self.radio_source_folder.setChecked(True)
self.source_button_group.addButton(self.radio_source_folder)
self.source_select_layout.addWidget(self.radio_source_folder)
-
+
self.radio_source_files = QRadioButton(tr("radio_source_files"))
self.source_button_group.addButton(self.radio_source_files)
self.source_select_layout.addWidget(self.radio_source_files)
self.source_select_layout.addStretch()
input_layout.addLayout(self.source_select_layout)
-
+
# Chemin
path_layout = QHBoxLayout()
self.lbl_path = QLabel(tr("label_path"))
path_layout.addWidget(self.lbl_path)
self.input_path = DropLineEdit()
- self.input_path.set_allowed_base_dirs([resolve_project_root(), Path.home()])
+ # Allow dropping files from any drive/folder (desktop tool on the user's own files).
self.input_path.fileDropped.connect(self.on_input_dropped)
path_layout.addWidget(self.input_path)
self.btn_browse_input = QPushButton(tr("btn_browse"))
self.btn_browse_input.clicked.connect(self.browse_input)
path_layout.addWidget(self.btn_browse_input)
input_layout.addLayout(path_layout)
-
-
+
+
# FPS (pour vidéo)
fps_layout = QHBoxLayout()
self.label_fps = QLabel(tr("label_fps"))
@@ -233,39 +248,30 @@ def init_ui(self):
self.fps_spin.setValue(5)
fps_layout.addWidget(self.label_fps)
fps_layout.addWidget(self.fps_spin)
-
- self.lbl_sharp_skip = QLabel(tr("sharp_lbl_frame_skip"))
- self.spin_sharp_skip = QSpinBox()
- self.spin_sharp_skip.setMinimum(1)
- self.spin_sharp_skip.setMaximum(100)
- self.spin_sharp_skip.setValue(1)
- fps_layout.addWidget(self.lbl_sharp_skip)
- fps_layout.addWidget(self.spin_sharp_skip)
-
+
fps_layout.addStretch()
input_layout.addLayout(fps_layout)
-
+
self.input_group.setLayout(input_layout)
layout.addWidget(self.input_group)
-
+
# Update visibility based on mode
self.combo_mode.currentIndexChanged.connect(self.update_ui_state)
-
+
# Groupe de sortie
self.output_group = QGroupBox(tr("group_output"))
output_layout = QVBoxLayout()
-
+
path_out_layout = QHBoxLayout()
self.lbl_out_path = QLabel(tr("label_out_path"))
path_out_layout.addWidget(self.lbl_out_path)
self.output_path = DropLineEdit()
- self.output_path.set_allowed_base_dirs([resolve_project_root(), Path.home()])
path_out_layout.addWidget(self.output_path)
self.btn_browse_output = QPushButton(tr("btn_browse"))
self.btn_browse_output.clicked.connect(self.browse_output)
path_out_layout.addWidget(self.btn_browse_output)
output_layout.addLayout(path_out_layout)
-
+
delete_layout = QHBoxLayout()
self.btn_delete_dataset = QPushButton(tr("btn_delete"))
self.btn_delete_dataset.clicked.connect(self.deleteDatasetRequested.emit)
@@ -273,31 +279,63 @@ def init_ui(self):
delete_layout.addWidget(self.btn_delete_dataset)
delete_layout.addStretch()
output_layout.addLayout(delete_layout)
-
+
# Auto Brush (reste dans Output)
self.chk_auto_brush = QCheckBox(tr("check_auto_brush"))
self.chk_auto_brush.setChecked(False)
output_layout.addWidget(self.chk_auto_brush)
-
+
self.output_group.setLayout(output_layout)
layout.addWidget(self.output_group)
-
+
# Nouveau Groupe: Options
self.options_group = QGroupBox(tr("group_options"))
options_layout = QVBoxLayout()
-
+
self.undistort_check = QCheckBox(tr("check_undistort"))
self.undistort_check.setChecked(False)
options_layout.addWidget(self.undistort_check)
-
+
self.chk_upscale = QCheckBox(tr("upscale_check_colmap", "Enable Upscale (upscayl-ncnn)"))
self.chk_upscale.setChecked(False)
options_layout.addWidget(self.chk_upscale)
-
+
+ # Blur filtering (discard out-of-focus / motion-blurred frames)
+ blur_layout = QHBoxLayout()
+ self.chk_blur_filter = QCheckBox(tr("blur_filter_check", "Filtrer les images floues"))
+ self.chk_blur_filter.setChecked(False)
+ blur_layout.addWidget(self.chk_blur_filter)
+ self.combo_blur_strength = QComboBox()
+ # (label, blur_factor) â higher factor = more aggressive removal
+ self.combo_blur_strength.addItem(tr("blur_light", "Léger"), 0.5)
+ self.combo_blur_strength.addItem(tr("blur_medium", "Moyen"), 0.7)
+ self.combo_blur_strength.addItem(tr("blur_strong", "Fort"), 0.85)
+ self.combo_blur_strength.setCurrentIndex(1)
+ self.combo_blur_strength.setMinimumWidth(110)
+ blur_layout.addWidget(self.combo_blur_strength)
+ blur_layout.addStretch()
+ options_layout.addLayout(blur_layout)
+ self.chk_blur_filter.toggled.connect(self.combo_blur_strength.setEnabled)
+ self.combo_blur_strength.setEnabled(False)
+
+ # Robust mode for large scenes â stabilises COLMAP's bundle adjustment
+ # (PINHOLE camera, no extra-param/principal-point refinement, multiple
+ # models allowed) and forces blur filtering on.
+ self.chk_robust = QCheckBox(tr("robust_check", "Mode robuste (grandes scÚnes / anti-crash COLMAP)"))
+ self.chk_robust.setChecked(False)
+ self.chk_robust.setToolTip(tr(
+ "robust_tooltip",
+ "Pour les grosses scÚnes (milliers d'images) : évite les plantages de "
+ "bundle adjustment (caméra PINHOLE, pas de raffinement distorsion, "
+ "modÚles multiples) et active le filtrage des images floues."
+ ))
+ self.chk_robust.toggled.connect(self._on_robust_toggled)
+ options_layout.addWidget(self.chk_robust)
+
options_layout.addStretch()
self.options_group.setLayout(options_layout)
layout.addWidget(self.options_group)
-
+
# Progress Bar & Status (hidden by default)
progress_layout = QVBoxLayout()
self.lbl_status = QLabel("")
@@ -305,65 +343,65 @@ def init_ui(self):
self.lbl_status.setStyleSheet("color: #aaaaaa; font-style: italic;")
self.lbl_status.setVisible(False)
progress_layout.addWidget(self.lbl_status)
-
+
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
self.progress_bar.setVisible(False)
progress_layout.addWidget(self.progress_bar)
-
+
layout.addLayout(progress_layout)
-
+
# Boutons d'action
action_layout = QHBoxLayout()
-
+
self.btn_process = QPushButton(tr("btn_process"))
self.btn_process.setMinimumHeight(50)
self.btn_process.setStyleSheet("font-size: 16px; font-weight: bold; background-color: #2a82da; color: white;")
self.btn_process.clicked.connect(self.processRequested.emit)
action_layout.addWidget(self.btn_process)
-
+
self.btn_stop = QPushButton(tr("btn_stop"))
self.btn_stop.setMinimumHeight(50)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stopRequested.emit)
action_layout.addWidget(self.btn_stop)
-
+
layout.addLayout(action_layout)
-
+
layout.addStretch()
-
+
# Boutons discrets pour Quitter et Relancer
restart_layout = QHBoxLayout()
restart_layout.addStretch()
-
+
self.btn_quit = QPushButton(tr("btn_quit"))
self.btn_quit.setStyleSheet("QPushButton { border: none; color: #888888; font-size: 10px; } QPushButton:hover { color: #ff5555; }")
self.btn_quit.setFlat(True)
self.btn_quit.clicked.connect(self.quitRequested.emit)
restart_layout.addWidget(self.btn_quit)
-
+
restart_layout.addSpacing(10)
-
+
self.btn_relaunch = QPushButton(tr("btn_relaunch"))
self.btn_relaunch.setStyleSheet("QPushButton { border: none; color: #888888; font-size: 10px; } QPushButton:hover { color: #ffffff; }")
self.btn_relaunch.setFlat(True)
self.btn_relaunch.clicked.connect(self.relaunchRequested.emit)
restart_layout.addWidget(self.btn_relaunch)
-
+
restart_layout.addSpacing(10)
-
+
self.btn_reset = QPushButton(tr("btn_reset"))
self.btn_reset.setStyleSheet("QPushButton { border: none; color: #884444; font-size: 10px; font-weight: bold; } QPushButton:hover { color: #ff0000; }")
self.btn_reset.setFlat(True)
self.btn_reset.clicked.connect(self.on_reset_clicked)
restart_layout.addWidget(self.btn_reset)
-
+
layout.addLayout(restart_layout)
-
+
layout.addStretch()
-
+
# Initial status update
self.update_ui_state()
@@ -371,7 +409,7 @@ def change_language(self, index):
"""Change la langue et demande redémarrage"""
lang_code = self.combo_lang.itemData(index)
current = get_current_lang()
-
+
if lang_code != current:
set_language(lang_code)
# No restart needed anymore!
@@ -380,47 +418,42 @@ def update_ui_state(self):
"""Met à jour la visibilité selon le mode d'entraînement"""
mode = self.get_training_mode()
is_gsplat = (mode == "gsplat")
- is_sharp = (mode == "sharp")
-
- self.lbl_type.setVisible(is_gsplat or is_sharp)
- self.radio_images.setVisible(is_gsplat or is_sharp)
- self.radio_video.setVisible(is_gsplat or is_sharp)
-
- is_video = ((is_gsplat or is_sharp) and self.radio_video.isChecked()) or (mode in ["360", "4dgs"])
- is_regular_video = is_video and not is_sharp
- is_sharp_video = is_sharp and self.radio_video.isChecked()
-
+
+ self.lbl_type.setVisible(is_gsplat)
+ self.radio_images.setVisible(is_gsplat)
+ self.radio_video.setVisible(is_gsplat)
+
+ is_video = (is_gsplat and self.radio_video.isChecked()) or (mode in ["360", "4dgs"])
+
# FPS input is visible only if we can give video sources in theory
- self.fps_spin.setVisible(is_regular_video)
- self.label_fps.setVisible(is_regular_video)
-
- if hasattr(self, 'lbl_sharp_skip'):
- self.lbl_sharp_skip.setVisible(is_sharp_video)
- self.spin_sharp_skip.setVisible(is_sharp_video)
-
- # Source selection type visibility (Gsplat and Sharp only)
- show_source_select = (mode in ["gsplat", "sharp"])
+ self.fps_spin.setVisible(is_video)
+ self.label_fps.setVisible(is_video)
+
+ # Source selection type visibility (Gsplat only)
+ show_source_select = (mode == "gsplat")
self.lbl_source_select.setVisible(show_source_select)
self.radio_source_folder.setVisible(show_source_select)
self.radio_source_files.setVisible(show_source_select)
-
- if is_sharp_video:
- self.radio_source_folder.setEnabled(False)
- if self.radio_source_folder.isChecked():
- self.radio_source_files.setChecked(True)
- else:
- self.radio_source_folder.setEnabled(True)
-
+ self.radio_source_folder.setEnabled(True)
+
# Undistort check makes sense for gsplat
self.undistort_check.setVisible(mode == "gsplat")
-
- # Upscale check for gsplat, sharp, 360
- self.chk_upscale.setVisible(mode in ["gsplat", "sharp", "360"])
+
+ # Upscale check for gsplat, 360
+ self.chk_upscale.setVisible(mode in ["gsplat", "360"])
+
+ # Blur filter applies to any mode that produces image frames
+ blur_visible = mode in ["gsplat", "360", "4dgs"]
+ self.chk_blur_filter.setVisible(blur_visible)
+ self.combo_blur_strength.setVisible(blur_visible)
+
+ # Robust COLMAP mode is relevant to the gsplat pipeline
+ self.chk_robust.setVisible(mode == "gsplat")
def browse_input(self):
"""Parcourir l'entrée en fonction du mode sélectionné"""
mode = self.get_training_mode()
-
+
if mode == "gsplat":
# Uses the radio button choice instead of popup
if self.radio_source_folder.isChecked():
@@ -436,21 +469,6 @@ def browse_input(self):
if paths:
self.input_path.setText("|".join(paths))
- elif mode == "sharp":
- # Uses the radio button choice instead of popup
- if self.radio_source_folder.isChecked():
- path = get_existing_directory(self, tr("group_input"))
- if path:
- self.input_path.setText(path)
- else:
- filters = "Images (*.jpg *.jpeg *.png);;Tous (*.*)" if self.radio_images.isChecked() else "Vidéos (*.mp4 *.mov *.avi *.mkv *.MP4 *.MOV);;Tous (*.*)"
- paths, _ = get_open_file_names(
- self, tr("group_input"),
- "", filters
- )
- if paths:
- self.input_path.setText(paths[0]) # Enforce single image/video for sharp if not folder
-
elif mode == "360":
# Exactly one video
paths, _ = get_open_file_names(
@@ -467,7 +485,7 @@ def browse_input(self):
path = get_existing_directory(self, tr("group_input"))
if path:
self.input_path.setText(path)
-
+
def browse_output(self):
"""Parcourir la sortie"""
path = get_existing_directory(self, tr("group_output"))
@@ -484,55 +502,72 @@ def on_input_changed(self, path):
"""Met à jour l'UI en fonction du mode/chemin"""
if not path: return
mode = self.get_training_mode()
-
+
# Constraint checks
if mode == "360" and "|" in str(path):
QMessageBox.warning(self, tr("msg_warning"), tr("err_360_single_video", "Le mode 360 ne supporte qu'une vidéo."))
path = str(path).split("|")[0]
self.input_path.setText(path)
-
+
# Getters/Setters pour la configuration
def get_input_path(self): return self.input_path.text()
def set_input_path(self, path): self.input_path.setText(path)
- def get_project_name(self):
+ def get_project_name(self):
text = self.input_project_name.text().strip()
return text if text else "UntitledProject"
-
+
def set_project_name(self, name): self.input_project_name.setText(name)
-
+
def get_output_path(self): return self.output_path.text()
def set_output_path(self, path): self.output_path.setText(path)
-
+
def get_fps(self): return self.fps_spin.value()
def set_fps(self, fps): self.fps_spin.setValue(fps)
-
+
def get_training_mode(self): return self.combo_mode.currentData()
def set_training_mode(self, mode):
idx = self.combo_mode.findData(mode)
if idx >= 0:
self.combo_mode.setCurrentIndex(idx)
-
+
def get_input_type(self):
"""Returns 'video' or 'images' based on mode and radio buttons"""
mode = self.get_training_mode()
- if mode in ["gsplat", "sharp"]:
+ if mode == "gsplat":
return "video" if self.radio_video.isChecked() else "images"
elif mode in ["360", "4dgs"]:
return "video"
return "images"
-
+
def get_undistort(self): return self.undistort_check.isChecked()
def set_undistort(self, val): self.undistort_check.setChecked(val)
-
+
def get_auto_brush(self): return self.chk_auto_brush.isChecked()
def set_auto_brush(self, val): self.chk_auto_brush.setChecked(val)
def get_upscale(self): return self.chk_upscale.isChecked()
def set_upscale(self, val): self.chk_upscale.setChecked(val)
+ def get_blur_filter(self): return self.chk_blur_filter.isChecked()
+ def set_blur_filter(self, val): self.chk_blur_filter.setChecked(bool(val))
+
+ def get_blur_factor(self): return float(self.combo_blur_strength.currentData())
+ def set_blur_factor(self, val):
+ idx = self.combo_blur_strength.findData(float(val))
+ if idx >= 0:
+ self.combo_blur_strength.setCurrentIndex(idx)
+
+ def get_robust_mode(self): return self.chk_robust.isChecked()
+ def set_robust_mode(self, val): self.chk_robust.setChecked(bool(val))
+
+ def _on_robust_toggled(self, checked):
+ """Robust mode also turns on blur filtering (so blurry frames aren't used)."""
+ if checked and not self.chk_blur_filter.isChecked():
+ self.chk_blur_filter.setChecked(True)
+
+
-
def set_processing_state(self, processing=True):
"""Bloque ou débloque les composants UI pendant l'entrainement"""
# Disable/Enable inputs
@@ -542,7 +577,7 @@ def set_processing_state(self, processing=True):
self.chk_upscale.setEnabled(not processing)
self.btn_delete_dataset.setEnabled(not processing)
self.combo_lang.setEnabled(not processing)
-
+
# Toggle Action Buttons
self.btn_process.setEnabled(not processing)
self.btn_process.setText(tr("btn_process") if not processing else tr("msg_processing"))
@@ -550,13 +585,13 @@ def set_processing_state(self, processing=True):
self.btn_process.setStyleSheet("font-size: 16px; font-weight: bold; background-color: #aaaaaa; color: white;")
else:
self.btn_process.setStyleSheet("font-size: 16px; font-weight: bold; background-color: #2a82da; color: white;")
-
+
self.btn_stop.setEnabled(processing)
-
+
# Toggle Progress Visibility
self.progress_bar.setVisible(processing)
self.lbl_status.setVisible(processing)
-
+
if processing:
self.progress_bar.setValue(0)
self.lbl_status.setText(tr("msg_processing", "Traitement en cours..."))
@@ -577,13 +612,16 @@ def get_state(self):
"undistort": self.get_undistort(),
"auto_brush": self.get_auto_brush(),
"upscale": self.get_upscale(),
+ "blur_filter": self.get_blur_filter(),
+ "blur_factor": self.get_blur_factor(),
+ "robust_mode": self.get_robust_mode(),
"lang": self.combo_lang.currentData()
}
def set_state(self, state):
"""Restaure l'état depuis le dictionnaire"""
if not state: return
-
+
if "project_name" in state: self.set_project_name(state["project_name"])
if "training_mode" in state: self.set_training_mode(state["training_mode"])
if "input_path" in state: self.set_input_path(state["input_path"])
@@ -592,13 +630,19 @@ def set_state(self, state):
if "undistort" in state: self.set_undistort(state["undistort"])
if "auto_brush" in state: self.set_auto_brush(state["auto_brush"])
if "upscale" in state: self.set_upscale(state["upscale"])
-
+ # Restore robust BEFORE blur: set_robust_mode(True) pre-ticks blur via
+ # _on_robust_toggled, so blur_filter must be applied last to honor the
+ # value the user actually saved (otherwise blur is always re-checked).
+ if "robust_mode" in state: self.set_robust_mode(state["robust_mode"])
+ if "blur_factor" in state: self.set_blur_factor(state["blur_factor"])
+ if "blur_filter" in state: self.set_blur_filter(state["blur_filter"])
+
# Lang is special, might require restart if changed, so we just set combo if it matches
# or we let the main app handle valid lang loading.
if "lang" in state:
idx = self.combo_lang.findData(state["lang"])
if idx >= 0: self.combo_lang.setCurrentIndex(idx)
-
+
self.update_ui_state()
def on_reset_clicked(self):
@@ -614,27 +658,29 @@ def retranslate_ui(self):
self.lbl_proj_name.setText(tr("label_project_name"))
self.lbl_mode.setText(tr("label_training_mode"))
self.combo_mode.setItemText(0, tr("mode_gsplat"))
- self.combo_mode.setItemText(1, tr("mode_sharp"))
- self.combo_mode.setItemText(2, tr("mode_360"))
- self.combo_mode.setItemText(3, tr("mode_4dgs"))
+ self.combo_mode.setItemText(1, tr("mode_360"))
+ self.combo_mode.setItemText(2, tr("mode_4dgs"))
self.lbl_type.setText(tr("label_type"))
self.radio_images.setText(tr("radio_images"))
self.radio_video.setText(tr("radio_video"))
self.lbl_path.setText(tr("label_path"))
self.btn_browse_input.setText(tr("btn_browse"))
self.label_fps.setText(tr("label_fps"))
- if hasattr(self, 'lbl_sharp_skip'):
- self.lbl_sharp_skip.setText(tr("sharp_lbl_frame_skip"))
-
+
self.output_group.setTitle(tr("group_output"))
self.lbl_out_path.setText(tr("label_out_path"))
self.btn_browse_output.setText(tr("btn_browse"))
self.btn_delete_dataset.setText(tr("btn_delete"))
self.chk_auto_brush.setText(tr("check_auto_brush"))
-
+
self.options_group.setTitle(tr("group_options"))
self.undistort_check.setText(tr("check_undistort"))
-
+ self.chk_blur_filter.setText(tr("blur_filter_check", "Filtrer les images floues"))
+ self.combo_blur_strength.setItemText(0, tr("blur_light", "Léger"))
+ self.combo_blur_strength.setItemText(1, tr("blur_medium", "Moyen"))
+ self.combo_blur_strength.setItemText(2, tr("blur_strong", "Fort"))
+ self.chk_robust.setText(tr("robust_check", "Mode robuste (grandes scÚnes / anti-crash COLMAP)"))
+
self.btn_process.setText(tr("btn_process") if self.btn_process.isEnabled() else tr("btn_stop"))
self.btn_stop.setText(tr("btn_stop"))
self.btn_quit.setText(tr("btn_quit"))
diff --git a/app/gui/tabs/export_tab.py b/app/gui/tabs/export_tab.py
index dacce38..facb04c 100644
--- a/app/gui/tabs/export_tab.py
+++ b/app/gui/tabs/export_tab.py
@@ -1,16 +1,28 @@
-import os
from pathlib import Path
+
+from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox,
- QGroupBox, QFormLayout, QLineEdit, QListWidget, QListWidgetItem,
- QCheckBox, QMessageBox, QProgressBar, QFrame, QSpinBox, QDoubleSpinBox,
- QStackedWidget, QSizePolicy
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QProgressBar,
+ QPushButton,
+ QStackedWidget,
+ QVBoxLayout,
+ QWidget,
)
-from PyQt6.QtCore import pyqtSignal, Qt
-from app.core.i18n import tr, add_language_observer
+
from app.core.export_engine import ExportEngine
+from app.core.i18n import add_language_observer, tr
+from app.gui.widgets.dialog_utils import get_existing_directory, get_open_file_name
from app.gui.widgets.drop_line_edit import DropLineEdit
-from app.gui.widgets.dialog_utils import get_open_file_name, get_existing_directory
class ExportTab(QWidget):
@@ -215,7 +227,7 @@ def on_format_changed(self, index):
def get_export_options(self) -> dict:
"""Retourne les options d'export selon le format sélectionné."""
fmt = self.combo_format.currentData()
-
+
if fmt == "spz":
return {
'quantize_positions': self.spz_quantize.isChecked(),
@@ -296,6 +308,7 @@ def retranslate_ui(self):
if __name__ == "__main__":
import sys
+
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
window = ExportTab()
diff --git a/app/gui/tabs/extractor_360_tab.py b/app/gui/tabs/extractor_360_tab.py
index 1b92e71..c0b2a1d 100644
--- a/app/gui/tabs/extractor_360_tab.py
+++ b/app/gui/tabs/extractor_360_tab.py
@@ -1,23 +1,33 @@
+from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QCheckBox, QComboBox, QSpinBox, QDoubleSpinBox, QGroupBox,
- QMessageBox, QProgressBar
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QProgressBar,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
-from PyQt6.QtCore import pyqtSignal, QThread
-from app.core.i18n import tr, add_language_observer
from app.core.extractor_360_engine import Extractor360Engine
-from app.gui.widgets.drop_line_edit import DropLineEdit
+from app.core.i18n import add_language_observer, tr
from app.gui.widgets.dialog_utils import get_existing_directory, get_open_file_name
+from app.gui.widgets.drop_line_edit import DropLineEdit
+
class InstallWorker(QThread):
finished_signal = pyqtSignal(bool, str)
-
+
def __init__(self, engine, install=True):
super().__init__()
self.engine = engine
self.install_mode = install
-
+
def run(self):
try:
if self.install_mode:
@@ -37,29 +47,29 @@ def __init__(self):
self.init_ui()
self.update_ui_state()
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
layout = QVBoxLayout(self)
-
+
# Header
self.lbl_header = QLabel(tr("360_header"))
self.lbl_header.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 10px;")
layout.addWidget(self.lbl_header)
-
+
self.lbl_desc = QLabel(tr("360_desc"))
self.lbl_desc.setWordWrap(True)
self.lbl_desc.setStyleSheet("color: #888; margin-bottom: 15px;")
layout.addWidget(self.lbl_desc)
-
+
# Activation
self.check_activate = QCheckBox(tr("360_activate"))
self.check_activate.clicked.connect(self.on_activate_clicked)
layout.addWidget(self.check_activate)
-
+
# Params Group
self.group_params = QGroupBox(tr("360_group_params"))
param_layout = QVBoxLayout(self.group_params)
-
+
# Interval
h_interval = QHBoxLayout()
self.lbl_interval = QLabel(tr("360_lbl_interval"))
@@ -71,7 +81,7 @@ def init_ui(self):
self.spin_interval.setToolTip(tr("360_tip_interval"))
h_interval.addWidget(self.spin_interval)
param_layout.addLayout(h_interval)
-
+
# Resolution
h_res = QHBoxLayout()
self.lbl_res = QLabel(tr("360_lbl_resolution"))
@@ -83,7 +93,7 @@ def init_ui(self):
self.spin_res.setToolTip(tr("360_tip_res"))
h_res.addWidget(self.spin_res)
param_layout.addLayout(h_res)
-
+
# Layout
h_layout = QHBoxLayout()
self.lbl_layout = QLabel(tr("360_lbl_layout"))
@@ -95,7 +105,7 @@ def init_ui(self):
self.combo_layout.setToolTip(tr("360_tip_layout"))
h_layout.addWidget(self.combo_layout)
param_layout.addLayout(h_layout)
-
+
# Camera Count
h_cam = QHBoxLayout()
self.lbl_cam = QLabel(tr("360_lbl_cameras"))
@@ -106,7 +116,7 @@ def init_ui(self):
self.spin_cam.setToolTip(tr("360_tip_cameras"))
h_cam.addWidget(self.spin_cam)
param_layout.addLayout(h_cam)
-
+
# Quality
h_qua = QHBoxLayout()
self.lbl_quality = QLabel(tr("360_lbl_quality"))
@@ -117,7 +127,7 @@ def init_ui(self):
self.spin_quality.setToolTip(tr("360_tip_quality"))
h_qua.addWidget(self.spin_quality)
param_layout.addLayout(h_qua)
-
+
# Format
h_fmt = QHBoxLayout()
self.lbl_format = QLabel(tr("360_lbl_format"))
@@ -127,25 +137,25 @@ def init_ui(self):
self.combo_format.setToolTip(tr("360_tip_format"))
h_fmt.addWidget(self.combo_format)
param_layout.addLayout(h_fmt)
-
+
layout.addWidget(self.group_params)
-
+
# AI Group
self.group_ai = QGroupBox(tr("360_group_ai"))
ai_layout = QVBoxLayout(self.group_ai)
-
+
self.check_ai_mask = QCheckBox(tr("360_check_ai_mask"))
self.check_ai_mask.setToolTip(tr("360_tip_ai_mask"))
ai_layout.addWidget(self.check_ai_mask)
-
+
self.check_ai_skip = QCheckBox(tr("360_check_ai_skip"))
self.check_ai_skip.setToolTip(tr("360_tip_ai_skip"))
ai_layout.addWidget(self.check_ai_skip)
-
+
self.check_adaptive = QCheckBox(tr("360_check_adaptive"))
self.check_adaptive.setToolTip(tr("360_tip_adaptive"))
ai_layout.addWidget(self.check_adaptive)
-
+
h_thresh = QHBoxLayout()
self.lbl_threshold = QLabel(tr("360_lbl_threshold"))
h_thresh.addWidget(self.lbl_threshold)
@@ -156,13 +166,13 @@ def init_ui(self):
self.spin_threshold.setToolTip(tr("360_tip_threshold"))
h_thresh.addWidget(self.spin_threshold)
ai_layout.addLayout(h_thresh)
-
+
layout.addWidget(self.group_ai)
-
+
# Standalone Extraction Group
self.group_standalone = QGroupBox(tr("360_btn_extract_only"))
standalone_layout = QVBoxLayout(self.group_standalone)
-
+
# Input Path
input_layout = QHBoxLayout()
self.lbl_input = QLabel(tr("360_lbl_input"))
@@ -173,7 +183,7 @@ def init_ui(self):
self.btn_browse_input.clicked.connect(self.browse_video)
input_layout.addWidget(self.btn_browse_input)
standalone_layout.addLayout(input_layout)
-
+
# Output Path
output_layout = QHBoxLayout()
self.lbl_output = QLabel(tr("360_lbl_output"))
@@ -184,25 +194,25 @@ def init_ui(self):
self.btn_browse_output.clicked.connect(self.browse_output)
output_layout.addWidget(self.btn_browse_output)
standalone_layout.addLayout(output_layout)
-
+
# Progress Bar
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
standalone_layout.addWidget(self.progress_bar)
-
+
# Action Button
self.btn_extract = QPushButton(tr("360_btn_extract_only"))
self.btn_extract.setFixedHeight(40)
self.btn_extract.setStyleSheet("background-color: #27ae60; color: white; font-weight: bold;")
self.btn_extract.clicked.connect(self.run_standalone_extraction)
standalone_layout.addWidget(self.btn_extract)
-
+
layout.addWidget(self.group_standalone)
-
+
# Status
self.lbl_status = QLabel("")
layout.addWidget(self.lbl_status)
-
+
layout.addStretch()
def browse_video(self):
@@ -218,15 +228,15 @@ def browse_output(self):
def run_standalone_extraction(self):
input_path = self.input_edit.text()
output_dir = self.output_edit.text()
-
+
if not input_path or not output_dir:
QMessageBox.warning(self, tr("msg_warning"), tr("err_no_paths"))
return
-
+
self.set_processing_state(True)
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
-
+
from app.gui.workers import Extractor360Worker
self.extract_worker = Extractor360Worker(
input_path=input_path,
@@ -234,7 +244,7 @@ def run_standalone_extraction(self):
params=self.get_params(),
engine=self.engine
)
-
+
self.extract_worker.progress_signal.connect(self.progress_bar.setValue)
self.extract_worker.finished_signal.connect(self.on_extraction_finished)
self.extract_worker.start()
@@ -256,18 +266,18 @@ def set_processing_state(self, is_processing):
self.btn_extract.setText(tr("msg_processing"))
else:
self.btn_extract.setText(tr("360_btn_extract_only"))
-
+
def update_ui_state(self):
installed = self.engine.is_installed()
-
+
# Block signals to prevent recursion if we changed checks programmatically
self.check_activate.blockSignals(True)
self.check_activate.setChecked(installed)
self.check_activate.blockSignals(False)
-
+
self.group_params.setEnabled(installed)
self.group_ai.setEnabled(installed)
-
+
if installed:
self.lbl_status.setText(tr("360_status_ready"))
self.lbl_status.setStyleSheet("color: green;")
@@ -278,12 +288,12 @@ def update_ui_state(self):
def on_activate_clicked(self):
current_state = self.check_activate.isChecked() # State AFTER click
installed = self.engine.is_installed()
-
+
if current_state and not installed:
# User wants to activate -> Install
reply = QMessageBox.question(
- self,
- tr("msg_warning"),
+ self,
+ tr("msg_warning"),
tr("360_install_msg"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
@@ -291,11 +301,11 @@ def on_activate_clicked(self):
self.start_install(True)
else:
self.check_activate.setChecked(False) # Revert
-
+
elif not current_state and installed:
# User wants to deactivate -> Uninstall?
reply = QMessageBox.question(
- self,
+ self,
tr("msg_warning"),
tr("360_uninstall_msg"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
@@ -310,49 +320,49 @@ def start_install(self, install):
self.group_ai.setEnabled(False)
self.check_activate.setEnabled(False)
self.lbl_status.setText(tr("360_status_installing") if install else tr("upscale_uninstalling"))
-
+
self.install_worker = InstallWorker(self.engine, install)
self.install_worker.finished_signal.connect(self.on_install_finished)
self.install_worker.start()
-
+
def on_install_finished(self, success, message):
self.check_activate.setEnabled(True)
if success:
QMessageBox.information(self, tr("msg_success"), message)
else:
QMessageBox.critical(self, tr("msg_error"), message)
-
+
self.update_ui_state()
def set_params(self, params):
if not params: return
-
+
if "enabled" in params: self.check_activate.setChecked(params["enabled"])
if "interval" in params: self.spin_interval.setValue(params["interval"])
if "resolution" in params: self.spin_res.setValue(params["resolution"])
if "camera_count" in params: self.spin_cam.setValue(params["camera_count"])
if "quality" in params: self.spin_quality.setValue(params["quality"])
-
+
if "layout" in params:
idx = self.combo_layout.findData(params["layout"])
if idx >= 0: self.combo_layout.setCurrentIndex(idx)
-
+
if "format" in params:
self.combo_format.setCurrentText(params["format"])
-
+
if "ai_mask" in params: self.check_ai_mask.setChecked(params["ai_mask"])
if "ai_skip" in params: self.check_ai_skip.setChecked(params["ai_skip"])
if "adaptive" in params: self.check_adaptive.setChecked(params["adaptive"])
if "motion_threshold" in params: self.spin_threshold.setValue(params["motion_threshold"])
-
+
self.update_ui_state()
def get_state(self):
return self.get_params()
-
+
def set_state(self, state):
self.set_params(state)
-
+
self.update_ui_state()
def get_params(self):
@@ -392,7 +402,7 @@ def retranslate_ui(self):
self.spin_quality.setToolTip(tr("360_tip_quality"))
self.lbl_format.setText(tr("360_lbl_format"))
self.combo_format.setToolTip(tr("360_tip_format"))
-
+
self.group_ai.setTitle(tr("360_group_ai"))
self.check_ai_mask.setText(tr("360_check_ai_mask"))
self.check_ai_mask.setToolTip(tr("360_tip_ai_mask"))
@@ -402,12 +412,12 @@ def retranslate_ui(self):
self.check_adaptive.setToolTip(tr("360_tip_adaptive"))
self.lbl_threshold.setText(tr("360_lbl_threshold"))
self.spin_threshold.setToolTip(tr("360_tip_threshold"))
-
+
self.group_standalone.setTitle(tr("360_btn_extract_only"))
self.lbl_input.setText(tr("360_lbl_input"))
self.btn_browse_input.setText(tr("btn_browse"))
self.lbl_output.setText(tr("360_lbl_output"))
self.btn_browse_output.setText(tr("btn_browse"))
self.btn_extract.setText(tr("360_btn_extract_only") if self.btn_extract.isEnabled() else tr("msg_processing"))
-
+
self.update_ui_state()
diff --git a/app/gui/tabs/four_dgs_tab.py b/app/gui/tabs/four_dgs_tab.py
index b298e1d..a084f0c 100644
--- a/app/gui/tabs/four_dgs_tab.py
+++ b/app/gui/tabs/four_dgs_tab.py
@@ -1,16 +1,29 @@
-from pathlib import Path
-import sys
import subprocess
+import sys
+from pathlib import Path
+
+from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGroupBox,
- QFormLayout, QCheckBox, QSpinBox, QMessageBox, QTextEdit, QApplication, QProgressDialog
+ QApplication,
+ QCheckBox,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QProgressDialog,
+ QPushButton,
+ QSpinBox,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
)
-from PyQt6.QtCore import Qt
-from app.core.i18n import tr, add_language_observer
+
+from app.core.i18n import add_language_observer, tr
from app.core.system import resolve_project_root
-from app.gui.widgets.drop_line_edit import DropLineEdit
from app.gui.widgets.dialog_utils import get_existing_directory
+from app.gui.widgets.drop_line_edit import DropLineEdit
from app.gui.workers import FourDGSWorker
@@ -47,7 +60,7 @@ def init_ui(self):
self.lbl_header = QLabel(tr("four_dgs_header"))
self.lbl_header.setStyleSheet("font-weight: bold; font-size: 14px; margin-bottom: 5px;")
layout.addWidget(self.lbl_header)
-
+
self.lbl_desc = QLabel(tr("four_dgs_desc"))
self.lbl_desc.setWordWrap(True)
self.lbl_desc.setStyleSheet("color: #aaa; margin-bottom: 10px;")
@@ -101,20 +114,20 @@ def init_ui(self):
self.btn_run.setStyleSheet("background-color: #2ecc71; color: white; font-weight: bold;")
self.btn_run.clicked.connect(self.run_process)
btn_layout.addWidget(self.btn_run)
-
+
self.btn_stop = QPushButton(tr("four_dgs_btn_stop"))
self.btn_stop.setFixedHeight(40)
self.btn_stop.setStyleSheet("background-color: #e74c3c; color: white; font-weight: bold;")
self.btn_stop.clicked.connect(self.stop_process)
self.btn_stop.setEnabled(False)
btn_layout.addWidget(self.btn_stop)
-
+
self.btn_colmap = QPushButton(tr("four_dgs_btn_colmap"))
self.btn_colmap.setFixedHeight(40)
self.btn_colmap.setStyleSheet("background-color: #3498db; color: white; font-weight: bold;")
self.btn_colmap.clicked.connect(self.run_colmap_only)
btn_layout.addWidget(self.btn_colmap)
-
+
layout.addLayout(btn_layout)
# Logs
@@ -126,7 +139,7 @@ def init_ui(self):
# Initial State
self.controls_group.setEnabled(False)
self.btn_run.setEnabled(False)
-
+
# Check if already active/installed (Check ns-process-data in dedicated venv)
ns_path = _get_venv_4dgs_ns_path()
if ns_path.exists():
@@ -140,12 +153,12 @@ def on_toggle_activation(self):
ns_path = _get_venv_4dgs_ns_path()
if not ns_path.exists():
reply = QMessageBox.question(
- self,
- "Installation Requise",
+ self,
+ "Installation Requise",
tr("msg_install_nerf"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
-
+
if reply == QMessageBox.StandardButton.Yes:
self.install_dependencies()
else:
@@ -160,18 +173,18 @@ def on_toggle_activation(self):
def install_dependencies(self):
"""Install nerfstudio in a dedicated venv (.venv_4dgs)."""
venv_python = _get_venv_4dgs_python()
-
+
progress = QProgressDialog("Installation de Nerfstudio (venv dédié)...", "Annuler", 0, 0, self)
progress.setWindowModality(Qt.WindowModality.WindowModal)
progress.show()
QApplication.processEvents()
-
+
try:
# Create venv if it doesn't exist
if not venv_python.exists():
self._log_to_view("Création du venv .venv_4dgs...")
subprocess.check_call([sys.executable, "-m", "venv", str(venv_python.parent.parent)])
-
+
# Upgrade pip first
self._log_to_view("Mise à jour de pip...")
subprocess.check_call([str(venv_python), "-m", "pip", "install", "--upgrade", "pip"])
@@ -185,7 +198,7 @@ def install_dependencies(self):
cmd = [str(venv_python), "-m", "pip", "install", "nerfstudio"]
self._log_to_view(f"Exécution: {' '.join(cmd)}")
subprocess.check_call(cmd)
-
+
QMessageBox.information(self, tr("msg_success"), tr("four_dgs_install_ok", "Installation terminée. Veuillez redémarrer l'application."))
self.controls_group.setEnabled(True)
self.btn_run.setEnabled(True)
@@ -194,7 +207,7 @@ def install_dependencies(self):
self.chk_activate.setChecked(False)
finally:
progress.close()
-
+
def _log_to_view(self, text):
"""Helper to append log line to the text view."""
self.log_view.append(text)
@@ -215,7 +228,7 @@ def browse_output(self):
def run_process(self):
src = self.input_edit.text().strip()
dst = self.output_edit.text().strip()
-
+
if not src or not dst:
QMessageBox.warning(self, tr("msg_warning"), tr("err_no_paths"))
return
@@ -227,7 +240,7 @@ def run_process(self):
self.btn_run.setEnabled(False)
self.btn_stop.setEnabled(True)
self.log_view.clear()
-
+
self.worker = FourDGSWorker(src, dst, self.fps_spin.value())
self.worker.log_signal.connect(self.append_log)
self.worker.finished_signal.connect(self.on_process_finished)
@@ -247,9 +260,9 @@ def run_colmap_only(self):
self.btn_colmap.setEnabled(False)
self.btn_stop.setEnabled(True)
self.log_view.clear()
-
+
self.append_log(tr("four_dgs_msg_colmap_start", dst))
-
+
# Use existing worker but with a flag? Or just call engine directly if synchronous?
# Better use worker to avoid blocking.
self.worker = FourDGSWorker(None, dst, self.fps_spin.value()) # None for videos_dir signals colmap only
@@ -298,7 +311,7 @@ def set_params(self, params):
def get_state(self):
return self.get_params()
-
+
def set_state(self, state):
self.set_params(state)
diff --git a/app/gui/tabs/logs_tab.py b/app/gui/tabs/logs_tab.py
index b9f61ad..9d9fa9b 100644
--- a/app/gui/tabs/logs_tab.py
+++ b/app/gui/tabs/logs_tab.py
@@ -1,55 +1,55 @@
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTextEdit, QMessageBox
-)
from PyQt6.QtGui import QFont
-from app.core.i18n import tr, add_language_observer
+from PyQt6.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget
+
+from app.core.i18n import add_language_observer, tr
from app.gui.widgets.dialog_utils import get_save_file_name
+
class LogsTab(QWidget):
"""Onglet des logs"""
-
+
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
layout = QVBoxLayout(self)
-
+
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setFont(QFont("Monaco", 10))
layout.addWidget(self.log_text)
-
+
btn_layout = QHBoxLayout()
self.btn_clear = QPushButton(tr("btn_clear_log"))
self.btn_clear.clicked.connect(self.log_text.clear)
btn_layout.addWidget(self.btn_clear)
-
+
self.btn_save_log = QPushButton(tr("btn_save_log"))
self.btn_save_log.clicked.connect(self.save_logs)
btn_layout.addWidget(self.btn_save_log)
btn_layout.addStretch()
-
+
layout.addLayout(btn_layout)
-
+
def append_log(self, message):
"""Ajoute au log"""
self.log_text.append(message)
cursor = self.log_text.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
self.log_text.setTextCursor(cursor)
-
+
def clear_log(self):
self.log_text.clear()
-
+
def save_logs(self):
"""Sauvegarde les logs"""
filename, _ = get_save_file_name(
self, tr("btn_save_log"),
"", "Fichier texte (*.txt);;Tous (*.*)"
)
-
+
if filename:
try:
with open(filename, 'w') as f:
diff --git a/app/gui/tabs/params_tab.py b/app/gui/tabs/params_tab.py
index 9a96900..9519ae7 100644
--- a/app/gui/tabs/params_tab.py
+++ b/app/gui/tabs/params_tab.py
@@ -1,92 +1,116 @@
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QLabel, QScrollArea, QGroupBox, QFormLayout,
- QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QFormLayout,
+ QGroupBox,
+ QLabel,
+ QScrollArea,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
+
+from app.core.i18n import add_language_observer, tr
from app.core.params import ColmapParams
-from app.core.system import is_apple_silicon, get_optimal_threads
-from app.core.i18n import tr, add_language_observer
+from app.core.system import get_optimal_threads
+from app.gui.widgets.wheel_guard import install_wheel_guard
+
class ParamsTab(QWidget):
"""Onglet des paramÚtres COLMAP"""
-
+
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
+ # Prevent stray mouse-wheel scrolling from changing combos/spin boxes
+ # (e.g. the matcher silently flipping to vocab_tree while scrolling).
+ install_wheel_guard(self)
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
layout = QVBoxLayout(self)
-
- if is_apple_silicon():
- self.info_label = QLabel(tr("info_cpu", get_optimal_threads()))
- layout.addWidget(self.info_label)
- else:
- self.info_label = None
-
+
+ self.info_label = QLabel(tr("info_cpu", get_optimal_threads()))
+ layout.addWidget(self.info_label)
+
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
-
+
# Feature Extraction
self.extract_group = QGroupBox(tr("group_extract"))
extract_layout = QFormLayout()
-
+
self.camera_model_combo = QComboBox()
- self.camera_model_combo.addItems(['SIMPLE_PINHOLE', 'PINHOLE', 'SIMPLE_RADIAL',
- 'RADIAL', 'OPENCV', 'OPENCV_FISHEYE'])
+ self.camera_model_combo.addItems(['SIMPLE_PINHOLE', 'PINHOLE', 'SIMPLE_RADIAL',
+ 'RADIAL', 'OPENCV', 'OPENCV_FISHEYE',
+ 'EQUIRECTANGULAR'])
self.camera_model_combo.setCurrentText('SIMPLE_RADIAL')
self.camera_model_combo.setMinimumWidth(180)
self.lbl_camera_model = QLabel(tr("lbl_camera_model"))
extract_layout.addRow(self.lbl_camera_model, self.camera_model_combo)
-
+
+ # Native 360 (COLMAP 4.1.0): convenience toggle that selects the
+ # EQUIRECTANGULAR camera model for panoramic / 360° footage.
+ self.native_360_check = QCheckBox()
+ self.native_360_check.setToolTip(
+ "Reconstruction 360° native (modÚle EQUIRECTANGULAR). Requiert COLMAP ⥠4.1.0."
+ )
+ self.lbl_native_360 = QLabel(tr("check_native_360"))
+ extract_layout.addRow(self.lbl_native_360, self.native_360_check)
+ self.native_360_check.toggled.connect(self._on_native_360_toggled)
+ self.camera_model_combo.currentTextChanged.connect(self._sync_native_360_check)
+
self.single_camera_check = QCheckBox()
self.single_camera_check.setChecked(True)
self.lbl_single_cam = QLabel(tr("check_single_cam"))
extract_layout.addRow(self.lbl_single_cam, self.single_camera_check)
-
+
self.max_image_spin = QSpinBox()
self.max_image_spin.setRange(640, 8192)
self.max_image_spin.setValue(3200)
self.max_image_spin.setMinimumWidth(100)
self.lbl_max_img = QLabel(tr("lbl_max_img"))
extract_layout.addRow(self.lbl_max_img, self.max_image_spin)
-
+
self.max_features_spin = QSpinBox()
self.max_features_spin.setRange(1024, 32768)
self.max_features_spin.setValue(8192)
self.max_features_spin.setMinimumWidth(100)
self.lbl_max_feat = QLabel(tr("lbl_max_feat"))
extract_layout.addRow(self.lbl_max_feat, self.max_features_spin)
-
+
self.force_cpu_check = QCheckBox()
self.force_cpu_check.setEnabled(False)
self.lbl_force_cpu = QLabel(tr("check_force_cpu"))
extract_layout.addRow(self.lbl_force_cpu, self.force_cpu_check)
-
+
self.estimate_affine_check = QCheckBox()
self.lbl_affine = QLabel(tr("check_affine"))
extract_layout.addRow(self.lbl_affine, self.estimate_affine_check)
-
+
self.domain_pooling_check = QCheckBox()
- self.domain_pooling_check.setChecked(True)
+ self.domain_pooling_check.setChecked(False) # OFF â use GPU SIFT (CUDA)
self.lbl_domain = QLabel(tr("check_domain"))
extract_layout.addRow(self.lbl_domain, self.domain_pooling_check)
-
+
self.extract_group.setLayout(extract_layout)
scroll_layout.addWidget(self.extract_group)
-
+
# Feature Matching
self.match_group = QGroupBox(tr("group_match"))
match_layout = QFormLayout()
-
+
self.matcher_type_combo = QComboBox()
self.matcher_type_combo.addItems(['exhaustive', 'sequential', 'vocab_tree'])
- self.matcher_type_combo.setCurrentText('exhaustive')
+ # Sequential by default: fast + correct for ordered video frames.
+ self.matcher_type_combo.setCurrentText('sequential')
self.matcher_type_combo.setMinimumWidth(150)
self.lbl_match_type = QLabel(tr("lbl_match_type"))
match_layout.addRow(self.lbl_match_type, self.matcher_type_combo)
-
+
self.max_ratio_spin = QDoubleSpinBox()
self.max_ratio_spin.setRange(0.1, 1.0)
self.max_ratio_spin.setSingleStep(0.1)
@@ -94,7 +118,7 @@ def init_ui(self):
self.max_ratio_spin.setMinimumWidth(100)
self.lbl_max_ratio = QLabel(tr("lbl_max_ratio"))
match_layout.addRow(self.lbl_max_ratio, self.max_ratio_spin)
-
+
self.max_distance_spin = QDoubleSpinBox()
self.max_distance_spin.setRange(0.1, 1.0)
self.max_distance_spin.setSingleStep(0.1)
@@ -102,24 +126,37 @@ def init_ui(self):
self.max_distance_spin.setMinimumWidth(100)
self.lbl_max_dist = QLabel(tr("lbl_max_dist"))
match_layout.addRow(self.lbl_max_dist, self.max_distance_spin)
-
+
self.cross_check_check = QCheckBox()
self.cross_check_check.setChecked(True)
self.lbl_cross = QLabel(tr("check_cross"))
match_layout.addRow(self.lbl_cross, self.cross_check_check)
-
+
+ # Loop-closure detection (sequential matcher): recognises revisited places
+ # so the same location isn't reconstructed twice ("ghost"/duplicate
+ # buildings). On by default; uses an auto-downloaded FAISS vocab tree.
+ self.loop_detection_check = QCheckBox()
+ self.loop_detection_check.setChecked(True)
+ self.loop_detection_check.setToolTip(
+ "Détecte les lieux revisités (matching séquentiel) pour éviter les "
+ "duplications géométriques. Télécharge un petit vocabulaire FAISS au "
+ "premier usage. Recommandé pour les vidéos qui repassent au même endroit."
+ )
+ self.lbl_loop_detection = QLabel("Détection de boucles")
+ match_layout.addRow(self.lbl_loop_detection, self.loop_detection_check)
+
self.guided_match_check = QCheckBox()
self.guided_match_check.setEnabled(False)
self.lbl_guided = QLabel(tr("check_guided"))
match_layout.addRow(self.lbl_guided, self.guided_match_check)
-
+
self.match_group.setLayout(match_layout)
scroll_layout.addWidget(self.match_group)
-
+
# Mapper
self.mapper_group = QGroupBox(tr("group_mapper"))
mapper_layout = QFormLayout()
-
+
self.min_model_spin = QSpinBox()
self.min_model_spin.setRange(3, 100)
self.min_model_spin.setValue(10)
@@ -131,39 +168,65 @@ def init_ui(self):
self.use_glomap_check.setText(tr("check_use_glomap"))
self.lbl_glomap = QLabel(tr("lbl_glomap"))
mapper_layout.addRow(self.lbl_glomap, self.use_glomap_check)
-
+
self.multiple_models_check = QCheckBox()
self.lbl_multi_model = QLabel(tr("check_multi_model"))
mapper_layout.addRow(self.lbl_multi_model, self.multiple_models_check)
-
+
self.refine_focal_check = QCheckBox()
self.refine_focal_check.setChecked(True)
self.lbl_focal = QLabel(tr("check_focal"))
mapper_layout.addRow(self.lbl_focal, self.refine_focal_check)
-
+
self.refine_principal_check = QCheckBox()
self.lbl_principal = QLabel(tr("check_principal"))
mapper_layout.addRow(self.lbl_principal, self.refine_principal_check)
-
+
self.refine_extra_check = QCheckBox()
self.refine_extra_check.setChecked(True)
self.lbl_extra = QLabel(tr("check_extra"))
mapper_layout.addRow(self.lbl_extra, self.refine_extra_check)
-
+
self.min_matches_spin = QSpinBox()
self.min_matches_spin.setRange(5, 100)
self.min_matches_spin.setValue(15)
self.min_matches_spin.setMinimumWidth(100)
self.lbl_min_match = QLabel(tr("lbl_min_match"))
mapper_layout.addRow(self.lbl_min_match, self.min_matches_spin)
-
+
+ # GPU bundle adjustment (COLMAP 4.1.0 "Caspar"). Runs the bundle
+ # adjustment on the GPU â fixes "Linear solver failure" on big scenes.
+ self.ba_use_gpu_check = QCheckBox()
+ self.ba_use_gpu_check.setChecked(True) # GPU BA on by default (fast, auto-fallback)
+ self.ba_use_gpu_check.setToolTip(
+ "Bundle adjustment sur GPU (CUDA). Corrige les 'Linear solver failure' "
+ "sur les grandes scÚnes. Requiert COLMAP ⥠4.1.0 (ignoré sinon)."
+ )
+ self.lbl_ba_gpu = QLabel(tr("check_ba_use_gpu"))
+ mapper_layout.addRow(self.lbl_ba_gpu, self.ba_use_gpu_check)
+
self.mapper_group.setLayout(mapper_layout)
scroll_layout.addWidget(self.mapper_group)
-
+
scroll_layout.addStretch()
scroll.setWidget(scroll_widget)
layout.addWidget(scroll)
+ def _on_native_360_toggled(self, checked):
+ """360 toggle drives the camera-model combo (single source of truth)."""
+ if checked:
+ self.camera_model_combo.setCurrentText('EQUIRECTANGULAR')
+ elif self.camera_model_combo.currentText() == 'EQUIRECTANGULAR':
+ self.camera_model_combo.setCurrentText('SIMPLE_RADIAL')
+
+ def _sync_native_360_check(self, model):
+ """Keep the 360 checkbox in sync if the camera model is changed directly."""
+ is_360 = model == 'EQUIRECTANGULAR'
+ if self.native_360_check.isChecked() != is_360:
+ self.native_360_check.blockSignals(True)
+ self.native_360_check.setChecked(is_360)
+ self.native_360_check.blockSignals(False)
+
def get_params(self):
"""RécupÚre les paramÚtres actuels"""
return ColmapParams(
@@ -183,8 +246,10 @@ def get_params(self):
ba_refine_focal_length=self.refine_focal_check.isChecked(),
ba_refine_principal_point=self.refine_principal_check.isChecked(),
ba_refine_extra_params=self.refine_extra_check.isChecked(),
+ ba_use_gpu=self.ba_use_gpu_check.isChecked(),
min_num_matches=self.min_matches_spin.value(),
matcher_type=self.matcher_type_combo.currentText(),
+ loop_detection=self.loop_detection_check.isChecked(),
use_glomap=self.use_glomap_check.isChecked(),
undistort_images=False, # Géré par ConfigTab pour l'instant, ou on peut le passer ici si on veut
)
@@ -206,14 +271,18 @@ def set_params(self, params):
self.refine_focal_check.setChecked(params.ba_refine_focal_length)
self.refine_principal_check.setChecked(params.ba_refine_principal_point)
self.refine_extra_check.setChecked(params.ba_refine_extra_params)
+ self.ba_use_gpu_check.setChecked(params.ba_use_gpu)
self.min_matches_spin.setValue(params.min_num_matches)
self.matcher_type_combo.setCurrentText(params.matcher_type)
+ self.loop_detection_check.setChecked(getattr(params, 'loop_detection', True))
self.use_glomap_check.setChecked(params.use_glomap)
+ # Keep the 360 convenience checkbox consistent with the loaded model.
+ self._sync_native_360_check(params.camera_model)
# undistort est dans config tab
def get_state(self):
return self.get_params().to_dict()
-
+
def set_state(self, state):
self.set_params(ColmapParams.from_dict(state))
@@ -221,7 +290,7 @@ def retranslate_ui(self):
"""Update texts when language changes"""
if self.info_label:
self.info_label.setText(tr("info_cpu", get_optimal_threads()))
-
+
self.extract_group.setTitle(tr("group_extract"))
self.lbl_camera_model.setText(tr("lbl_camera_model"))
self.lbl_single_cam.setText(tr("check_single_cam"))
@@ -230,14 +299,15 @@ def retranslate_ui(self):
self.lbl_force_cpu.setText(tr("check_force_cpu"))
self.lbl_affine.setText(tr("check_affine"))
self.lbl_domain.setText(tr("check_domain"))
-
+ self.lbl_native_360.setText(tr("check_native_360"))
+
self.match_group.setTitle(tr("group_match"))
self.lbl_match_type.setText(tr("lbl_match_type"))
self.lbl_max_ratio.setText(tr("lbl_max_ratio"))
self.lbl_max_dist.setText(tr("lbl_max_dist"))
self.lbl_cross.setText(tr("check_cross"))
self.lbl_guided.setText(tr("check_guided"))
-
+
self.mapper_group.setTitle(tr("group_mapper"))
self.lbl_min_model.setText(tr("lbl_min_model"))
self.lbl_glomap.setText(tr("lbl_glomap"))
@@ -247,3 +317,4 @@ def retranslate_ui(self):
self.lbl_principal.setText(tr("check_principal"))
self.lbl_extra.setText(tr("check_extra"))
self.lbl_min_match.setText(tr("lbl_min_match"))
+ self.lbl_ba_gpu.setText(tr("check_ba_use_gpu"))
diff --git a/app/gui/tabs/sharp_tab.py b/app/gui/tabs/sharp_tab.py
deleted file mode 100644
index 1660c4f..0000000
--- a/app/gui/tabs/sharp_tab.py
+++ /dev/null
@@ -1,460 +0,0 @@
-from PyQt6.QtCore import pyqtSignal, Qt
-from app.core.i18n import tr, add_language_observer
-from app.gui.widgets.drop_line_edit import DropLineEdit
-from app.gui.widgets.dialog_utils import get_existing_directory, get_open_file_name
-from app.scripts.setup_dependencies import install_sharp, uninstall_sharp
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGroupBox,
- QFormLayout, QCheckBox, QComboBox, QMessageBox, QProgressDialog, QApplication,
- QRadioButton, QButtonGroup, QStackedWidget, QSpinBox, QProgressBar
-)
-
-class SharpTab(QWidget):
- """Onglet de configuration Apple ML Sharp"""
-
- predictRequested = pyqtSignal()
- stopRequested = pyqtSignal()
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.init_ui()
- add_language_observer(self.retranslate_ui)
-
- def init_ui(self):
- layout = QVBoxLayout(self)
-
- # Engine check for initial state
- from app.core.sharp_engine import SharpEngine
- self.engine = SharpEngine()
- self.is_installed = self.engine.is_installed()
-
- # Activation / Installation Checkbox
- self.chk_activate = QCheckBox(tr("sharp_activate"))
- self.chk_activate.setStyleSheet("font-weight: bold; font-size: 14px; margin-bottom: 10px;")
- self.chk_activate.setChecked(self.is_installed)
- self.chk_activate.clicked.connect(self.on_toggle_activation)
- layout.addWidget(self.chk_activate)
-
- # Status Label (below Checkbox)
- self.status_lbl = QLabel("") # Will be updated
- layout.addWidget(self.status_lbl)
- self.check_status() # Update text/color
-
- # Mode Selection
- mode_layout = QHBoxLayout()
- self.radio_mode_image = QRadioButton(tr("sharp_mode_image"))
- self.radio_mode_video = QRadioButton(tr("sharp_mode_video"))
- self.radio_mode_image.setChecked(True) # default
-
- mode_layout.addWidget(self.radio_mode_image)
- mode_layout.addWidget(self.radio_mode_video)
- mode_layout.addStretch()
-
- self.mode_group = QButtonGroup(self)
- self.mode_group.addButton(self.radio_mode_image, 0)
- self.mode_group.addButton(self.radio_mode_video, 1)
- self.mode_group.buttonClicked.connect(self.on_mode_changed)
-
- layout.addLayout(mode_layout)
-
- # Stacked Widget for Modes
- self.stacked_widget = QStackedWidget()
-
- # -----------------------------
- # MODE A: Image -> PLY
- # -----------------------------
- self.page_image = QWidget()
- page_image_layout = QVBoxLayout(self.page_image)
- page_image_layout.setContentsMargins(0, 5, 0, 0)
-
- self.path_group = QGroupBox(tr("sharp_group_paths"))
- path_layout = QVBoxLayout()
-
- self.lbl_input = QLabel(tr("sharp_lbl_input"))
- path_layout.addWidget(self.lbl_input)
-
- input_controls = QHBoxLayout()
- self.input_path = DropLineEdit()
- self.input_path.setPlaceholderText(tr("sharp_placeholder_input"))
- self.btn_browse_input_dir = QPushButton(tr("sharp_btn_folder"))
- self.btn_browse_input_dir.clicked.connect(self.browse_input_dir)
- self.btn_browse_input_file = QPushButton(tr("sharp_btn_file"))
- self.btn_browse_input_file.clicked.connect(self.browse_input_file)
-
- input_controls.addWidget(self.input_path)
- input_controls.addWidget(self.btn_browse_input_dir)
- input_controls.addWidget(self.btn_browse_input_file)
- path_layout.addLayout(input_controls)
-
- self.lbl_output = QLabel(tr("sharp_lbl_output"))
- path_layout.addWidget(self.lbl_output)
-
- output_controls = QHBoxLayout()
- self.output_path = DropLineEdit()
- self.output_path.setPlaceholderText(tr("sharp_placeholder_output"))
- self.btn_browse_output = QPushButton(tr("btn_browse"))
- self.btn_browse_output.clicked.connect(self.browse_output)
- output_controls.addWidget(self.output_path)
- output_controls.addWidget(self.btn_browse_output)
- path_layout.addLayout(output_controls)
-
- self.path_group.setLayout(path_layout)
- page_image_layout.addWidget(self.path_group)
- self.stacked_widget.addWidget(self.page_image)
-
- # -----------------------------
- # MODE B: Video -> PLY
- # -----------------------------
- self.page_video = QWidget()
- page_video_layout = QVBoxLayout(self.page_video)
- page_video_layout.setContentsMargins(0, 5, 0, 0)
-
- self.video_group = QGroupBox(tr("sharp_group_paths"))
- video_layout = QVBoxLayout()
-
- self.lbl_video_input = QLabel(tr("sharp_lbl_video_input"))
- video_layout.addWidget(self.lbl_video_input)
-
- video_input_controls = QHBoxLayout()
- self.video_path = DropLineEdit()
- self.video_path.setPlaceholderText(tr("sharp_placeholder_video_input"))
- self.btn_browse_video_file = QPushButton(tr("btn_browse"))
- self.btn_browse_video_file.clicked.connect(self.browse_video_file)
-
- video_input_controls.addWidget(self.video_path)
- video_input_controls.addWidget(self.btn_browse_video_file)
- video_layout.addLayout(video_input_controls)
-
- self.lbl_video_output = QLabel(tr("sharp_lbl_output"))
- video_layout.addWidget(self.lbl_video_output)
-
- video_output_controls = QHBoxLayout()
- self.video_output_path = DropLineEdit()
- self.video_output_path.setPlaceholderText(tr("sharp_placeholder_output"))
- self.btn_browse_video_output = QPushButton(tr("btn_browse"))
- self.btn_browse_video_output.clicked.connect(self.browse_video_output)
- video_output_controls.addWidget(self.video_output_path)
- video_output_controls.addWidget(self.btn_browse_video_output)
- video_layout.addLayout(video_output_controls)
-
- # Frame skip for video mode
- skip_layout = QHBoxLayout()
- self.lbl_frame_skip = QLabel(tr("sharp_lbl_frame_skip"))
- self.spin_frame_skip = QSpinBox()
- self.spin_frame_skip.setMinimum(1)
- self.spin_frame_skip.setMaximum(100)
- self.spin_frame_skip.setValue(1)
- skip_layout.addWidget(self.lbl_frame_skip)
- skip_layout.addWidget(self.spin_frame_skip)
- skip_layout.addStretch()
- video_layout.addLayout(skip_layout)
-
- self.lbl_frame_skip_desc = QLabel(tr("sharp_tip_frame_skip"))
- self.lbl_frame_skip_desc.setStyleSheet("color: #888888; font-size: 11px;")
- video_layout.addWidget(self.lbl_frame_skip_desc)
-
- self.video_group.setLayout(video_layout)
- page_video_layout.addWidget(self.video_group)
- self.stacked_widget.addWidget(self.page_video)
-
- layout.addWidget(self.stacked_widget)
-
- # -----------------------------
- # SHARED OPTIONS
- # -----------------------------
- self.opt_group = QGroupBox(tr("group_options"))
- opt_layout = QFormLayout()
-
- # Checkpoint
- ckpt_layout = QHBoxLayout()
- self.ckpt_path = DropLineEdit()
- self.ckpt_path.setPlaceholderText(tr("sharp_placeholder_ckpt"))
- self.btn_browse_ckpt = QPushButton(tr("btn_browse"))
- self.btn_browse_ckpt.clicked.connect(self.browse_ckpt)
- ckpt_layout.addWidget(self.ckpt_path)
- ckpt_layout.addWidget(self.btn_browse_ckpt)
- self.lbl_ckpt = QLabel(tr("sharp_lbl_ckpt"))
- opt_layout.addRow(self.lbl_ckpt, ckpt_layout)
-
- # Device
- self.device_combo = QComboBox()
- self.device_combo.addItems(["default", "mps", "cpu", "cuda"])
- self.device_combo.setMinimumWidth(150)
- self.lbl_device = QLabel(tr("sharp_lbl_device"))
- opt_layout.addRow(self.lbl_device, self.device_combo)
-
- # Verbose
- self.verbose_check = QCheckBox(tr("sharp_check_verbose"))
- opt_layout.addRow("", self.verbose_check)
-
- self.upscale_check = QCheckBox(tr("upscale_check_sharp"))
- opt_layout.addRow("", self.upscale_check)
-
- self.opt_group.setLayout(opt_layout)
- layout.addWidget(self.opt_group)
-
- # Actions
- action_layout = QHBoxLayout()
-
- self.btn_run = QPushButton(tr("sharp_btn_run"))
- self.btn_run.setMinimumHeight(40)
- self.btn_run.setStyleSheet("background-color: #2a82da; color: white; font-weight: bold;")
- self.btn_run.clicked.connect(self.predictRequested.emit)
-
- action_layout.addWidget(self.btn_run)
-
- self.btn_stop = QPushButton(tr("btn_stop"))
- self.btn_stop.setMinimumHeight(40)
- self.btn_stop.setEnabled(False)
- self.btn_stop.clicked.connect(self.stopRequested.emit)
- action_layout.addWidget(self.btn_stop)
-
- layout.addLayout(action_layout)
-
- self.progress_bar = QProgressBar()
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setValue(0)
- self.progress_bar.setTextVisible(False)
- self.progress_bar.setFixedHeight(6)
- self.progress_bar.setVisible(False)
- layout.addWidget(self.progress_bar)
-
- layout.addStretch()
-
- def on_mode_changed(self, button):
- mode_idx = self.mode_group.id(button)
- self.stacked_widget.setCurrentIndex(mode_idx)
- if mode_idx == 1:
- self.btn_run.setText(tr("sharp_btn_run_video"))
- else:
- self.btn_run.setText(tr("sharp_btn_run"))
-
- def set_processing_state(self, is_processing):
- self.btn_run.setEnabled(not is_processing)
- self.btn_stop.setEnabled(is_processing)
- self.chk_activate.setEnabled(not is_processing)
- self.radio_mode_image.setEnabled(not is_processing)
- self.radio_mode_video.setEnabled(not is_processing)
-
- if is_processing:
- self.progress_bar.setValue(0)
- if self.radio_mode_image.isChecked():
- self.progress_bar.setRange(0, 0) # indeterminate / pulsing
- else:
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setVisible(True)
- else:
- self.progress_bar.setVisible(False)
- self.progress_bar.setRange(0, 100)
- self.progress_bar.setValue(0)
-
- def browse_input_dir(self):
- path = get_existing_directory(self, tr("sharp_dlg_input_dir"))
- if path:
- self.input_path.setText(path)
-
- def browse_input_file(self):
- path, _ = get_open_file_name(self, tr("sharp_dlg_input_file"), "", "Images (*.png *.jpg *.jpeg *.tif *.tiff)")
- if path:
- self.input_path.setText(path)
-
- def browse_output(self):
- path = get_existing_directory(self, tr("sharp_dlg_output"))
- if path:
- self.output_path.setText(path)
-
- def browse_video_file(self):
- path, _ = get_open_file_name(self, tr("sharp_dlg_video_file"), "", "Videos (*.mp4 *.mov *.avi *.mkv)")
- if path:
- self.video_path.setText(path)
-
- def browse_video_output(self):
- path = get_existing_directory(self, tr("sharp_dlg_output"))
- if path:
- self.video_output_path.setText(path)
-
- def browse_ckpt(self):
- path, _ = get_open_file_name(self, tr("sharp_dlg_ckpt"), "", "PyTorch Model (*.pt)")
- if path:
- self.ckpt_path.setText(path)
-
- def check_status(self):
- is_installed = self.engine.is_installed()
- if is_installed:
- self.status_lbl.setText(tr("sharp_status_ready"))
- self.status_lbl.setStyleSheet("color: green;")
- else:
- self.status_lbl.setText(tr("sharp_status_missing"))
- self.status_lbl.setStyleSheet("color: orange;")
-
- def on_toggle_activation(self):
- if self.chk_activate.isChecked():
- if not self.engine.is_installed():
- reply = QMessageBox.question(
- self, tr("sharp_install_title"),
- tr("sharp_install_msg"),
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
- )
- if reply == QMessageBox.StandardButton.Yes:
- self.install_sharp_module()
- else:
- self.chk_activate.setChecked(False)
- else:
- self.enable_controls(True)
- else:
- reply = QMessageBox.question(
- self, tr("sharp_uninstall_title"),
- tr("sharp_uninstall_msg"),
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
- )
- if reply == QMessageBox.StandardButton.Yes:
- self.uninstall_sharp_module()
- else:
- self.enable_controls(False) # Just disable UI
- self.chk_activate.setChecked(False) # Uncheck it visually
-
- def enable_controls(self, enabled):
- self.path_group.setEnabled(enabled)
- self.video_group.setEnabled(enabled)
- self.opt_group.setEnabled(enabled)
- self.btn_run.setEnabled(enabled)
-
- def install_sharp_module(self):
- progress = QProgressDialog(tr("sharp_status_installing"), None, 0, 0, self)
- progress.setWindowModality(Qt.WindowModality.WindowModal)
- progress.show()
- QApplication.processEvents()
-
- try:
- success = install_sharp()
- if success:
- QMessageBox.information(self, tr("msg_success"), tr("sharp_msg_install_ok"))
- self.enable_controls(True)
- self.check_status()
- else:
- QMessageBox.critical(self, tr("msg_error"), tr("sharp_msg_install_err"))
- self.chk_activate.setChecked(False)
- self.enable_controls(False)
- except Exception as e:
- QMessageBox.critical(self, tr("msg_error"), f"Exception: {e}")
- self.chk_activate.setChecked(False)
- finally:
- progress.close()
-
- def uninstall_sharp_module(self):
- progress = QProgressDialog(tr("sharp_status_uninstalling"), None, 0, 0, self)
- progress.setWindowModality(Qt.WindowModality.WindowModal)
- progress.show()
- QApplication.processEvents()
-
- try:
- success = uninstall_sharp()
- if success:
- QMessageBox.information(self, tr("msg_success"), tr("sharp_msg_uninstall_ok"))
- self.enable_controls(False)
- self.check_status()
- except Exception as e:
- QMessageBox.critical(self, tr("msg_error"), f"Exception: {e}")
- finally:
- progress.close()
-
- def get_params(self):
- mode = "image" if self.radio_mode_image.isChecked() else "video"
- return {
- "mode": mode,
- "enabled": self.chk_activate.isChecked(),
- "input_path": self.input_path.text(),
- "output_path": self.output_path.text(),
- "video_path": self.video_path.text(),
- "video_output_path": self.video_output_path.text(),
- "skip_frames": self.spin_frame_skip.value(),
- "checkpoint": self.ckpt_path.text(),
- "device": self.device_combo.currentText(),
- "verbose": self.verbose_check.isChecked(),
- "upscale": self.upscale_check.isChecked()
- }
-
- def set_params(self, params):
- if not params: return
-
- if "enabled" in params:
- enabled = params["enabled"]
- self.chk_activate.setChecked(enabled)
- self.enable_controls(enabled)
-
- if "mode" in params:
- if params["mode"] == "video":
- self.radio_mode_video.setChecked(True)
- self.stacked_widget.setCurrentIndex(1)
- else:
- self.radio_mode_image.setChecked(True)
- self.stacked_widget.setCurrentIndex(0)
-
- if "input_path" in params: self.input_path.setText(params["input_path"])
- if "output_path" in params: self.output_path.setText(params["output_path"])
- if "video_path" in params: self.video_path.setText(params["video_path"])
- if "video_output_path" in params: self.video_output_path.setText(params["video_output_path"])
- if "skip_frames" in params: self.spin_frame_skip.setValue(params["skip_frames"])
-
- if "checkpoint" in params: self.ckpt_path.setText(params["checkpoint"])
- if "device" in params: self.device_combo.setCurrentText(params["device"])
- if "verbose" in params: self.verbose_check.setChecked(params["verbose"])
- if "upscale" in params: self.upscale_check.setChecked(params["upscale"])
-
- def get_state(self):
- return self.get_params()
-
- def set_state(self, state):
- self.set_params(state)
-
- def retranslate_ui(self):
- """Update texts when language changes"""
- self.chk_activate.setText(tr("sharp_activate"))
- self.check_status() # Updates status_lbl text
-
- self.radio_mode_image.setText(tr("sharp_mode_image"))
- self.radio_mode_video.setText(tr("sharp_mode_video"))
-
- self.path_group.setTitle(tr("sharp_group_paths"))
- self.lbl_input.setText(tr("sharp_lbl_input"))
- self.input_path.setPlaceholderText(tr("sharp_placeholder_input"))
- self.btn_browse_input_dir.setText(tr("sharp_btn_folder"))
- self.btn_browse_input_file.setText(tr("sharp_btn_file"))
-
- self.lbl_output.setText(tr("sharp_lbl_output"))
- self.output_path.setPlaceholderText(tr("sharp_placeholder_output"))
- self.btn_browse_output.setText(tr("btn_browse"))
-
- self.video_group.setTitle(tr("sharp_group_paths"))
- self.lbl_video_input.setText(tr("sharp_lbl_video_input"))
- self.video_path.setPlaceholderText(tr("sharp_placeholder_video_input"))
- self.btn_browse_video_file.setText(tr("btn_browse"))
- self.lbl_video_output.setText(tr("sharp_lbl_output"))
- self.video_output_path.setPlaceholderText(tr("sharp_placeholder_output"))
- self.btn_browse_video_output.setText(tr("btn_browse"))
- self.lbl_frame_skip.setText(tr("sharp_lbl_frame_skip"))
-
- frame_skip_tip = tr("sharp_tip_frame_skip")
- self.lbl_frame_skip.setToolTip(frame_skip_tip)
- self.spin_frame_skip.setToolTip(frame_skip_tip)
- if hasattr(self, 'lbl_frame_skip_desc'):
- self.lbl_frame_skip_desc.setText(frame_skip_tip)
-
- self.opt_group.setTitle(tr("group_options"))
- self.lbl_ckpt.setText(tr("sharp_lbl_ckpt"))
- self.ckpt_path.setPlaceholderText(tr("sharp_placeholder_ckpt"))
- self.btn_browse_ckpt.setText(tr("btn_browse"))
- self.lbl_device.setText(tr("sharp_lbl_device"))
- self.verbose_check.setText(tr("sharp_check_verbose"))
- self.upscale_check.setText(tr("upscale_check_sharp"))
-
- # update button run according to mode? well, keep it simple and just let it say "Lancer predict"/"Lancer la conversion".
- # actually, let's keep "sharp_btn_run" for both or separate it.
- # sharp_btn_run works well. The requirement originally said "Bouton Lancer la conversion" for video.
- # But maybe just one single button at the bottom is cleaner.
- if self.radio_mode_video.isChecked():
- self.btn_run.setText(tr("sharp_btn_run_video"))
- else:
- self.btn_run.setText(tr("sharp_btn_run"))
-
- self.btn_stop.setText(tr("btn_stop"))
diff --git a/app/gui/tabs/superplat_tab.py b/app/gui/tabs/superplat_tab.py
index ea2b5c4..7cd9a98 100644
--- a/app/gui/tabs/superplat_tab.py
+++ b/app/gui/tabs/superplat_tab.py
@@ -1,91 +1,103 @@
-from pathlib import Path
import webbrowser
+from pathlib import Path
+from urllib.parse import quote
+
+from PyQt6.QtCore import QTimer, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
- QGroupBox, QCheckBox, QMessageBox, QSpinBox, QFormLayout
+ QCheckBox,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
-from PyQt6.QtCore import pyqtSignal, QTimer
-from app.core.i18n import tr, add_language_observer
+
+from app.core.i18n import add_language_observer, tr
from app.core.superplat_engine import SuperSplatEngine
from app.gui.widgets.dialog_utils import get_open_file_name
-from urllib.parse import quote
+
class SuperSplatTab(QWidget):
"""Onglet pour SuperSplat"""
-
+
stopRequested = pyqtSignal() # Pour signifier au Main Window si besoin de cleanup global
-
+
def __init__(self, parent=None):
super().__init__(parent)
self.engine = SuperSplatEngine()
self.is_running = False
self.init_ui()
add_language_observer(self.retranslate_ui)
-
+
def init_ui(self):
layout = QVBoxLayout(self)
-
+
# Header / Info
self.lbl_info = QLabel(tr("superplat_info", "SuperSplat (PlayCanvas)"))
self.lbl_info.setWordWrap(True)
layout.addWidget(self.lbl_info)
-
+
# Configuration Serveur
self.server_group = QGroupBox(tr("group_server_config", "Configuration Serveur"))
server_layout = QFormLayout()
-
+
self.splat_port = QSpinBox()
self.splat_port.setRange(1024, 65535)
self.splat_port.setValue(3000)
self.lbl_splat_port = QLabel(tr("lbl_splat_port", "Port SuperSplat :"))
server_layout.addRow(self.lbl_splat_port, self.splat_port)
-
+
self.data_port = QSpinBox()
self.data_port.setRange(1024, 65535)
self.data_port.setValue(8000)
self.lbl_data_port = QLabel(tr("lbl_data_port", "Port Données :"))
server_layout.addRow(self.lbl_data_port, self.data_port)
-
+
self.server_group.setLayout(server_layout)
layout.addWidget(self.server_group)
-
+
# Données
self.data_group = QGroupBox(tr("group_data", "Données à Visualiser"))
data_layout = QVBoxLayout()
-
+
path_layout = QHBoxLayout()
self.input_path = QLineEdit()
self.input_path.setPlaceholderText(tr("placeholder_ply", "Chemin vers un fichier .ply ou dossier"))
path_layout.addWidget(self.input_path)
-
+
self.btn_browse = QPushButton(tr("btn_browse"))
self.btn_browse.clicked.connect(self.browse_input)
path_layout.addWidget(self.btn_browse)
-
+
data_layout.addLayout(path_layout)
self.data_group.setLayout(data_layout)
layout.addWidget(self.data_group)
-
+
# Options URL
self.options_group = QGroupBox(tr("group_url_options", "Options de Vue"))
options_layout = QFormLayout()
-
+
self.chk_no_ui = QCheckBox(tr("check_no_ui", "Masquer l'interface (No UI)"))
options_layout.addRow(self.chk_no_ui)
-
+
self.cam_pos = QLineEdit()
self.cam_pos.setPlaceholderText("X,Y,Z (ex: 0,1,-5)")
self.lbl_cam_pos = QLabel(tr("lbl_cam_pos", "Position Caméra :"))
options_layout.addRow(self.lbl_cam_pos, self.cam_pos)
-
+
self.cam_rot = QLineEdit()
self.cam_rot.setPlaceholderText("X,Y,Z (Degrés)")
self.lbl_cam_rot = QLabel(tr("lbl_cam_rot", "Rotation Caméra :"))
options_layout.addRow(self.lbl_cam_rot, self.cam_rot)
-
+
self.options_group.setLayout(options_layout)
layout.addWidget(self.options_group)
-
+
# Actions
action_layout = QHBoxLayout()
@@ -97,7 +109,7 @@ def init_ui(self):
layout.addLayout(action_layout)
layout.addStretch()
-
+
self.status_label = QLabel(tr("status_stopped", "Statut : Arrêté"))
layout.addWidget(self.status_label)
@@ -112,7 +124,7 @@ def toggle_server(self):
self.stop_server()
else:
self.start_server()
-
+
def start_server(self):
# 1. Start SuperSplat
success, msg = self.engine.start_supersplat(self.splat_port.value())
@@ -151,9 +163,9 @@ def open_browser(self):
"""Construit l'URL et ouvre le navigateur"""
# Construit l'URL racine de SuperSplat
url = f"http://localhost:{self.splat_port.value()}"
-
+
params = []
-
+
# Load Param
path_str = self.input_path.text()
if path_str:
@@ -163,22 +175,22 @@ def open_browser(self):
# URL to data server
data_url = f"http://localhost:{self.data_port.value()}/{filename}"
params.append(f"load={quote(data_url, safe=':/')}")
-
+
# No UI
if self.chk_no_ui.isChecked():
params.append("noui")
-
+
# Camera
if self.cam_pos.text():
params.append(f"cameraPosition={self.cam_pos.text().strip()}")
if self.cam_rot.text():
params.append(f"cameraRotation={self.cam_rot.text().strip()}")
-
+
if params:
url += "?" + "&".join(params)
-
+
webbrowser.open(url)
-
+
def get_state(self):
"""Returns the full state for persistence"""
return {
diff --git a/app/gui/tabs/upscale_tab.py b/app/gui/tabs/upscale_tab.py
index 6b3fc09..04f597f 100644
--- a/app/gui/tabs/upscale_tab.py
+++ b/app/gui/tabs/upscale_tab.py
@@ -3,18 +3,32 @@
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGroupBox,
- QFormLayout, QCheckBox, QComboBox, QSpinBox, QMessageBox,
- QProgressDialog, QApplication, QScrollArea, QFrame, QSlider,
- QFileDialog, QSizePolicy,
+ QCheckBox,
+ QComboBox,
+ QFileDialog,
+ QFormLayout,
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QScrollArea,
+ QSlider,
+ QSpinBox,
+ QVBoxLayout,
+ QWidget,
)
-from app.core.i18n import tr, add_language_observer
+from app.core.i18n import add_language_observer, tr
from app.gui.widgets.drop_line_edit import DropLineEdit
from app.gui.widgets.upscale_widgets import (
- BinaryInstallWorker, ModelDownloadWorker, TestWorker, ModelCard,
+ BinaryInstallWorker,
+ ModelCard,
+ ModelDownloadWorker,
+ TestWorker,
)
-
+from app.gui.widgets.wheel_guard import install_wheel_guard
# ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Main tab
@@ -29,6 +43,7 @@ def __init__(self, parent=None):
self._model_cards: dict[str, ModelCard] = {}
self._active_workers: list[QThread] = []
self.init_ui()
+ install_wheel_guard(self)
add_language_observer(self.retranslate_ui)
# ââââââââââââââââââââââââââââââââââââââââââââ build UI
diff --git a/app/gui/widgets/dialog_utils.py b/app/gui/widgets/dialog_utils.py
index e7ae531..c5f2a0a 100644
--- a/app/gui/widgets/dialog_utils.py
+++ b/app/gui/widgets/dialog_utils.py
@@ -1,5 +1,6 @@
from PyQt6.QtWidgets import QFileDialog
+
def get_dialog_options():
"""Returns standard options for file dialogs."""
return QFileDialog.Option(0)
diff --git a/app/gui/widgets/drop_line_edit.py b/app/gui/widgets/drop_line_edit.py
index 67e158c..ff893a2 100644
--- a/app/gui/widgets/drop_line_edit.py
+++ b/app/gui/widgets/drop_line_edit.py
@@ -1,7 +1,7 @@
-from PyQt6.QtWidgets import QLineEdit
-from PyQt6.QtCore import pyqtSignal
from pathlib import Path
-from typing import Optional
+
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QLineEdit
class DropLineEdit(QLineEdit):
@@ -16,7 +16,7 @@ class DropLineEdit(QLineEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
- self._allowed_base_dirs: Optional[list[Path]] = None
+ self._allowed_base_dirs: list[Path] | None = None
def set_allowed_base_dirs(self, dirs: list[Path]):
"""Set allowed base directories for containment validation."""
diff --git a/app/gui/widgets/upscale_widgets.py b/app/gui/widgets/upscale_widgets.py
index c79d7a8..281e377 100644
--- a/app/gui/widgets/upscale_widgets.py
+++ b/app/gui/widgets/upscale_widgets.py
@@ -1,9 +1,12 @@
-import subprocess
from pathlib import Path
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
)
@@ -55,11 +58,12 @@ def __init__(self, input_path: str, output_dir: str, params: dict):
def run(self):
try:
- from app.upscayl_manager import run_upscayl, find_binary, resize_to_original
- from app.upscayl_models import get_model
import shutil as _shutil
import tempfile as _tempfile
+ from app.upscayl_manager import find_binary, resize_to_original, run_upscayl
+ from app.upscayl_models import get_model
+
model_id = self.params.get("model_id", "")
if not model_id:
self.finished.emit(False, "Aucun mod\u00e8le s\u00e9lectionn\u00e9.")
diff --git a/app/gui/widgets/wheel_guard.py b/app/gui/widgets/wheel_guard.py
new file mode 100644
index 0000000..52196df
--- /dev/null
+++ b/app/gui/widgets/wheel_guard.py
@@ -0,0 +1,31 @@
+"""wheel_guard.py â stop scroll wheel from accidentally changing form widgets.
+
+Combo boxes, spin boxes and sliders live inside scrollable tabs (QScrollArea).
+When the user scrolls the page, the wheel passes over those widgets and Qt would
+change their value (e.g. the COLMAP matcher silently flips to another option).
+This guard makes such widgets ignore the wheel unless they actually have focus
+(the user clicked into them); the scroll area scrolls instead.
+"""
+from PyQt6.QtCore import QEvent, QObject, Qt
+from PyQt6.QtWidgets import QAbstractSlider, QAbstractSpinBox, QComboBox
+
+
+class _WheelGuard(QObject):
+ def eventFilter(self, obj, event):
+ if event.type() == QEvent.Type.Wheel and not obj.hasFocus():
+ event.ignore()
+ return True # consume: don't change the widget, let the page scroll
+ return False
+
+
+# Module-level singleton: installEventFilter does not take ownership, so the
+# filter must outlive every widget it is installed on.
+_guard = _WheelGuard()
+
+
+def install_wheel_guard(root):
+ """Guard every combo / spin box / slider under ``root`` against stray wheel."""
+ for widget_type in (QComboBox, QAbstractSpinBox, QAbstractSlider):
+ for w in root.findChildren(widget_type):
+ w.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # drop WheelFocus
+ w.installEventFilter(_guard)
diff --git a/app/gui/workers.py b/app/gui/workers.py
index 88d0906..0c3ad61 100755
--- a/app/gui/workers.py
+++ b/app/gui/workers.py
@@ -1,15 +1,16 @@
import os
-import shutil
import re
-import subprocess
+import shutil
import time
import traceback
from pathlib import Path
-from app.core.engine import ColmapEngine
+
from app.core.brush_engine import BrushEngine
+from app.core.engine import ColmapEngine
+from app.core.extractor_360_engine import Extractor360Engine
from app.core.i18n import tr
from app.gui.base_worker import BaseWorker
-from app.core.extractor_360_engine import Extractor360Engine
+
class Extractor360Worker(BaseWorker):
"""Thread worker pour exécuter 360Extractor"""
@@ -34,14 +35,14 @@ def run(self):
# Use engine to construct/run instead of manual cmd construction
success = self.engine.run_extraction(
- self.input_path,
- self.output_path,
+ self.input_path,
+ self.output_path,
self.params,
progress_callback=self.progress_signal.emit,
log_callback=self.log_signal.emit,
check_cancel_callback=self.isInterruptionRequested
)
-
+
if success:
self.finished_signal.emit(True, tr("status_360_done", "Extraction terminée avec succÚs."))
else:
@@ -58,7 +59,7 @@ def parse_line(self, line):
class ColmapWorker(BaseWorker):
"""Thread worker pour exécuter COLMAP via le moteur"""
-
+
def __init__(self, params, input_path, output_path, input_type, fps, project_name="Untitled", upscale_params=None, extractor_360_params=None, engine=None):
super().__init__()
self.upscale_params = upscale_params
@@ -72,30 +73,30 @@ def __init__(self, params, input_path, output_path, input_type, fps, project_nam
status_callback=self.status_signal.emit,
check_cancel_callback=self.isInterruptionRequested
)
-
+
def stop(self):
if self.extractor_engine:
self.extractor_engine.stop()
self.engine.stop()
super().stop()
-
+
def run(self):
# 1. Check 360 Extractor
if self.extractor_360_params and self.extractor_360_params.get("enabled", False):
from app.core.extractor_360_engine import Extractor360Engine
self.extractor_engine = Extractor360Engine()
-
+
if not self.extractor_engine.is_installed():
self.log_signal.emit(tr("err_360_not_installed_colmap", "ERREUR: 360 Extractor activé mais non installé."))
self.finished_signal.emit(False, tr("err_360_missing", "Dépendances 360 manquantes"))
return
self.log_signal.emit(tr("status_360_pre", "--- Démarrage 360 Extractor (Pré-traitement) ---"))
-
+
# Output images to project/images
images_dir = self.engine.project_path / "images"
images_dir.mkdir(parents=True, exist_ok=True)
-
+
# Run extraction
success = self.extractor_engine.run_extraction(
self.engine.input_path, # Video path
@@ -105,13 +106,13 @@ def run(self):
log_callback=self.log_signal.emit,
check_cancel_callback=self.isInterruptionRequested
)
-
+
if not success:
self.finished_signal.emit(False, tr("err_360_failed", "Echec de l'extraction 360."))
return
-
+
self.log_signal.emit(tr("status_360_colmap", "Extraction 360 terminée. Passage à COLMAP..."))
-
+
self.engine = ColmapEngine(
self.engine.params, images_dir, self.engine.output_path, "images",
self.engine.fps, self.engine.project_name,
@@ -121,11 +122,11 @@ def run(self):
check_cancel_callback=self.isInterruptionRequested
)
- # 2. Check Upscale
+ # 2. Check Upscale
if self.upscale_params and self.upscale_params.get("active", False):
self.engine.upscale_config = self.upscale_params
self.log_signal.emit(tr("status_upscale_colmap", "--- Upscale activé pour COLMAP ---"))
-
+
success, message = self.engine.run()
self.finished_signal.emit(success, message)
@@ -140,7 +141,7 @@ def __init__(self, input_path, output_path, params, engine=None, project_name=""
self.output_path = output_path
self.params = params
self.project_name = project_name
-
+
def resolve_dataset_root(self, path: Path) -> Path:
"""
Tente de resoudre la racine du dataset si l'utilisateur a selectionne
@@ -149,40 +150,40 @@ def resolve_dataset_root(self, path: Path) -> Path:
# Cas sparse/0 -> remonter de 2 niveaux
if path.name == "0" and path.parent.name == "sparse":
return path.parent.parent
-
+
# Cas sparse -> remonter de 1 niveau
if path.name == "sparse":
return path.parent
-
+
return path
def stop(self):
self.engine.stop()
super().stop()
-
+
def run(self):
try:
- self.log_signal.emit(f"Initialisation BrushWorker...")
+ self.log_signal.emit("Initialisation BrushWorker...")
self.log_signal.emit(f"Input: {self.input_path}")
self.log_signal.emit(f"Output: {self.output_path}")
# Resolution automatique du chemin dataset
resolved_input = self.resolve_dataset_root(Path(self.input_path))
-
+
if str(resolved_input) != str(self.input_path):
self.log_signal.emit(f"Chemin ajusté: {self.input_path} -> {resolved_input}")
-
+
if not resolved_input.exists():
self.finished_signal.emit(False, f"Le dossier dataset n'existe pas: {resolved_input}")
return
# Gestion Refine Auto (Prioritaire sur Init PLY manuel)
refine_mode = self.params.get("refine_mode")
-
+
if refine_mode:
self.log_signal.emit("Mode Raffinement (Refine) activé...")
checkpoints_dir = resolved_input / "checkpoints"
-
+
# 1. Trouver le dernier PLY
latest_ply = None
last_mtime = 0
@@ -193,24 +194,24 @@ def run(self):
if mt > last_mtime:
last_mtime = mt
latest_ply = ply_path
-
+
if latest_ply:
self.log_signal.emit(f"Checkpoint trouvé: {latest_ply.name}")
-
+
# 2. Créer dossier Refine
refine_dir = resolved_input / "Refine"
self.log_signal.emit(f"Préparation du dossier de raffinement: {refine_dir}")
-
+
# Safety check: Ensure refine_dir is inside resolved_input
try:
if refine_dir.exists():
- shutil.rmtree(refine_dir)
+ shutil.rmtree(refine_dir)
refine_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
self.log_signal.emit(f"ERREUR lors de la préparation du dossier Refine: {e}")
self.finished_signal.emit(False, f"Erreur dossier Refine: {e}")
return
-
+
# 3. Copier init.ply
dest_init = refine_dir / "init.ply"
try:
@@ -220,11 +221,17 @@ def run(self):
self.log_signal.emit(f"ERREUR lors de la copie de init.ply: {e}")
self.finished_signal.emit(False, f"Erreur copie init.ply: {e}")
return
-
+
# 4. Symlinks sparse & images
try:
- self.log_signal.emit("Création des liens symboliques pour sparse et images...")
- os.symlink(resolved_input / "sparse", refine_dir / "sparse")
+ self.log_signal.emit("Liaison de sparse et images...")
+ # os.symlink needs admin/Developer Mode on Windows; fall
+ # back to a copy for BOTH links so Refine never crashes.
+ try:
+ os.symlink(resolved_input / "sparse", refine_dir / "sparse")
+ except OSError as e:
+ self.log_signal.emit(f"Symlink sparse échoué ({e}), copie...")
+ shutil.copytree(resolved_input / "sparse", refine_dir / "sparse")
try:
os.symlink(resolved_input / "images", refine_dir / "images")
except OSError as e:
@@ -232,24 +239,24 @@ def run(self):
shutil.copytree(resolved_input / "images", refine_dir / "images")
self.log_signal.emit("Liens symboliques/copies terminés.")
-
+
# 5. Rediriger l'entraînement
resolved_input = refine_dir
self.output_path = refine_dir / "checkpoints"
self.output_path.mkdir(parents=True, exist_ok=True)
self.log_signal.emit(f"Dossier de travail redirigé vers: {refine_dir}")
-
+
except Exception as e:
self.log_signal.emit(f"Erreur fatale lors de la création de l'environnement Refine: {e}")
self.finished_signal.emit(False, f"Erreur env Refine: {e}")
return
-
+
if self.params.get("start_iter", 0) == 0:
detected_iter = self.params.get("total_steps", 30000)
match = re.search(r"iteration_(\d+)", latest_ply.name)
if match:
detected_iter = int(match.group(1))
-
+
self.params["start_iter"] = detected_iter
self.log_signal.emit(f"Refine: Start Iteration réglé sur {detected_iter}")
else:
@@ -278,10 +285,10 @@ def run(self):
self.log_signal.emit("Lancement de la commande Brush...")
# Use refactored train method (Template Method)
returncode = self.engine.train(resolved_input, self.output_path, self.params)
-
+
# Delegate handling to Template Method return logic
success = (returncode == 0)
-
+
if success:
self.handle_ply_rename()
if self.project_name:
@@ -289,7 +296,7 @@ def run(self):
self.finished_signal.emit(True, "Entrainement Brush terminé avec succÚs")
else:
self.finished_signal.emit(False, "Brush a retourné une erreur (voir logs ci-dessus).")
-
+
except Exception as e:
self.log_signal.emit(f"EXCEPTION dans BrushWorker: {e}\n{traceback.format_exc()}")
self.finished_signal.emit(False, f"Exception: {e}")
@@ -304,24 +311,24 @@ def handle_ply_rename(self):
ply_name = Path(ply_name).name
if not ply_name.endswith('.ply'):
ply_name += '.ply'
-
+
output_path = Path(self.output_path)
-
+
last_iter = self.params.get("total_steps", 30000)
search_paths = [
output_path,
output_path / "point_cloud" / f"iteration_{last_iter}",
output_path / "point_cloud" / f"iteration_{last_iter // 2}",
]
-
+
found_ply = None
last_mtime = 0
-
+
# Helper to check a dir
def check_dir(directory: Path):
nonlocal found_ply, last_mtime
if not directory.exists(): return
-
+
for file_path in directory.iterdir():
if file_path.is_file() and file_path.suffix == '.ply' and file_path.name != ply_name:
mt = file_path.stat().st_mtime
@@ -332,7 +339,7 @@ def check_dir(directory: Path):
# 1. Check likely paths first
for path in search_paths:
check_dir(path)
-
+
# 2. If nothing found, fallback to walk
if not found_ply:
for ply_file_path in output_path.rglob("*.ply"):
@@ -369,122 +376,29 @@ def _rename_checkpoints_with_project_name(self):
if renamed:
self.log_signal.emit(f"Checkpoints renommés avec le préfixe '{prefix}' ({renamed} fichiers)")
-class SharpWorker(BaseWorker):
- """Thread worker pour exécuter Apple ML Sharp"""
-
- def __init__(self, input_path, output_path, params, engine=None):
+class CleanerWorker(BaseWorker):
+ """Thread worker pour le nettoyage automatique d'un fichier .ply (splat)."""
+
+ def __init__(self, input_path, output_path, strength="medium", overrides=None):
super().__init__()
- # On importe ici pour eviter les cycles si besoin, ou juste par proprete
- from app.core.sharp_engine import SharpEngine
- # DIP : Injection
- self.engine = engine or SharpEngine(logger_callback=self.log_signal.emit)
self.input_path = input_path
self.output_path = output_path
- self.params = params
-
- def stop(self):
- self.engine.stop()
- super().stop()
-
- def run(self):
- try:
- # Handle Upscale
- if self.params.get("upscale", False):
- from app.upscayl_manager import run_upscayl, find_binary
- if find_binary():
- self.log_signal.emit(tr("status_upscaling", "--- Upscale Image ---"))
- input_path = Path(self.input_path)
- output_path = Path(self.output_path)
- if input_path.is_file():
- temp_dir = output_path / "temp_upscale"
- temp_dir.mkdir(parents=True, exist_ok=True)
- fmt = self.params.get("format", "png")
- model_id = self.params.get("model_id") or ""
- if not model_id:
- from app.upscayl_models import get_downloaded_models
- from app.upscayl_manager import get_models_dir
- _dl = get_downloaded_models(get_models_dir())
- model_id = _dl[0].id if _dl else ""
- if model_id:
- # upscayl-bin operates on folders; use a temp input folder
- tmp_in = temp_dir / "_in"
- tmp_in.mkdir(exist_ok=True)
- shutil.copy2(input_path, tmp_in / input_path.name)
- upscale_params = {
- "model_id": model_id,
- "scale": self.params.get("scale", 4),
- "format": fmt,
- "tile": self.params.get("tile", 0),
- "tta": self.params.get("tta", False),
- "compression": self.params.get("compression", 0),
- }
- success = [False]
- run_upscayl(str(tmp_in), str(temp_dir), upscale_params,
- log_callback=self.log_signal.emit,
- done_callback=lambda ok: success.__setitem__(0, ok),
- cancel_check=self.isInterruptionRequested)
- upscaled_path = temp_dir / (input_path.stem + "." + fmt)
- if success[0] and upscaled_path.exists():
- self.input_path = str(upscaled_path)
- self.log_signal.emit(tr("status_upscale_done", "Upscale done. Launching Sharp..."))
- else:
- self.log_signal.emit(tr("err_upscale_failed", "Upscale failed. Using original image."))
- else:
- self.log_signal.emit("â Upscale activé mais aucun modÚle disponible â ignoré.")
- else:
- self.log_signal.emit(tr("err_upscale_folder", "Folder upscale not supported in Sharp mode."))
- else:
- self.log_signal.emit(tr("err_upscale_missing", "Error: Upscale requested but upscayl-bin not found."))
-
- # Use refactored predict method
- self.status_signal.emit(tr("status_sharp", "Amélioration avec ML Sharp..."))
-
- # Délégation à la Template Method
- returncode = self.engine.predict(self.input_path, self.output_path, self.params)
- success = (returncode == 0)
-
- self.finished_signal.emit(success, "Prédiction Sharp terminée." if success else "Sharp a retourné une erreur (voir logs).")
- except Exception as e:
- self.finished_signal.emit(False, str(e))
+ self.strength = strength
+ self.overrides = overrides or {}
+ self.stats = None
-class SharpVideoWorker(BaseWorker):
- """Thread worker for executing Apple ML Sharp on a sequence of frames from a video."""
-
- def __init__(self, video_path, output_path, params, engine=None):
- super().__init__()
- from app.core.sharp_engine import SharpEngine
- self.engine = engine or SharpEngine(logger_callback=self.log_signal.emit)
- self.video_path = video_path
- self.output_path = output_path
- self.params = params
-
- def stop(self):
- self.engine.stop()
- super().stop()
-
def run(self):
- """Process video frames using the shared SharpEngine.process_video_frames pipeline."""
try:
- self.status_signal.emit(tr("sharp_msg_extract_frames"))
- self.log_signal.emit(tr("sharp_msg_extract_frames"))
-
- success_count = self.engine.process_video_frames(
- video_path=self.video_path,
- output_dir=self.output_path,
- params=self.params,
- log_callback=self.log_signal.emit,
- status_callback=self.status_signal.emit,
- progress_callback=self.progress_signal.emit,
- cancel_check=self.isInterruptionRequested,
+ from app.core.ply_cleaner import clean_ply
+ self.status_signal.emit(tr("status_cleaning", "Nettoyage du splat..."))
+ self.stats = clean_ply(
+ self.input_path, self.output_path,
+ strength=self.strength, overrides=self.overrides,
+ log=self.log_signal.emit,
)
-
- if success_count > 0:
- self.finished_signal.emit(True, f"Conversion Video -> PLY terminée. {success_count} frames traitées avec succÚs.")
- else:
- self.finished_signal.emit(False, "Aucune frame n'a pu être traitée par SHARP.")
-
+ self.finished_signal.emit(True, tr("status_clean_done", "Nettoyage terminé."))
except Exception as e:
- self.log_signal.emit(f"EXCEPTION: {e}\n{traceback.format_exc()}")
+ self.log_signal.emit(f"â {e}")
self.finished_signal.emit(False, str(e))
@@ -493,6 +407,7 @@ def run(self):
# ---------------------------------------------------------------------
from app.core.four_dgs_engine import FourDGSEngine
+
class FourDGSWorker(BaseWorker):
def __init__(self, videos_dir, output_dir, fps=5, engine=None):
super().__init__()
@@ -508,14 +423,14 @@ def __init__(self, videos_dir, output_dir, fps=5, engine=None):
def run(self):
self.log_signal.emit("--- Démarrage 4DGS ---")
-
+
try:
if self.videos_dir:
success = self.engine.process_dataset(self.videos_dir, self.output_dir, self.fps)
else:
# COLMAP ONLY MODE
success = self.engine.run_colmap(self.output_dir)
-
+
self.finished_signal.emit(success, "Dataset 4DGS créé avec succÚs." if success else "Ãchec du traitement 4DGS.")
except Exception as e:
self.finished_signal.emit(False, str(e))
diff --git a/app/scripts/__init__.py b/app/scripts/__init__.py
index 20eb9e0..32c3657 100644
--- a/app/scripts/__init__.py
+++ b/app/scripts/__init__.py
@@ -3,13 +3,12 @@
# External code should import from app.scripts.installers.* or
# app.scripts.setup_dependencies directly to avoid import ordering warnings.
from app.scripts.installers.base import (
+ DependencyManager,
EngineDependency,
PipEngine,
- DependencyManager,
)
from app.scripts.installers.brush import BrushEngineDep
-from app.scripts.installers.sharp import SharpEngineDep
-from app.scripts.installers.mapping import ColmapBrewDep, GlomapEngineDep
-from app.scripts.installers.supersplat import SuperSplatEngineDep
from app.scripts.installers.extractor_360 import Extractor360EngineDep
+from app.scripts.installers.mapping import ColmapEngineDep, FfmpegEngineDep, GlomapEngineDep
+from app.scripts.installers.supersplat import SuperSplatEngineDep
from app.scripts.installers.upscayl import UpscaylEngineDep
diff --git a/app/scripts/checksums.json b/app/scripts/checksums.json
index f0e4560..515ba95 100644
--- a/app/scripts/checksums.json
+++ b/app/scripts/checksums.json
@@ -1,11 +1,7 @@
{
- "_instructions": "Calculated via: shasum -a 256 . Update after each binary upgrade. For rustup, download the script first: curl -fsSL https://sh.rustup.rs -o rustup-init.sh && shasum -a 256 rustup-init.sh",
- "darwin_brush": "7742e8ac44ef4bdcc299e3e79dba7ac5d05b3f45f68a66dc1054029c23a281dc",
- "linux_brush": "",
- "darwin_upscayl": "b7f54f362fc10d5f334e587fb90917e95a5557ec1cfefbddce78657dd3fee055",
- "linux_upscayl": "",
- "darwin_glomap": "23c7983ee6e1d62dd96d43cec40803e457b1325424b2c28d13aed22901414aba",
- "linux_glomap": "",
- "darwin_rustup": "6c30b75a75b28a96fd913a037c8581b580080b6ee9b8169a3c0feb1af7fe8caf",
- "linux_rustup": "6c30b75a75b28a96fd913a037c8581b580080b6ee9b8169a3c0feb1af7fe8caf"
+ "_instructions": "Calculated via: certutil -hashfile SHA256 (Windows) or shasum -a 256 . Update after each binary upgrade. For rustup, download https://win.rustup.rs/x86_64 first then hash it.",
+ "windows_brush": "",
+ "windows_upscayl": "",
+ "windows_glomap": "",
+ "windows_rustup": ""
}
diff --git a/app/scripts/installers/__init__.py b/app/scripts/installers/__init__.py
index 9d7f2fb..2bab01f 100644
--- a/app/scripts/installers/__init__.py
+++ b/app/scripts/installers/__init__.py
@@ -1,31 +1,29 @@
-"""Installers package â engine dependency management for CorbeauSplat."""
+"""Installers package â engine dependency management for CorbeauSplat (Windows/CUDA)."""
from app.scripts.installers.base import (
+ DependencyManager,
EngineDependency,
PipEngine,
- DependencyManager,
)
from app.scripts.installers.brush import BrushEngineDep
-from app.scripts.installers.sharp import SharpEngineDep
-from app.scripts.installers.mapping import ColmapBrewDep, GlomapEngineDep
-from app.scripts.installers.supersplat import SuperSplatEngineDep
from app.scripts.installers.extractor_360 import Extractor360EngineDep
-from app.scripts.installers.upscayl import UpscaylEngineDep
+from app.scripts.installers.mapping import ColmapEngineDep, FfmpegEngineDep, GlomapEngineDep
+from app.scripts.installers.supersplat import SuperSplatEngineDep
from app.scripts.installers.tools import (
- load_config,
- relax_requirements,
- get_remote_version,
- get_local_version,
- save_local_version,
check_cargo,
- check_brew,
- check_node,
check_cmake_ninja,
- check_xcode_tools,
- install_node_js,
+ check_node,
+ check_winget,
+ get_local_version,
+ get_remote_version,
install_build_tools,
+ install_node_js,
install_rust_toolchain,
install_system_dependencies,
+ load_config,
+ relax_requirements,
+ save_local_version,
)
+from app.scripts.installers.upscayl import UpscaylEngineDep
__all__ = [
# Base classes
@@ -34,8 +32,8 @@
"DependencyManager",
# Engine dependencies
"BrushEngineDep",
- "SharpEngineDep",
- "ColmapBrewDep",
+ "ColmapEngineDep",
+ "FfmpegEngineDep",
"GlomapEngineDep",
"SuperSplatEngineDep",
"Extractor360EngineDep",
@@ -47,10 +45,9 @@
"get_local_version",
"save_local_version",
"check_cargo",
- "check_brew",
+ "check_winget",
"check_node",
"check_cmake_ninja",
- "check_xcode_tools",
"install_node_js",
"install_build_tools",
"install_rust_toolchain",
diff --git a/app/scripts/installers/base.py b/app/scripts/installers/base.py
index cf708d2..84b4ad4 100644
--- a/app/scripts/installers/base.py
+++ b/app/scripts/installers/base.py
@@ -1,9 +1,9 @@
"""Base classes for engine dependency management."""
-import os
-import sys
import json
+import os
import shutil
import subprocess
+import sys
from pathlib import Path
from app.core.system import resolve_project_root
@@ -13,6 +13,8 @@ class EngineDependency:
"""Represents an external engine (Colmap, Glomap, Brush, etc.)"""
auto_update_default = False # Subclasses can override to enable auto-update by default
ask_before_update = False # If True, prompt user at startup before updating
+ install_on_startup = True # If False, only auto-installs when enabled in config
+ # (for heavy/optional engines built from source)
def on_startup_ready(self):
"""Called at startup when the engine is installed and up to date."""
@@ -101,13 +103,13 @@ def create_venv(self, python_cmd=sys.executable):
if not self.venv_dir.exists():
print(f"Creating venv: {self.venv_dir}")
subprocess.check_call([python_cmd, "-m", "venv", str(self.venv_dir)])
-
+
# Ensure pip is present (sometimes venv is created --without-pip on some systems)
try:
subprocess.check_call([str(self.python_bin), "-m", "ensurepip", "--upgrade"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
pass
-
+
# Upgrade pip
try:
subprocess.check_call([str(self.python_bin), "-m", "pip", "install", "--upgrade", "pip", "--no-input"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -149,18 +151,24 @@ def main_install(self, check_only=False, startup=False):
print("--- System Dependency Check ---")
install_system_dependencies(check_only=check_only or startup)
-
+
config = self.get_config()
missing_engines_startup = False
-
+
for name, engine in self.engines.items():
# OCP : Le moteur decide s'il est active
enabled = engine.is_enabled_in_config(config)
-
+
# During --check or --startup, we audit everything. During install, we respect enablement.
if not enabled and not (check_only or startup):
continue
+ # Heavy/optional engines (e.g. source-built Glomap, 360 Extractor) must
+ # not auto-install at startup unless the user has enabled them. They
+ # install lazily when their feature is turned on.
+ if startup and not enabled and not engine.install_on_startup:
+ continue
+
remote = engine.get_remote_version()
local = engine.get_local_version()
# Normalize local version for comparison (strip build-mode suffixes like -source)
@@ -180,12 +188,11 @@ def main_install(self, check_only=False, startup=False):
else:
print(f">>> Auto-installing missing engine [{name}]...")
engine.install()
-
+
# Report status for check/startup
if not engine.is_installed():
status = f" â {name.capitalize()}: Missing"
- if startup: print(status)
- elif check_only: print(status)
+ if startup or check_only: print(status)
missing_engines_startup = True
elif remote and local and remote != local_clean:
@@ -198,7 +205,7 @@ def main_install(self, check_only=False, startup=False):
if startup and engine.ask_before_update:
print(f"\n>>> Mise à jour disponible pour {name.capitalize()} ({local_clean} â {remote})")
try:
- answer = input(f" Mettre à jour maintenant ? (o/n) : ").strip().lower()
+ answer = input(" Mettre à jour maintenant ? (o/n) : ").strip().lower()
except EOFError:
answer = "n"
if answer in ("o", "y", "oui", "yes"):
diff --git a/app/scripts/installers/brush.py b/app/scripts/installers/brush.py
index 9146422..ce3af7c 100644
--- a/app/scripts/installers/brush.py
+++ b/app/scripts/installers/brush.py
@@ -1,16 +1,15 @@
"""Brush engine dependency installer."""
-import os
-import sys
import json
+import os
import shutil
import subprocess
+import sys
from pathlib import Path
from app.scripts.checksum_verifier import load_expected_checksums, verify_download
from app.scripts.installers.base import EngineDependency
from app.scripts.installers.tools import install_rust_toolchain
-
BRUSH_REPO = "https://github.com/ArthurBrussee/brush.git"
@@ -19,6 +18,9 @@ class BrushEngineDep(EngineDependency):
def __init__(self):
super().__init__("brush", BRUSH_REPO)
+ # On Windows the binary must keep its .exe extension to be launchable.
+ if sys.platform == "win32":
+ self.bin_path = self.engines_dir / "brush.exe"
def is_enabled_in_config(self, config: dict) -> bool:
return config.get("brush_params", {}).get("enabled", False) or config.get("brush_enabled", False)
@@ -35,8 +37,8 @@ def get_remote_version(self) -> str:
if build_mode == "source":
return self._get_head_commit()
- import urllib.request
import json as _json
+ import urllib.request
try:
req = urllib.request.Request(
"https://api.github.com/repos/ArthurBrussee/brush/releases/latest",
@@ -116,8 +118,8 @@ def install(self):
def _install_from_release(self, version: str) -> bool:
import platform
- import urllib.request
import tarfile
+ import urllib.request
import zipfile
system = platform.system()
@@ -140,10 +142,11 @@ def _install_from_release(self, version: str) -> bool:
archive_path = self.engines_dir / f"brush-app-{platform_suffix}"
try:
- req = urllib.request.Request(release_url)
- with urllib.request.urlopen(req, timeout=120) as resp:
- with open(str(archive_path), "wb") as f:
- f.write(resp.read())
+ import shutil
+ req = urllib.request.Request(release_url, headers={"User-Agent": "CorbeauSplat"})
+ # Stream â the Brush binary is tens of MB; avoid buffering in RAM.
+ with urllib.request.urlopen(req, timeout=120) as resp, open(str(archive_path), "wb") as f:
+ shutil.copyfileobj(resp, f)
except Exception as e:
print(f"â ïž Download failed: {e}")
if archive_path.exists():
@@ -151,7 +154,10 @@ def _install_from_release(self, version: str) -> bool:
return False
checksums = load_expected_checksums()
- checksum_key = "darwin_brush" if system == "Darwin" else "linux_brush"
+ checksum_key = {
+ "Windows": "windows_brush",
+ "Darwin": "darwin_brush",
+ }.get(system, "linux_brush")
if not verify_download(archive_path, checksums.get(checksum_key, "")):
print(f"â ïž Brush archive SHA256 mismatch (checksum key: {checksum_key}). Continuing anyway.")
@@ -201,7 +207,9 @@ def _is_safe_member(name: str, dest: Path) -> bool:
shutil.rmtree(str(extract_dir), ignore_errors=True)
return False
- dest = self.engines_dir / "brush"
+ dest = self.bin_path
+ if dest.exists():
+ dest.unlink()
shutil.move(str(extracted_bin), str(dest))
shutil.rmtree(str(extract_dir), ignore_errors=True)
@@ -272,7 +280,9 @@ def _install_from_source(self, head_ref: str) -> bool:
for name in ["brush-app", "brush_app", "brush", "brush-app.exe", "brush_app.exe"]:
src = bin_dir / name
if src.exists():
- shutil.move(str(src), str(self.engines_dir / "brush"))
+ if self.bin_path.exists():
+ self.bin_path.unlink()
+ shutil.move(str(src), str(self.bin_path))
moved = True
break
shutil.rmtree(str(bin_dir), ignore_errors=True)
diff --git a/app/scripts/installers/extractor_360.py b/app/scripts/installers/extractor_360.py
index 66f679f..f0e241f 100644
--- a/app/scripts/installers/extractor_360.py
+++ b/app/scripts/installers/extractor_360.py
@@ -1,15 +1,13 @@
"""Extractor 360 engine dependency installer."""
-import subprocess
-from pathlib import Path
from app.scripts.installers.base import PipEngine
-
EXTRACTOR_360_REPO = "https://github.com/nicolasdiolez/360Extractor"
class Extractor360EngineDep(PipEngine):
ask_before_update = True
+ install_on_startup = False # Heavy venv (YOLO/PySide6); only when the 360 mode is enabled
def __init__(self):
super().__init__("extractor_360", EXTRACTOR_360_REPO, ".venv_360")
diff --git a/app/scripts/installers/mapping.py b/app/scripts/installers/mapping.py
index c5a44e9..f718cc3 100644
--- a/app/scripts/installers/mapping.py
+++ b/app/scripts/installers/mapping.py
@@ -1,138 +1,250 @@
-"""COLMAP and Glomap engine dependency installers."""
-import os
-import re
-import sys
+"""COLMAP, FFmpeg and Glomap engine dependency installers (Windows/CUDA)."""
import json
+import os
import shutil
import subprocess
-from pathlib import Path
+import urllib.request
+from app.core.system import has_cuda
from app.scripts.installers.base import EngineDependency
-from app.scripts.installers.tools import check_cmake_ninja, check_xcode_tools, install_build_tools
-
+from app.scripts.installers.tools import (
+ check_cmake_ninja,
+ download_and_extract_zip,
+ install_build_tools,
+)
GLOMAP_REPO = "https://github.com/colmap/glomap.git"
-
-
-class ColmapBrewDep(EngineDependency):
- """COLMAP géré via Homebrew â vérifie la version et met à jour si nécessaire"""
- ask_before_update = True
+COLMAP_RELEASES_API = "https://api.github.com/repos/colmap/colmap/releases/latest"
+# Static "latest release essentials" build â stable URL, contains bin/ffmpeg.exe
+FFMPEG_ZIP_URL = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
+
+
+def find_colmap_windows_asset(assets: list, prefer_cuda: bool = True) -> dict | None:
+ """Selects the Windows COLMAP release asset.
+
+ Prefers the CUDA build (`*-windows-cuda.zip`) and explicitly avoids the
+ `nocuda` build. Falls back to any Windows .zip.
+ """
+ def is_zip(a):
+ return a.get("name", "").lower().endswith(".zip")
+
+ if prefer_cuda:
+ for a in assets:
+ name = a.get("name", "").lower()
+ if "windows" in name and "cuda" in name and "nocuda" not in name and is_zip(a):
+ return a
+ # Fallback: any windows zip (nocuda or generic)
+ for a in assets:
+ name = a.get("name", "").lower()
+ if "windows" in name and is_zip(a):
+ return a
+ return None
+
+
+class ColmapEngineDep(EngineDependency):
+ """COLMAP on Windows â auto-downloads the pre-built CUDA release into engines/.
+
+ The CUDA build (`colmap-x64-windows-cuda.zip`) is fetched from GitHub
+ releases and extracted into ``engines/colmap``. ``resolve_binary("colmap")``
+ then finds ``colmap.exe`` anywhere in that subtree.
+ """
+ ask_before_update = False
+ # Minimum COLMAP we want installed. 4.1.0 ships GPU bundle adjustment
+ # ("Caspar", fixes "Linear solver failure" on big scenes) and the native
+ # 360 / EQUIRECTANGULAR camera model. An older local build is auto-upgraded.
+ REQUIRED_MIN = "4.1.0"
def __init__(self):
super().__init__("colmap")
+ self.target_dir = self.engines_dir / "colmap"
+
+ @staticmethod
+ def _version_tuple(tag: str) -> tuple:
+ import re
+ nums = re.findall(r"\d+", tag or "")
+ return tuple(int(n) for n in nums[:3]) if nums else (0,)
def is_installed(self) -> bool:
- return shutil.which("colmap") is not None
+ from app.core.system import resolve_binary
+ return resolve_binary("colmap") is not None
def is_enabled_in_config(self, config: dict) -> bool:
- return sys.platform == "darwin" and shutil.which("brew") is not None
+ return True # COLMAP is required by the core pipeline
- def get_local_version(self) -> str:
+ def _fetch_latest(self) -> dict | None:
try:
- out = subprocess.check_output(
- ["brew", "list", "--versions", "colmap"],
- text=True, stderr=subprocess.DEVNULL
- ).strip()
- parts = out.split()
- if len(parts) >= 2:
- # Strip Homebrew revision suffix (e.g., 4.0.4_2 â 4.0.4)
- ver = parts[1]
- return re.split(r'_\d+$', ver)[0]
- return ""
- except (subprocess.CalledProcessError, OSError):
- return ""
-
- def get_remote_version(self) -> str:
- try:
- out = subprocess.check_output(
- ["brew", "info", "--json", "colmap"],
- text=True, stderr=subprocess.DEVNULL, timeout=10
+ req = urllib.request.Request(
+ COLMAP_RELEASES_API,
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "CorbeauSplat"},
)
- data = json.loads(out)
- if data and isinstance(data, list):
- return data[0].get("versions", {}).get("stable", "")
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
except Exception as e:
- print(f"â ïž Could not fetch latest COLMAP version: {e}")
- return ""
+ print(f"â ïž Could not fetch COLMAP release info: {e}")
+ return None
+
+ def get_remote_version(self) -> str:
+ data = self._fetch_latest()
+ return data.get("tag_name", "") if data else ""
def install(self):
- if not shutil.which("brew"):
- print("â Homebrew requis pour mettre à jour COLMAP.")
+ local = self.get_local_version()
+ if self.is_installed() and local:
+ # Already installed â only re-download if it's older than the
+ # minimum we need (e.g. a pre-4.1.0 build without GPU BA / 360).
+ if self._version_tuple(local) >= self._version_tuple(self.REQUIRED_MIN):
+ return
+ print(f">>> COLMAP {local} < {self.REQUIRED_MIN} requis "
+ f"(GPU bundle adjustment + 360 natif) â mise à jour...")
+
+ data = self._fetch_latest()
+ if not data:
+ print("â Impossible de contacter GitHub pour télécharger COLMAP.")
return
- try:
- if self.is_installed():
- print("Mise à jour de COLMAP via Homebrew...")
- subprocess.check_call(["brew", "upgrade", "colmap"])
- else:
- print("Installation de COLMAP via Homebrew...")
- subprocess.check_call(["brew", "install", "colmap"])
- except subprocess.CalledProcessError:
- print("â ïž brew upgrade/install colmap a échoué (peut-être déjà à jour).")
+
+ asset = find_colmap_windows_asset(data.get("assets", []), prefer_cuda=has_cuda())
+ if not asset:
+ print("â Aucun binaire COLMAP Windows trouvé dans la derniÚre release.")
+ print(" Téléchargez-le manuellement : https://github.com/colmap/colmap/releases")
+ return
+
+ tag = data.get("tag_name", "")
+ print(f">>> Installation automatique de COLMAP {tag} ({asset['name']})...")
+
+ # Clean any previous extraction so updates don't accumulate
+ if self.target_dir.exists():
+ shutil.rmtree(str(self.target_dir), ignore_errors=True)
+
+ if not download_and_extract_zip(asset["browser_download_url"], self.target_dir):
+ print("â Ãchec du téléchargement/extraction de COLMAP.")
+ return
+
+ if self.is_installed():
+ self.save_local_version(tag)
+ print(f"â
COLMAP {tag} installé dans {self.target_dir}.")
+ else:
+ print("â ïž COLMAP extrait mais colmap.exe introuvable dans l'archive.")
+
+
+class FfmpegEngineDep(EngineDependency):
+ """FFmpeg on Windows â auto-downloads a static build into engines/ffmpeg.
+
+ Tries `winget` first (if available) for a system-wide install, then falls
+ back to extracting a static "essentials" build into ``engines/ffmpeg``.
+ """
+ ask_before_update = False
+
+ def __init__(self):
+ super().__init__("ffmpeg")
+ self.target_dir = self.engines_dir / "ffmpeg"
+
+ def is_installed(self) -> bool:
+ from app.core.system import resolve_binary
+ return resolve_binary("ffmpeg") is not None
+
+ def is_enabled_in_config(self, config: dict) -> bool:
+ return True # FFmpeg is required for video input
+
+ def get_remote_version(self) -> str:
+ return "" # No version tracking for the static build
+
+ def install(self):
+ # is_installed() also matches a system ffmpeg on PATH (e.g. via winget),
+ # so we only download a self-contained build when none is present.
+ if self.is_installed():
+ return
+
+ print(">>> Installation automatique de FFmpeg (build statique) dans engines/...")
+ if self.target_dir.exists():
+ shutil.rmtree(str(self.target_dir), ignore_errors=True)
+ if not download_and_extract_zip(FFMPEG_ZIP_URL, self.target_dir):
+ print("â Ãchec du téléchargement de FFmpeg. Installez-le manuellement et ajoutez-le au PATH.")
+ return
+
+ if self.is_installed():
+ self.save_local_version("essentials")
+ print(f"â
FFmpeg installé dans {self.target_dir}.")
+ else:
+ print("â ïž FFmpeg extrait mais ffmpeg.exe introuvable dans l'archive.")
class GlomapEngineDep(EngineDependency):
ask_before_update = True
+ install_on_startup = False # Source build (MSVC + CUDA); only when use_glomap is on
def __init__(self):
super().__init__("glomap", GLOMAP_REPO)
- # Fix: source code is in a separate dir, not replacing the binary
+ # Source code lives in a separate dir, not replacing the binary
self.target_dir = self.engines_dir / "glomap-source"
def is_enabled_in_config(self, config: dict) -> bool:
return config.get("params", {}).get("use_glomap", False)
+ def is_installed(self) -> bool:
+ from app.core.system import resolve_binary
+ if resolve_binary("glomap") is not None:
+ return True
+ # COLMAP 4.0+ ships a built-in `global_mapper` (GLOMAP merged into COLMAP),
+ # so no separate glomap build is needed when colmap advertises it.
+ colmap = resolve_binary("colmap")
+ if colmap:
+ try:
+ out = subprocess.run([colmap, "help"], capture_output=True,
+ text=True, timeout=15)
+ if "global_mapper" in (out.stdout + out.stderr):
+ return True
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return False
+
def install(self):
- if sys.platform == "darwin" and not check_xcode_tools():
- print("Xcode Command Line Tools required.")
- return
-
if not check_cmake_ninja():
- if not install_build_tools(): return
-
+ if not install_build_tools():
+ print("â ïž CMake/Ninja requis pour compiler Glomap. Installez-les et relancez.")
+ return
+
self.update_git()
- # Source dir is now handled by update_git via self.target_dir
source_dir = self.target_dir
build_dir = source_dir / "build"
- # Fix CMakeCache error by cleaning build dir if it exists
if build_dir.exists():
shutil.rmtree(str(build_dir))
build_dir.mkdir(exist_ok=True)
-
- cmake_args = ["cmake", "..", "-GNinja", "-DCMAKE_BUILD_TYPE=Release"]
+
+ # GLOMAP fetches/builds its own COLMAP. On Windows we let CMake pick the
+ # default (MSVC) generator; CUDA is auto-detected by COLMAP's CMake.
+ cmake_args = ["cmake", "..", "-DCMAKE_BUILD_TYPE=Release", "-DFETCH_COLMAP=ON"]
env = os.environ.copy()
- if sys.platform == "darwin":
- # GLOMAP builds its own COLMAP via FETCH_COLMAP. The Homebrew COLMAP
- # CMake config does not export the colmap::colmap target, and the
- # SQLite crash that originally motivated -DFETCH_COLMAP=OFF is now
- # handled by _convert_db_journal_mode() in the pipeline.
- cmake_args = ["cmake", "..", "-GNinja", "-DCMAKE_BUILD_TYPE=Release", "-DFETCH_COLMAP=ON"]
+ try:
+ subprocess.check_call(cmake_args, cwd=str(build_dir), env=env)
+ subprocess.check_call(
+ ["cmake", "--build", ".", "--config", "Release"],
+ cwd=str(build_dir), env=env,
+ )
+ except (subprocess.CalledProcessError, OSError) as e:
+ print(f"â ïž Glomap build failed: {e}")
+ return
- try:
- libomp = subprocess.check_output(["brew", "--prefix", "libomp"], text=True).strip()
- include_p = f"{libomp}/include"
- lib_p = f"{libomp}/lib"
- cmake_args.extend([
- f"-DOpenMP_ROOT={libomp}",
- "-DOpenMP_C_FLAGS=-Xpreprocessor -fopenmp",
- "-DOpenMP_CXX_FLAGS=-Xpreprocessor -fopenmp"
- ])
- env["LDFLAGS"] = f"-L{lib_p} -lomp"
- env["CPPFLAGS"] = f"-I{include_p} -Xpreprocessor -fopenmp"
- except (subprocess.CalledProcessError, OSError) as e:
- print(f"â ïž Could not detect libomp via brew: {e}")
-
- subprocess.check_call(cmake_args, cwd=str(build_dir), env=env)
- subprocess.check_call(["ninja"], cwd=str(build_dir), env=env)
-
- # Binary name is glomap
+ # Locate the built binary (glomap.exe on Windows)
built_bin = None
- for p in [build_dir / "glomap" / "glomap", build_dir / "glomap"]:
- if p.exists() and not p.is_dir():
- built_bin = p
+ for pattern in ("glomap.exe", "glomap"):
+ for found in build_dir.rglob(pattern):
+ if found.is_file():
+ built_bin = found
+ break
+ if built_bin:
break
-
+
if built_bin:
- shutil.copy2(str(built_bin), str(self.engines_dir / "glomap"))
- self.save_local_version(self.get_remote_version())
+ dest = self.engines_dir / ("glomap.exe" if built_bin.suffix == ".exe" else "glomap")
+ shutil.copy2(str(built_bin), str(dest))
+ # Only record success if the copy actually landed â otherwise a failed
+ # copy would be marked "installed" and skip rebuilds forever.
+ if dest.exists() and dest.stat().st_size > 0:
+ self.save_local_version(self.get_remote_version())
+ print(f"â
Glomap installé : {dest}")
+ else:
+ print("â ïž Glomap compilé mais la copie du binaire a échoué.")
+ else:
+ print("â ïž Glomap compilé mais binaire introuvable dans le dossier build.")
diff --git a/app/scripts/installers/sharp.py b/app/scripts/installers/sharp.py
deleted file mode 100644
index c5e67cf..0000000
--- a/app/scripts/installers/sharp.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""Sharp engine dependency installer."""
-import shutil
-import subprocess
-from pathlib import Path
-
-from app.scripts.installers.base import PipEngine
-from app.scripts.installers.tools import relax_requirements
-
-
-SHARP_REPO = "https://github.com/apple/ml-sharp.git"
-
-
-class SharpEngineDep(PipEngine):
- ask_before_update = True
-
- def __init__(self):
- super().__init__("sharp", SHARP_REPO, ".venv_sharp")
-
- def is_enabled_in_config(self, config: dict) -> bool:
- return config.get("sharp_params", {}).get("enabled", False) or config.get("sharp_enabled", False)
-
- def install(self):
- self.update_git()
- # Sharp needs 3.11/3.10 ideally
- py311 = shutil.which("python3.11") or shutil.which("python3.10")
- if not py311:
- print("Python 3.11/3.10 missing for Sharp.")
- return
-
- self.create_venv(py311)
- req_file = self.target_dir / "requirements.txt"
- if req_file.exists():
- loose = self.target_dir / "requirements_loose.txt"
- relax_requirements(str(req_file), str(loose))
- self.pip_install(["-r", str(loose)], cwd=str(self.target_dir))
-
- if (self.target_dir / "setup.py").exists() or (self.target_dir / "pyproject.toml").exists():
- self.pip_install(["-e", "."], cwd=str(self.target_dir))
-
- self.save_local_version(self.get_remote_version())
diff --git a/app/scripts/installers/supersplat.py b/app/scripts/installers/supersplat.py
index d2a865c..3a2f0fe 100644
--- a/app/scripts/installers/supersplat.py
+++ b/app/scripts/installers/supersplat.py
@@ -1,4 +1,5 @@
"""SuperSplat engine dependency installer."""
+import os
import shutil
import subprocess
from pathlib import Path
@@ -6,10 +7,17 @@
from app.scripts.installers.base import EngineDependency
from app.scripts.installers.tools import install_node_js
-
SUPERPLAT_REPO = "https://github.com/playcanvas/supersplat.git"
+def _npm(args):
+ """Build an npm command. On Windows npm is a .cmd shim that CreateProcess
+ cannot launch directly, so route it through cmd.exe."""
+ if os.name == "nt":
+ return ["cmd", "/c", "npm", *args]
+ return ["npm", *args]
+
+
class SuperSplatEngineDep(EngineDependency):
ask_before_update = True
@@ -28,7 +36,7 @@ def _git_is_own_repo(self) -> bool:
def _npm_install(self):
target = str(self.target_dir)
- result = subprocess.run(["npm", "install"], cwd=target)
+ result = subprocess.run(_npm(["install"]), cwd=target)
if result.returncode == 0:
return
# npm bug #4828: optional deps fail silently, leaving native modules missing.
@@ -38,7 +46,7 @@ def _npm_install(self):
lock = self.target_dir / "package-lock.json"
if lock.exists():
lock.unlink()
- subprocess.check_call(["npm", "install"], cwd=target)
+ subprocess.check_call(_npm(["install"]), cwd=target)
def install(self):
if not shutil.which("node"):
@@ -59,5 +67,5 @@ def install(self):
self.update_git()
self._npm_install()
- subprocess.check_call(["npm", "run", "build"], cwd=str(self.target_dir))
+ subprocess.check_call(_npm(["run", "build"]), cwd=str(self.target_dir))
self.save_local_version(self.get_remote_version())
diff --git a/app/scripts/installers/tools.py b/app/scripts/installers/tools.py
index 38057df..bc1c645 100644
--- a/app/scripts/installers/tools.py
+++ b/app/scripts/installers/tools.py
@@ -1,16 +1,12 @@
-"""Utility functions for dependency management â installers, checkers, helpers."""
+"""Utility functions for dependency management â installers, checkers, helpers (Windows)."""
+import json
import os
-import re
-import sys
import shutil
import subprocess
-import json
from pathlib import Path
-from app.core.system import resolve_project_root
from app.scripts.checksum_verifier import load_expected_checksums, verify_download
-
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Config and requirements helpers
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
@@ -27,7 +23,7 @@ def load_config():
def relax_requirements(src, dst):
"""Refactor utils: Relax strict torch deps"""
- with open(src, 'r') as f_in, open(dst, 'w') as f_out:
+ with open(src) as f_in, open(dst, 'w') as f_out:
for line in f_in:
if line.strip().startswith('torch==') or line.strip().startswith('torchvision=='):
line = line.replace('==', '>=')
@@ -75,8 +71,9 @@ def check_cargo():
return shutil.which("cargo") is not None
-def check_brew():
- return shutil.which("brew") is not None
+def check_winget():
+ """Returns True if the Windows Package Manager (winget) is available."""
+ return shutil.which("winget") is not None
def check_node():
@@ -87,60 +84,98 @@ def check_cmake_ninja():
return shutil.which("cmake") is not None and shutil.which("ninja") is not None
-def check_xcode_tools():
- """Checks if Xcode Command Line Tools are installed (macOS only)"""
- if sys.platform != "darwin": return True
- try:
- # xcode-select -p prints the path if installed, or exits with error
- subprocess.check_call(["xcode-select", "-p"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
- return True
- except (subprocess.CalledProcessError, OSError):
- return False
-
-
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Installers helpers
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
-def install_node_js():
- print("Installing Node.js via Homebrew...")
+def refresh_windows_path():
+ """Reload PATH from the registry so tools installed this session (via winget)
+ become visible to the current process. No-op on non-Windows.
+
+ Windows updates the persistent PATH in the registry but does not propagate it
+ to already-running processes, so a freshly winget-installed cmake/node/etc. is
+ invisible to subprocess calls until the shell is restarted â unless we refresh.
+ """
+ if os.name != "nt":
+ return
try:
- subprocess.check_call(["brew", "install", "node"])
- return True
- except (subprocess.CalledProcessError, OSError) as e:
- print(f"Error installing Node.js: {e}")
- return False
+ import winreg
+ parts = []
+ for root, sub in (
+ (winreg.HKEY_LOCAL_MACHINE,
+ r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
+ (winreg.HKEY_CURRENT_USER, "Environment"),
+ ):
+ try:
+ with winreg.OpenKey(root, sub) as key:
+ val, _ = winreg.QueryValueEx(key, "Path")
+ if val:
+ parts.append(os.path.expandvars(val))
+ except OSError:
+ continue
+ if parts:
+ merged = os.pathsep.join(parts)
+ current = os.environ.get("PATH", "")
+ seen = set()
+ ordered = []
+ for entry in (merged + os.pathsep + current).split(os.pathsep):
+ key = entry.lower().rstrip("\\")
+ if entry and key not in seen:
+ seen.add(key)
+ ordered.append(entry)
+ os.environ["PATH"] = os.pathsep.join(ordered)
+ except Exception as e:
+ print(f"â ïž Could not refresh PATH from registry: {e}")
-def install_build_tools():
- print("Installing CMake & Ninja via Homebrew...")
+def _winget_install(package_id: str, friendly_name: str) -> bool:
+ """Installs a package via winget. Returns True on success."""
+ if not check_winget():
+ print(f"â ïž winget introuvable â installez {friendly_name} manuellement.")
+ return False
+ print(f"Installing {friendly_name} via winget ({package_id})...")
try:
- subprocess.check_call(["brew", "install", "cmake", "ninja"])
+ subprocess.check_call([
+ "winget", "install", "-e", "--id", package_id,
+ "--accept-source-agreements", "--accept-package-agreements",
+ ])
+ # Make the newly-installed binaries visible to this process immediately.
+ refresh_windows_path()
return True
except (subprocess.CalledProcessError, OSError) as e:
- print(f"Error installing build tools: {e}")
+ print(f"Error installing {friendly_name}: {e}")
return False
+def install_node_js():
+ return _winget_install("OpenJS.NodeJS", "Node.js")
+
+
+def install_build_tools():
+ ok_cmake = _winget_install("Kitware.CMake", "CMake")
+ ok_ninja = _winget_install("Ninja-build.Ninja", "Ninja")
+ return ok_cmake and ok_ninja
+
+
def install_rust_toolchain():
- print("Installing Rust (cargo)...")
- import urllib.request
+ print("Installing Rust (cargo) via rustup-init.exe...")
import tempfile
+ import urllib.request
try:
- rustup_path = Path(tempfile.mkstemp(suffix=".sh")[1])
- req = urllib.request.Request("https://sh.rustup.rs")
+ _fd, _rustup_name = tempfile.mkstemp(suffix=".exe")
+ os.close(_fd) # release the handle so Windows can run/delete the file
+ rustup_path = Path(_rustup_name)
+ req = urllib.request.Request("https://win.rustup.rs/x86_64")
with urllib.request.urlopen(req, timeout=30) as resp:
rustup_path.write_bytes(resp.read())
checksums = load_expected_checksums()
- checksum_key = "darwin_rustup" if sys.platform == "darwin" else "linux_rustup"
- if not verify_download(rustup_path, checksums.get(checksum_key, "")):
- print(f"â ïž rustup installer SHA256 mismatch (checksum key: {checksum_key}). Continuing anyway.")
-
- rustup_path.chmod(0o755)
- subprocess.check_call([str(rustup_path), "-y"])
- rustup_path.unlink()
-
+ if not verify_download(rustup_path, checksums.get("windows_rustup", "")):
+ print("â ïž rustup installer SHA256 mismatch (checksum key: windows_rustup). Continuing anyway.")
+
+ subprocess.check_call([str(rustup_path), "-y", "--default-toolchain", "stable"])
+ rustup_path.unlink(missing_ok=True)
+
# Add to current path for this session
cargo_bin = Path.home() / ".cargo" / "bin"
if cargo_bin.exists():
@@ -152,42 +187,71 @@ def install_rust_toolchain():
return False
+def _safe_extract_zip(archive_path: Path, dest_dir: Path):
+ """Extracts a zip into dest_dir, rejecting path-traversal members."""
+ import zipfile
+ dest_resolved = dest_dir.resolve()
+ with zipfile.ZipFile(archive_path, "r") as zf:
+ for member in zf.infolist():
+ target = (dest_dir / member.filename).resolve()
+ try:
+ target.relative_to(dest_resolved)
+ except ValueError:
+ print(f" â ïž Rejected unsafe archive member: {member.filename}")
+ continue
+ zf.extract(member, dest_dir)
+
+
+def download_and_extract_zip(url: str, dest_dir: Path, log=print) -> bool:
+ """Downloads a .zip from `url` and extracts it into `dest_dir`.
+
+ Returns True on success. dest_dir is created if needed.
+ """
+ import tempfile
+ import urllib.request
+
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ # NOTE: close the fd mkstemp opens, otherwise Windows holds an exclusive lock
+ # on the temp file and ZipFile can't read it (WinError 32 sharing violation).
+ fd, tmp_name = tempfile.mkstemp(suffix=".zip")
+ os.close(fd)
+ tmp = Path(tmp_name)
+ try:
+ log(f"Downloading {url} ...")
+ import shutil
+ req = urllib.request.Request(url, headers={"User-Agent": "CorbeauSplat"})
+ # Stream to disk â COLMAP/ffmpeg archives are hundreds of MB and must
+ # not be buffered entirely in RAM (resp.read()).
+ with urllib.request.urlopen(req, timeout=600) as resp, open(tmp, "wb") as f:
+ shutil.copyfileobj(resp, f)
+ log(f"Extracting into {dest_dir} ...")
+ _safe_extract_zip(tmp, dest_dir)
+ return True
+ except Exception as e:
+ log(f"â ïž Download/extract failed: {e}")
+ return False
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
def install_system_dependencies(check_only=False):
- print("--- System Dependency Check (Homebrew) ---")
+ """Reports presence of ffmpeg and COLMAP.
+
+ Actual installation is handled by the engine dependencies
+ (FfmpegEngineDep / ColmapEngineDep), which auto-download into engines/.
+ """
+ from app.core.system import resolve_binary
+
+ print("--- System Dependency Check (Windows) ---")
missing = []
- for cmd in ["colmap", "ffmpeg"]:
- if shutil.which(cmd) is None: missing.append(cmd)
-
- if sys.platform == "darwin":
- try:
- # Check for libomp and freeimage
- if subprocess.run(["brew", "list", "libomp"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
- missing.append("libomp")
- if subprocess.run(["brew", "list", "freeimage"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
- missing.append("freeimage")
- except (subprocess.CalledProcessError, OSError):
- print("â ïž Could not check brew packages (libomp/freeimage).")
+ if resolve_binary("ffmpeg") is None:
+ missing.append("ffmpeg")
+ if resolve_binary("colmap") is None:
+ missing.append("colmap")
if not missing:
- print("â
System dependencies present.")
+ print("â
System dependencies present (ffmpeg, COLMAP).")
return True
-
- print(f"Missing: {', '.join(missing)}")
- if check_only:
- print("â¹ïž Audit mode: automatic installation skipped.")
- return False
- if shutil.which("brew") is None:
- print("ERROR: Homebrew required.")
- return False
-
- print("Installing via Homebrew...")
- try:
- if "colmap" in missing: subprocess.check_call(["brew", "install", "colmap"])
- if "ffmpeg" in missing: subprocess.check_call(["brew", "install", "ffmpeg"])
- if "libomp" in missing: subprocess.check_call(["brew", "install", "libomp"])
- if "freeimage" in missing: subprocess.check_call(["brew", "install", "freeimage"])
- return True
- except subprocess.CalledProcessError as e:
- print(f"System installation failed: {e}")
- return False
+ print(f"Missing: {', '.join(missing)} â will be auto-installed into engines/.")
+ return False
diff --git a/app/scripts/installers/upscayl.py b/app/scripts/installers/upscayl.py
index df25be1..7f9e17f 100644
--- a/app/scripts/installers/upscayl.py
+++ b/app/scripts/installers/upscayl.py
@@ -1,6 +1,4 @@
"""Upscayl engine dependency installer."""
-import sys
-from pathlib import Path
from app.scripts.installers.base import EngineDependency
@@ -25,7 +23,8 @@ def get_local_version(self) -> str:
return ""
def get_remote_version(self) -> str:
- import urllib.request, json as _json
+ import json as _json
+ import urllib.request
try:
req = urllib.request.Request(
"https://api.github.com/repos/upscayl/upscayl-ncnn/releases/latest",
diff --git a/app/scripts/setup_dependencies.py b/app/scripts/setup_dependencies.py
index 488c994..ad848d6 100644
--- a/app/scripts/setup_dependencies.py
+++ b/app/scripts/setup_dependencies.py
@@ -7,45 +7,36 @@
import sys
from pathlib import Path
+# Used at module level by get_venv_360_python() AND re-exported for external
+# importers (e.g. app.core.extractor_360_engine). Do not remove â ruff's
+# unused-import autofix previously stripped this and broke GUI startup.
+from app.core.system import resolve_project_root
+
# ââ Re-export all classes and functions for backward compatibility ââââââââââ
from app.scripts.installers.base import (
- EngineDependency,
- PipEngine,
DependencyManager,
)
from app.scripts.installers.brush import BrushEngineDep
-from app.scripts.installers.sharp import SharpEngineDep
-from app.scripts.installers.mapping import ColmapBrewDep, GlomapEngineDep
-from app.scripts.installers.supersplat import SuperSplatEngineDep
from app.scripts.installers.extractor_360 import Extractor360EngineDep
+from app.scripts.installers.mapping import ColmapEngineDep, FfmpegEngineDep, GlomapEngineDep
+from app.scripts.installers.supersplat import SuperSplatEngineDep
+from app.scripts.installers.tools import relax_requirements
from app.scripts.installers.upscayl import UpscaylEngineDep
-from app.scripts.installers.tools import (
- load_config,
- relax_requirements,
- get_remote_version,
- get_local_version,
- save_local_version,
- check_cargo,
- check_brew,
- check_node,
- check_cmake_ninja,
- check_xcode_tools,
- install_node_js,
- install_build_tools,
- install_rust_toolchain,
- install_system_dependencies,
-)
-
-# ââ Compatibility wrappers (used by external modules) ââââââââââââââââââââââ
-def uninstall_sharp():
- return SharpEngineDep().uninstall()
+# These names are re-exported for backward compatibility (external code imports
+# them from here). Listing them in __all__ marks them as intentional re-exports
+# so `ruff --fix` never strips them â a stripped re-export previously broke GUI
+# startup (resolve_project_root).
+__all__ = [
+ "DependencyManager", "BrushEngineDep", "Extractor360EngineDep",
+ "ColmapEngineDep", "FfmpegEngineDep", "GlomapEngineDep",
+ "SuperSplatEngineDep", "UpscaylEngineDep", "relax_requirements",
+ "resolve_project_root",
+ "uninstall_upscale", "install_upscale", "uninstall_extractor_360",
+ "install_extractor_360", "get_venv_360_python",
+]
-def install_sharp(engines_dir=None, version_file=None):
- # Compatibility wrapper
- dep = SharpEngineDep()
- dep.install()
- return dep.is_installed()
+# ââ Compatibility wrappers (used by external modules) ââââââââââââââââââââââ
def uninstall_upscale():
return UpscaylEngineDep().uninstall()
@@ -65,15 +56,10 @@ def install_extractor_360():
def get_venv_360_python():
"""Returns path to python executable in .venv_360"""
- from app.core.system import resolve_project_root
root = resolve_project_root()
if sys.platform == "win32":
return root / ".venv_360" / "Scripts" / "python.exe"
- return root / ".venv_360" / "bin" / "python"
-
-
-# resolve_project_root is imported from app.core.system
-from app.core.system import resolve_project_root
+ return root / ".venv_360" / "bin" / "python" # pragma: no cover (non-Windows fallback)
# ââ Main entry point ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
@@ -84,14 +70,14 @@ def main():
engines_dir.mkdir(parents=True, exist_ok=True)
manager = DependencyManager(engines_dir)
- manager.register(ColmapBrewDep())
+ manager.register(FfmpegEngineDep())
+ manager.register(ColmapEngineDep())
manager.register(GlomapEngineDep())
manager.register(BrushEngineDep())
- manager.register(SharpEngineDep())
manager.register(SuperSplatEngineDep())
manager.register(Extractor360EngineDep())
manager.register(UpscaylEngineDep())
-
+
check_only = "--check" in sys.argv
startup = "--startup" in sys.argv
manager.main_install(check_only=check_only, startup=startup)
diff --git a/app/upscayl_manager.py b/app/upscayl_manager.py
index 87389e9..0236b09 100644
--- a/app/upscayl_manager.py
+++ b/app/upscayl_manager.py
@@ -1,10 +1,9 @@
"""
-upscayl_manager.py â Finds, installs and manages the upscayl-bin binary.
+upscayl_manager.py â Finds, installs and manages the upscayl-bin binary (Windows).
Priority order for binary discovery:
- 1. ./bin/upscayl-bin (embedded, downloaded at runtime)
- 2. /Applications/Upscayl.app (user has Upscayl installed)
- 3. which upscayl-bin (Homebrew or PATH)
+ 1. ./bin/upscayl-bin.exe (embedded, downloaded at runtime)
+ 2. which upscayl-bin (on PATH)
"""
import json
import os
@@ -22,6 +21,15 @@
GITHUB_API = "https://api.github.com/repos/upscayl/upscayl-ncnn/releases/latest"
+def _is_windows() -> bool:
+ return os.name == "nt" or platform.system() == "Windows"
+
+
+def bin_filename() -> str:
+ """Executable file name for the current platform."""
+ return "upscayl-bin.exe" if _is_windows() else "upscayl-bin"
+
+
def get_bin_dir() -> Path:
return resolve_project_root() / "bin"
@@ -64,20 +72,15 @@ def get_effective_models_dir() -> Path | None:
def is_using_local_binary() -> bool:
"""True if we downloaded our own binary (not using system/app install)."""
- local = get_bin_dir() / "upscayl-bin"
+ local = get_bin_dir() / bin_filename()
return local.exists() and os.access(local, os.X_OK)
def find_binary() -> Path | None:
"""Returns the first usable upscayl-bin, or None."""
- candidates = [
- get_bin_dir() / "upscayl-bin",
- Path("/Applications/Upscayl.app/Contents/MacOS/upscayl-bin"),
- Path("/Applications/Upscayl.app/Contents/Resources/bin/upscayl-bin"),
- ]
- for p in candidates:
- if p.exists() and os.access(p, os.X_OK):
- return p
+ local = get_bin_dir() / bin_filename()
+ if local.exists() and os.access(local, os.X_OK):
+ return local
which = shutil.which("upscayl-bin")
if which:
@@ -108,24 +111,25 @@ def _fetch_release() -> dict:
return json.loads(resp.read())
-def _find_macos_asset(assets: list) -> dict | None:
- """Finds the macOS arm64 release asset."""
+def _find_windows_asset(assets: list) -> dict | None:
+ """Finds the Windows release asset."""
+ # Use specific tokens (not bare "win", which also matches "darwin").
+ win_tokens = ("windows", "win64", "win32", "-win-", "_win_")
for a in assets:
name = a["name"].lower()
- if ("macos" in name or "darwin" in name or "mac" in name) and \
- ("arm64" in name or "aarch64" in name):
+ if any(t in name for t in win_tokens) and name.endswith((".zip", ".7z")):
return a
- # Fallback: any macOS asset
+ # Fallback: any windows-named archive
for a in assets:
name = a["name"].lower()
- if "macos" in name or "darwin" in name or "mac" in name:
+ if any(t in name for t in win_tokens):
return a
return None
def download_binary(log_callback=None) -> Path:
"""
- Downloads the latest upscayl-bin release for macOS arm64.
+ Downloads the latest upscayl-bin release for Windows.
Extracts binary to ./bin/ and bundled models to ./models/upscayl/.
Returns the installed binary path.
Raises RuntimeError on failure.
@@ -137,9 +141,9 @@ def log(msg: str):
log("Fetching latest upscayl-ncnn release info...")
release = _fetch_release()
- asset = _find_macos_asset(release.get("assets", []))
+ asset = _find_windows_asset(release.get("assets", []))
if not asset:
- raise RuntimeError("No macOS release asset found on GitHub.")
+ raise RuntimeError("No Windows release asset found on GitHub.")
size_mb = asset["size"] // 1024 // 1024
log(f"Downloading {asset['name']} ({size_mb} MB)...")
@@ -148,13 +152,14 @@ def log(msg: str):
bin_dir.mkdir(parents=True, exist_ok=True)
archive_path = bin_dir / asset["name"]
- req = urllib.request.Request(asset["browser_download_url"])
- with urllib.request.urlopen(req, timeout=120) as resp:
- with open(str(archive_path), "wb") as f:
- f.write(resp.read())
+ req = urllib.request.Request(asset["browser_download_url"], headers={"User-Agent": "CorbeauSplat"})
+ # Stream to disk instead of resp.read() â a several-hundred-MB archive must
+ # not be buffered entirely in RAM.
+ with urllib.request.urlopen(req, timeout=120) as resp, open(str(archive_path), "wb") as f:
+ shutil.copyfileobj(resp, f)
checksums = load_expected_checksums()
- checksum_key = "darwin_upscayl" if platform.system() == "Darwin" else "linux_upscayl"
+ checksum_key = "windows_upscayl" if _is_windows() else "linux_upscayl"
if not verify_download(archive_path, checksums.get(checksum_key, "")):
log(f"â ïž upscayl archive SHA256 mismatch (checksum key: {checksum_key}). Continuing anyway.")
@@ -164,12 +169,13 @@ def log(msg: str):
_extract_archive(archive_path, bin_dir, models_dir, log)
archive_path.unlink(missing_ok=True)
- dest = bin_dir / "upscayl-bin"
+ dest = bin_dir / bin_filename()
if not dest.exists():
- raise RuntimeError("upscayl-bin not found after extraction.")
+ raise RuntimeError(f"{bin_filename()} not found after extraction.")
- os.chmod(dest, 0o755)
- log(f"â
upscayl-bin installed: {dest}")
+ if not _is_windows():
+ os.chmod(dest, 0o755)
+ log(f"â
{bin_filename()} installed: {dest}")
return dest
@@ -188,13 +194,15 @@ def is_safe_extraction(name: str, allowed_dirs: list) -> bool:
continue
return False
+ target_bin = bin_filename()
+
def handle_member(name: str, read_fn):
fname = Path(name).name
- if fname == "upscayl-bin":
+ if fname in ("upscayl-bin", "upscayl-bin.exe"):
if not is_safe_extraction(name, [bin_dest_resolved]):
log(f" â ïž Rejected unsafe member: {name}")
return
- out = bin_dest / "upscayl-bin"
+ out = bin_dest / target_bin
out.write_bytes(read_fn())
log(f" â {out}")
elif fname.endswith(".bin") or fname.endswith(".param"):
@@ -213,9 +221,14 @@ def handle_member(name: str, read_fn):
handle_member(info.filename, lambda i=info: zf.read(i.filename))
elif name_lower.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive, "r:gz") as tf:
+ def _read_member(m):
+ # extractfile() returns None for non-regular members (links,
+ # dirs) â guard so we never call .read() on None.
+ src = tf.extractfile(m)
+ return src.read() if src is not None else b""
for member in tf.getmembers():
if member.isfile():
- handle_member(member.name, lambda m=member: tf.extractfile(m).read())
+ handle_member(member.name, lambda m=member: _read_member(m))
else:
log(f"Unknown archive format: {archive.name}")
@@ -284,7 +297,13 @@ def _log(msg):
"-t", str(tile),
]
if models_dir:
- models_arg = os.path.relpath(str(models_dir), str(Path(binary).parent))
+ # os.path.relpath raises ValueError on Windows when the models dir and
+ # the binary live on different drives (e.g. C:\ binary, I:\ models) â
+ # fall back to the absolute path in that case.
+ try:
+ models_arg = os.path.relpath(str(models_dir), str(Path(binary).parent))
+ except ValueError:
+ models_arg = str(Path(models_dir).resolve())
cmd += ["-m", models_arg]
if tta:
cmd.append("-x")
diff --git a/app/upscayl_models.py b/app/upscayl_models.py
index 41920b3..53fd036 100644
--- a/app/upscayl_models.py
+++ b/app/upscayl_models.py
@@ -6,7 +6,6 @@
"""
from dataclasses import dataclass
from pathlib import Path
-from typing import Optional
_CUSTOM = "https://raw.githubusercontent.com/upscayl/custom-models/main/models"
@@ -128,7 +127,7 @@ def size_on_disk_mb(self, models_dir: Path) -> float:
]
-def get_model(model_id: str) -> Optional[UpscaylModel]:
+def get_model(model_id: str) -> UpscaylModel | None:
return next((m for m in MODELS if m.id == model_id), None)
diff --git a/assets/locales/ar.json b/assets/locales/ar.json
index 0deedc7..a4545ec 100644
--- a/assets/locales/ar.json
+++ b/assets/locales/ar.json
@@ -28,7 +28,7 @@
"confirm_reset": "تأÙÙØ¯ إعادة Ø§ÙØ¶ØšØ·Ø",
"opt_jit_enabled": "JIT Ù
ÙØ¹Ù",
"opt_jit_disabled": "JIT ØºÙØ± Ù
ÙØ¹Ù",
- "info_cpu": "Ø§ÙØªØŽØ§Ù Apple Silicon - ØªØØ³ÙÙ {} Ø®ÙÙØ·",
+ "info_cpu": "اÙÙ
Ø¹Ø§ÙØ¬: تÙ
Ø§ÙØªØŽØ§Ù {} Ø®ÙÙØ·",
"group_extract": "استخراج اÙÙ
عاÙÙ
",
"lbl_camera_model": "ÙÙ
ÙØ°Ø¬ اÙÙØ§Ù
ÙØ±Ø§:",
"check_single_cam": "ÙØ§Ù
ÙØ±Ø§ ÙØ§ØØ¯Ø©:",
@@ -348,5 +348,7 @@
"status_360_pre": "اÙÙ
Ø¹Ø§ÙØ¬Ø© اÙÙ
Ø³ØšÙØ© 360°...",
"status_360_start": "ؚدء استخراج 360°...",
"status_upscale_done": "Ø§ÙØªÙ
Ù ØªØØ³ÙÙ Ø§ÙØ¯ÙØ©",
- "btn_brush_standalone": "ت؎غÙÙ Brush ÙÙØ·"
+ "btn_brush_standalone": "ت؎غÙÙ Brush ÙÙØ·",
+ "check_native_360": "360 أصÙÙ:",
+ "check_ba_use_gpu": "ضؚط Ø§ÙØØ²Ù
Ø© GPU:"
}
diff --git a/assets/locales/de.json b/assets/locales/de.json
index ed3772d..8d07ae3 100644
--- a/assets/locales/de.json
+++ b/assets/locales/de.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž VollstÀndige Werkseinstellung\n\nDiese Aktion wird:\n1. Virtuelle Umgebungen (.venv, .venv_sharp) löschen.\n2. TemporÀre Dateien bereinigen.\n3. Installation neu starten (Apple Silicon optimiert).\n\nEmpfohlen bei AbhÀngigkeitsproblemen (z. B. Numpy/Pytorch).",
"opt_jit_enabled": "JIT aktiviert (Python 3.13+)",
"opt_jit_disabled": "JIT inaktiv (Empfohlen: Python 3.13)",
- "info_cpu": "Apple Silicon erkannt - {} Threads optimiert",
+ "info_cpu": "CPU: {} Threads erkannt",
"group_extract": "Feature-Extraktion",
"lbl_camera_model": "Kameramodell:",
"check_single_cam": "Einzelne Kamera:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "SH-Grad:",
"brush_device": "GerÀt:",
"brush_lbl_res": "Maximale Auflösung:",
- "brush_res_warn": "0 = Standardauflösung (1080)",
+ "brush_res_warn": "0 = Standardauflösung (1920)",
"brush_res_default": "0",
"brush_group_densify": "Densifizierung & Fortgeschritten",
"brush_lbl_preset": "Voreinstellung:",
@@ -349,5 +349,7 @@
"status_360_pre": "360°-Vorverarbeitung...",
"status_360_start": "360°-Extraktion wird gestartet...",
"status_upscale_done": "Upscale abgeschlossen",
- "btn_brush_standalone": "Nur Brush starten"
+ "btn_brush_standalone": "Nur Brush starten",
+ "check_native_360": "Natives 360:",
+ "check_ba_use_gpu": "GPU-BÃŒndelausgleich:"
}
diff --git a/assets/locales/en.json b/assets/locales/en.json
index b1772ab..cc6bb4d 100644
--- a/assets/locales/en.json
+++ b/assets/locales/en.json
@@ -38,7 +38,7 @@
"confirm_reset": "Choose reset level:",
"opt_jit_enabled": "JIT Enabled (Python 3.13+)",
"opt_jit_disabled": "JIT Disabled (Recommended: Python 3.13)",
- "info_cpu": "Apple Silicon detected - {} threads optimized",
+ "info_cpu": "CPU: {} threads detected",
"group_extract": "Feature Extraction",
"lbl_camera_model": "Camera Model:",
"check_single_cam": "Single Camera:",
@@ -82,7 +82,7 @@
"brush_sh_degree": "SH Degree:",
"brush_device": "Device:",
"brush_lbl_res": "Max Resolution:",
- "brush_res_warn": "0 = default resolution (1080)",
+ "brush_res_warn": "0 = default resolution (1920)",
"brush_res_default": "0",
"brush_group_densify": "Densification & Advanced",
"brush_lbl_build_mode": "Brush Version:",
@@ -364,5 +364,7 @@
"export_done_title": "Export Complete",
"export_done_body": "{} files exported successfully.",
"err_no_output": "Please select an output folder",
- "err_no_input": "Please add at least one PLY file"
-}
\ No newline at end of file
+ "err_no_input": "Please add at least one PLY file",
+ "check_native_360": "Native 360:",
+ "check_ba_use_gpu": "GPU Bundle Adjustment:"
+}
diff --git a/assets/locales/es.json b/assets/locales/es.json
index 08da716..66cf04d 100644
--- a/assets/locales/es.json
+++ b/assets/locales/es.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž Restablecimiento de Fábrica Completo\n\nEsta acción:\n1. Eliminará los entornos virtuales (.venv, .venv_sharp).\n2. Limpiará los archivos temporales.\n3. Reiniciará la instalación (Optimizada para Apple Silicon).\n\nRecomendado en caso de problemas con las dependencias (ej. Numpy/Pytorch).",
"opt_jit_enabled": "JIT Habilitado (Python 3.13+)",
"opt_jit_disabled": "JIT Inactivo (Recomendado: Python 3.13)",
- "info_cpu": "Apple Silicon detectado - {} hilos optimizados",
+ "info_cpu": "CPU: {} hilos detectados",
"group_extract": "Extracción de CaracterÃsticas",
"lbl_camera_model": "Modelo de Cámara:",
"check_single_cam": "Cámara Ãnica:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "Grado SH:",
"brush_device": "Dispositivo:",
"brush_lbl_res": "Resolución Máxima:",
- "brush_res_warn": "0 = resolución por defecto (1080)",
+ "brush_res_warn": "0 = resolución por defecto (1920)",
"brush_res_default": "0",
"brush_group_densify": "Densificación y Avanzado",
"brush_lbl_build_mode": "Versión de Brush:",
@@ -348,5 +348,7 @@
"status_360_pre": "Preprocesamiento 360°...",
"status_360_start": "Iniciando extracción 360°...",
"status_upscale_done": "Upscale completado",
- "btn_brush_standalone": "Iniciar solo Brush"
+ "btn_brush_standalone": "Iniciar solo Brush",
+ "check_native_360": "360 nativo:",
+ "check_ba_use_gpu": "Ajuste de paquetes GPU:"
}
diff --git a/assets/locales/fr.json b/assets/locales/fr.json
index 00d5460..37c325f 100644
--- a/assets/locales/fr.json
+++ b/assets/locales/fr.json
@@ -38,7 +38,7 @@
"confirm_reset": "Choisissez le niveau de réinitialisation :",
"opt_jit_enabled": "JIT Activé (Python 3.13+)",
"opt_jit_disabled": "JIT Inactif (Recommandé: Python 3.13)",
- "info_cpu": "Apple Silicon detecte - {} threads optimises",
+ "info_cpu": "CPU : {} threads détectés",
"group_extract": "Feature Extraction",
"lbl_camera_model": "Modele camera :",
"check_single_cam": "Camera unique :",
@@ -80,7 +80,7 @@
"brush_lbl_output": "Dossier Export :",
"brush_lbl_ply": "Nom du fichier PLY (optionnel) :",
"brush_lbl_res": "Résolution Maximum :",
- "brush_res_warn": "0 = résolution par défaut (1080)",
+ "brush_res_warn": "0 = résolution par défaut (1920)",
"brush_res_default": "0",
"brush_group_densify": "Densification & Avancé",
"brush_lbl_build_mode": "Version Brush :",
@@ -372,5 +372,7 @@
"export_done_title": "Export terminé",
"export_done_body": "{} fichiers exportés avec succÚs.",
"err_no_output": "Veuillez sélectionner un dossier de sortie",
- "err_no_input": "Veuillez ajouter au moins un fichier PLY"
-}
\ No newline at end of file
+ "err_no_input": "Veuillez ajouter au moins un fichier PLY",
+ "check_native_360": "360 natif :",
+ "check_ba_use_gpu": "Bundle adjustment GPU :"
+}
diff --git a/assets/locales/it.json b/assets/locales/it.json
index 1eeb784..896c090 100644
--- a/assets/locales/it.json
+++ b/assets/locales/it.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž Ripristino di Fabbrica Completo\n\nQuesta azione:\n1. Eliminerà gli ambienti virtuali (.venv, .venv_sharp).\n2. Pulirà i file temporanei.\n3. Riavvierà l'installazione (Ottimizzata per Apple Silicon).\n\nConsigliato in caso di problemi con le dipendenze (es. Numpy/Pytorch).",
"opt_jit_enabled": "JIT Abilitato (Python 3.13+)",
"opt_jit_disabled": "JIT Non Attivo (Consigliato: Python 3.13)",
- "info_cpu": "Apple Silicon rilevato - {} thread ottimizzati",
+ "info_cpu": "CPU: {} thread rilevati",
"group_extract": "Estrazione Feature",
"lbl_camera_model": "Modello Fotocamera:",
"check_single_cam": "Singola Fotocamera:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "Grado SH:",
"brush_device": "Dispositivo:",
"brush_lbl_res": "Risoluzione Massima:",
- "brush_res_warn": "0 = risoluzione predefinita (1080)",
+ "brush_res_warn": "0 = risoluzione predefinita (1920)",
"brush_res_default": "0",
"brush_group_densify": "Densificazione & Avanzate",
"brush_lbl_build_mode": "Versione Brush:",
@@ -348,5 +348,7 @@
"status_360_pre": "Pre-elaborazione 360°...",
"status_360_start": "Avvio estrazione 360°...",
"status_upscale_done": "Upscale completato",
- "btn_brush_standalone": "Avvia solo Brush"
+ "btn_brush_standalone": "Avvia solo Brush",
+ "check_native_360": "360 nativo:",
+ "check_ba_use_gpu": "Bundle adjustment GPU:"
}
diff --git a/assets/locales/ja.json b/assets/locales/ja.json
index d92e1d3..ed959de 100644
--- a/assets/locales/ja.json
+++ b/assets/locales/ja.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž å®å
šãªãªã»ãã\n\nãã®ã¢ã¯ã·ã§ã³ã¯:\n1. ä»®æ³ç°å¢(.venv)ãåé€ããŸãã\n2. äžæãã¡ã€ã«ãã¯ãªã¢ããŸãã\n3. ã€ã³ã¹ããŒã«ãåéããŸãã\n\näŸåé¢ä¿ã®åé¡ãããå Žåã«æšå¥šã",
"opt_jit_enabled": "JITæå¹ (Python 3.13+)",
"opt_jit_disabled": "JITç¡å¹",
- "info_cpu": "Apple Siliconãæ€åº - {}ã¹ã¬ããæé©å",
+ "info_cpu": "CPU: {} ã¹ã¬ããæ€åº",
"group_extract": "ç¹åŸŽæœåº",
"lbl_camera_model": "ã«ã¡ã©ã¢ãã«:",
"check_single_cam": "ã·ã³ã°ã«ã«ã¡ã©:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "SH次æ°:",
"brush_device": "ããã€ã¹:",
"brush_lbl_res": "æå€§è§£å床:",
- "brush_res_warn": "0 = ããã©ã«ã (1080)",
+ "brush_res_warn": "0 = ããã©ã«ã (1920)",
"brush_res_default": "0",
"brush_group_densify": "é«å¯åºŠåãšé«åºŠãªèšå®",
"brush_lbl_build_mode": "Brush ããŒãžã§ã³:",
@@ -348,5 +348,7 @@
"status_360_pre": "360°ååŠçäž...",
"status_360_start": "360°æœåºãéå§äž...",
"status_upscale_done": "ã¢ããã¹ã±ãŒã«å®äº",
- "btn_brush_standalone": "Brushã®ã¿èµ·å"
+ "btn_brush_standalone": "Brushã®ã¿èµ·å",
+ "check_native_360": "ãã€ãã£ã360:",
+ "check_ba_use_gpu": "GPUãã³ãã«èª¿æŽ:"
}
diff --git a/assets/locales/ru.json b/assets/locales/ru.json
index 9d44321..6ad5223 100644
--- a/assets/locales/ru.json
+++ b/assets/locales/ru.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž ÐПлМÑй ÑбÑÐŸÑ Ðº завПЎÑкОЌ МаÑÑÑПйкаЌ\n\nÐÑП ЎейÑÑвОе:\n1. Ð£ÐŽÐ°Ð»ÐžÑ Ð²ÐžÑÑÑалÑМÑе ÑÑÐµÐŽÑ (.venv, .venv_sharp).\n2. ÐÑОÑÑÐžÑ Ð²ÑеЌеММÑе ÑайлÑ.\n3. ÐеÑезапÑÑÑÐžÑ ÑÑÑаМПвкÑ.\n\nРекПЌеМЎÑеÑÑÑ Ð¿ÑО пÑПблеЌаÑ
Ñ Ð·Ð°Ð²ÐžÑОЌПÑÑÑЌО.",
"opt_jit_enabled": "ÐклÑÑеМ JIT (Python 3.13+)",
"opt_jit_disabled": "JIT ПÑклÑÑеМ",
- "info_cpu": "ÐбМаÑÑжеМ Apple Silicon - ПпÑОЌОзОÑПваМП {} пПÑПкПв",
+ "info_cpu": "CPU: ПбМаÑÑжеМП {} пПÑПкПв",
"group_extract": "ÐзвлеÑеМОе пÑОзМакПв",
"lbl_camera_model": "ÐÐŸÐŽÐµÐ»Ñ ÐºÐ°ÐŒÐµÑÑ:",
"check_single_cam": "ÐЎМа каЌеÑа:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "СÑÐµÐ¿ÐµÐœÑ SH:",
"brush_device": "УÑÑÑПйÑÑвП:",
"brush_lbl_res": "ÐакÑ. ÑазÑеÑеМОе:",
- "brush_res_warn": "0 = ÑазÑеÑеМОе пП ÑЌПлÑÐ°ÐœÐžÑ (1080)",
+ "brush_res_warn": "0 = ÑазÑеÑеМОе пП ÑЌПлÑÐ°ÐœÐžÑ (1920)",
"brush_res_default": "0",
"brush_group_densify": "УплПÑМеМОе О ÑаÑÑОÑеММÑе МаÑÑÑПйкО",
"brush_lbl_build_mode": "ÐеÑÑÐžÑ Brush:",
@@ -348,5 +348,7 @@
"status_360_pre": "ÐÑеЎПбÑабПÑка 360°...",
"status_360_start": "ÐапÑÑк ОзвлеÑÐµÐœÐžÑ 360°...",
"status_upscale_done": "ÐпÑкейлОМг завеÑÑÑМ",
- "btn_brush_standalone": "ÐапÑÑÑОÑÑ ÑПлÑкП Brush"
+ "btn_brush_standalone": "ÐапÑÑÑОÑÑ ÑПлÑкП Brush",
+ "check_native_360": "ÐаÑОвМÑй 360:",
+ "check_ba_use_gpu": "GPU Bundle Adjustment:"
}
diff --git a/assets/locales/zh.json b/assets/locales/zh.json
index 2b2d8a1..0b8240e 100644
--- a/assets/locales/zh.json
+++ b/assets/locales/zh.json
@@ -28,7 +28,7 @@
"confirm_reset": "â ïž å®å
šæ¢å€åºå讟眮\n\næ€æäœå°:\n1. å é€èæç¯å¢ (.venv, .venv_sharp).\n2. æž
çäžŽæ¶æä»¶.\n3. éæ°å¯åšå®è£
.\n\n建议éå°äŸèµé®é¢æ¶äœ¿çšã",
"opt_jit_enabled": "å·²å¯çš JIT (Python 3.13+)",
"opt_jit_disabled": "JIT å·²çŠçš",
- "info_cpu": "æ£æµå° Apple Silicon - å·²äŒå {} 䞪线çš",
+ "info_cpu": "CPUïŒæ£æµå° {} 䞪线çš",
"group_extract": "ç¹åŸæå",
"lbl_camera_model": "çžæºåå·:",
"check_single_cam": "åçžæº:",
@@ -71,7 +71,7 @@
"brush_sh_degree": "SH é¶æ°:",
"brush_device": "è®Ÿå€ (Device):",
"brush_lbl_res": "æå€§å蟚ç:",
- "brush_res_warn": "0 = é»è®€å蟚ç (1080)",
+ "brush_res_warn": "0 = é»è®€å蟚ç (1920)",
"brush_res_default": "0",
"brush_group_densify": "èŽå¯åäžé«çº§è®Ÿçœ®",
"brush_lbl_build_mode": "Brush çæ¬:",
@@ -348,5 +348,7 @@
"status_360_pre": "360°é¢å€çäž...",
"status_360_start": "åŒå§360°æå...",
"status_upscale_done": "è¶
å蟚ç宿",
- "btn_brush_standalone": "ä»
å¯åš Brush"
+ "btn_brush_standalone": "ä»
å¯åš Brush",
+ "check_native_360": "åç360ïŒ",
+ "check_ba_use_gpu": "GPUå
ææ³å¹³å·®ïŒ"
}
diff --git a/config.example.json b/config.example.json
index 9c7732a..4dce26b 100644
--- a/config.example.json
+++ b/config.example.json
@@ -57,19 +57,6 @@
"show_details": false,
"build_mode": "release"
},
- "sharp_params": {
- "mode": "image",
- "enabled": true,
- "input_path": "",
- "output_path": "",
- "video_path": "",
- "video_output_path": "",
- "skip_frames": 1,
- "checkpoint": "",
- "device": "default",
- "verbose": false,
- "upscale": false
- },
"upscale_params": {
"model_id": "realesrgan-x4plus",
"scale": 4,
@@ -105,4 +92,4 @@
"cam_pos": "",
"cam_rot": ""
}
-}
+}
\ No newline at end of file
diff --git a/manifest.md b/manifest.md
index c5517ac..1ba16d0 100644
--- a/manifest.md
+++ b/manifest.md
@@ -1,15 +1,14 @@
# CorbeauSplat â Project Manifest
-> Version 1.0.0 â macOS Apple Silicon Gaussian Splatting Pipeline
+> Windows / CUDA â Gaussian Splatting Pipeline (video â frames â COLMAP â Brush)
## Identity
-- **Purpose**: All-in-one GUI + CLI tool for Gaussian Splatting 3D reconstruction on macOS
-- **Author**: Frederick (freddewitt) â github.com/freddewitt/CorbeauSplat
+- **Purpose**: All-in-one GUI + CLI tool for Gaussian Splatting 3D reconstruction on Windows with NVIDIA CUDA
- **License**: MIT
-- **Python**: 3.13+ (main), 3.11 (ML Sharp venv)
-- **Stack**: PyQt6, COLMAP, Brush (Rust/WGPU), Apple ML Sharp, upscayl-ncnn
-- **File count**: ~30 Python files, ~10,500 LOC first-party
+- **Python**: 3.11+
+- **Stack**: PyQt6, COLMAP (CUDA), Brush (Rust/wgpu, DX12/Vulkan), upscayl-ncnn, FFmpeg (NVDEC)
+- **GPU**: NVIDIA CUDA (detected via `nvidia-smi`); CPU fallback supported
## Quickstart
@@ -36,9 +35,8 @@ main.py â Entry: CLI parser or GUI launcher
â â
â âââ core/ â Business logic (engine layer)
â â âââ base_engine.py â BaseEngine + IProcessRunner (Template Method)
-â â âââ engine.py â ColmapEngine â SfM pipeline
-â â âââ brush_engine.py â BrushEngine â Gaussian Splat trainer
-â â âââ sharp_engine.py â SharpEngine â Apple ML Sharp
+â â âââ engine.py â ColmapEngine â SfM pipeline (CUDA SIFT)
+â â âââ brush_engine.py â BrushEngine â Gaussian Splat trainer (wgpu DX12/Vulkan)
â â âââ upscale_engine.py â UpscaleEngine â upscayl-ncnn wrapper
â â âââ superplat_engine.py â SuperSplatEngine â web viewer
â â âââ four_dgs_engine.py â 4DGS data preparation
@@ -98,10 +96,9 @@ main.py â Entry: CLI parser or GUI launcher
| Engine | Input | Output | Binary |
|--------|-------|--------|--------|
-| **ColmapEngine** | Video/images | COLMAP dataset (sparse + dense) | `colmap` / `glomap` |
-| **BrushEngine** | COLMAP dataset | Gaussian Splat `.ply` | `brush` (Rust) |
-| **SharpEngine** | Image/video | `.ply` splat | `sharp` (Apple ML) |
-| **UpscaleEngine** | Image/folder | Upscaled images | `upscayl-bin` (NCNN) |
+| **ColmapEngine** | Video/images | COLMAP dataset (sparse + dense) | `colmap.exe` / `glomap.exe` (CUDA) |
+| **BrushEngine** | COLMAP dataset | Gaussian Splat `.ply` | `brush.exe` (Rust/wgpu) |
+| **UpscaleEngine** | Image/folder | Upscaled images | `upscayl-bin.exe` (NCNN) |
| **SuperSplatEngine** | `.ply` file | Web viewer | `npx serve` |
| **FourDGSEngine** | Multi-cam videos | Nerfstudio dataset | COLMAP + ns-process-data |
| **Extractor360Engine** | 360° video | Planar images | 360Extractor venv |
@@ -109,11 +106,11 @@ main.py â Entry: CLI parser or GUI launcher
## Dependencies
-**Python** (requirements.txt): PyQt6, requests, urllib3, numpy, send2trash, pyobjc-framework-Cocoa, Pillow, plyfile
+**Python** (requirements.txt): PyQt6, requests, urllib3, numpy, send2trash, Pillow, plyfile
-**System**: FFmpeg, COLMAP, Homebrew, Xcode CLT
+**System**: NVIDIA driver (for CUDA). COLMAP (CUDA) and FFmpeg auto-install on first launch. Optional: winget (FFmpeg/Node.js/CMake/Ninja), Git, Rust (Brush source builds)
-**Run-time downloaded**: upscayl-bin (auto-install from GitHub releases), upscayl models (6 custom models)
+**Run-time downloaded**: COLMAP CUDA build + FFmpeg (into `engines/`), brush.exe + upscayl-bin.exe (from GitHub releases), upscayl models
## Security
@@ -165,14 +162,23 @@ Input (Video/Images)
9 languages via `assets/locales/{lang}.json`. `LanguageManager` singleton with Observer pattern. Fallback chain: selected â `en.json` â `fr.json` â empty dict.
-## Dual-Venv Setup
+## Virtual Environments
-- **`.venv/`**: Main app (Python 3.13+ with PyQt6, etc.)
-- **`.venv_sharp/`**: ML Sharp (Python 3.11 â required by Apple's fork)
+- **`.venv/`**: Main app (Python 3.11+ with PyQt6, etc.) â `Scripts\python.exe`
- **`.venv_360/`**: 360Extractor (isolated environment)
+- **`.venv_4dgs/`**: Nerfstudio for 4DGS prep (isolated environment)
## CLI Subcommands
-`pipeline`, `colmap`, `brush`, `sharp`, `view`, `upscale`, `4dgs`, `extract360`
+`pipeline`, `colmap`, `brush`, `view`, `upscale`, `4dgs`, `extract360`
Each has `--help`. No subcommand = GUI mode.
+
+## Platform Notes (Windows/CUDA)
+
+- Device selection: `app/core/system.py:get_device()` â `cuda` when `nvidia-smi` is on PATH, else `cpu`.
+- COLMAP/FFmpeg auto-install: `ColmapEngineDep`/`FfmpegEngineDep` download into `engines/colmap` and `engines/ffmpeg` on first launch (COLMAP CUDA build chosen when a GPU is present, non-CUDA otherwise).
+- COLMAP resolution: `resolve_binary("colmap")` searches `engines/colmap/**` for `colmap.exe`, then `C:\COLMAP`/`Program Files`, then PATH. `run_command` adds the COLMAP `bin`/`lib` dirs to `PATH` so bundled DLLs load.
+- FFmpeg uses `-hwaccel cuda` (NVDEC) when a GPU is present.
+- COLMAP SIFT extraction/matching pass `--SiftExtraction.use_gpu`/`--SiftMatching.use_gpu` when CUDA is available.
+- Brush runs on wgpu with `WGPU_BACKEND=dx12` for the discrete NVIDIA adapter.
diff --git a/requirements.lock b/requirements.lock
index 304f367..6066227 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -1,15 +1,14 @@
# requirements.lock â Pinned versions for reproducible builds
-# Generated 2026-06-17 from .venv/ (Python 3.13)
+# Generated from .venv/ (Python 3.11+, Windows)
# Install with: pip install -r requirements.lock
PyQt6==6.11.0
PyQt6-Qt6==6.11.0
PyQt6_sip==13.11.1
numpy==2.4.4
+opencv-python-headless==4.11.0.86
Pillow==11.3.0
plyfile==0.9
-pyobjc-core==12.1
-pyobjc-framework-Cocoa==10.3.2
requests==2.33.1
Send2Trash==1.8.3
urllib3==2.6.3
diff --git a/requirements.lock.bak b/requirements.lock.bak
deleted file mode 100644
index 44ca6f7..0000000
--- a/requirements.lock.bak
+++ /dev/null
@@ -1,15 +0,0 @@
-attrs==25.4.0
-certifi==2026.1.4
-charset-normalizer==3.4.4
-idna==3.11
-numpy==2.2.2
-pillow==11.3.0
-pyarrow==22.0.0
-PyQt6==6.10.2
-PyQt6-Qt6==6.10.1
-PyQt6_sip==13.10.2
-requests==2.32.5
-rerun-sdk==0.28.2
-Send2Trash==2.0.0
-typing_extensions==4.15.0
-urllib3==2.6.3
diff --git a/requirements.txt b/requirements.txt
index f68619a..ff22e24 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,6 +3,6 @@ requests>=2.32,<3
urllib3>=2.0,<3
numpy>=1.26,<3
send2trash>=1.8,<2
-pyobjc-framework-Cocoa>=10.0,<11
Pillow>=10.0,<12
plyfile>=0.7,<1
+opencv-python-headless>=4.8,<5
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000..e28629a
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,121 @@
+@echo off
+setlocal EnableDelayedExpansion
+
+rem ===========================================================================
+rem CorbeauSplat (Windows / CUDA) launcher
+rem Sets up a Python virtual environment, installs dependencies, checks
+rem external engines (COLMAP, Brush, ...) and launches the application.
+rem ===========================================================================
+
+cd /d "%~dp0"
+echo Working directory: %CD%
+
+rem --- Phase 0: Clean Reset (--clean flag) ---
+set "CLEAN_MODE="
+set "FILTERED_ARGS="
+for %%A in (%*) do (
+ if /I "%%~A"=="--clean" (
+ set "CLEAN_MODE=1"
+ ) else (
+ set "FILTERED_ARGS=!FILTERED_ARGS! %%A"
+ )
+)
+
+if defined CLEAN_MODE (
+ echo.
+ echo WARNING: CLEAN MODE
+ echo This will delete:
+ echo - .venv, .venv_360, .venv_4dgs ^(Python environments^)
+ echo - engines\ ^(COLMAP, Brush, Glomap binaries^)
+ echo - config.json ^(configuration^)
+ echo.
+ set /p "CONFIRM= Confirm full reset? (y/n): "
+ if /I "!CONFIRM!"=="y" (
+ echo Cleaning...
+ rmdir /s /q ".venv" 2>nul
+ rmdir /s /q ".venv_360" 2>nul
+ rmdir /s /q ".venv_4dgs" 2>nul
+ rmdir /s /q "engines" 2>nul
+ del /q "config.json" 2>nul
+ echo Done.
+ ) else (
+ echo Cancelled. Normal launch.
+ )
+ echo.
+)
+
+rem --- Phase 0.5: Prerequisites ---
+echo --- Phase 0.5: Checking prerequisites ---
+where git >nul 2>&1 || echo ^(info^) git not found - updates and source builds will be unavailable.
+where nvidia-smi >nul 2>&1 && (echo NVIDIA GPU detected - CUDA acceleration available.) || (echo ^(info^) nvidia-smi not found - running on CPU. Install the NVIDIA driver for CUDA.)
+
+rem --- Phase 0.7: Auto-update from git (latest fixes) ---
+if exist ".git" (
+ where git >nul 2>&1 && (
+ echo --- Phase 0.7: Checking for updates ---
+ git pull --ff-only 2>nul && (
+ echo Up to date with the latest version.
+ ) || (
+ echo ^(info^) Could not fast-forward ^(local changes or offline^) - continuing with current version.
+ )
+ )
+)
+
+rem --- Phase 1: Locate Python ---
+echo --- Phase 1: Environment configuration ---
+set "VENV_DIR=.venv"
+set "PYTHON_CMD=%VENV_DIR%\Scripts\python.exe"
+
+if not exist "%PYTHON_CMD%" (
+ echo Creating virtual environment...
+ if exist "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
+
+ set "SELECTED_PY="
+ for %%P in (python py python3) do (
+ if not defined SELECTED_PY (
+ where %%P >nul 2>&1 && set "SELECTED_PY=%%P"
+ )
+ )
+ if not defined SELECTED_PY (
+ echo ERROR: Python 3 not found. Install Python 3.11+ from https://www.python.org/downloads/
+ exit /b 1
+ )
+ echo Detected Python launcher: !SELECTED_PY!
+ !SELECTED_PY! -m venv "%VENV_DIR%" || (
+ echo ERROR: Failed to create virtual environment.
+ exit /b 1
+ )
+ echo Virtual environment created.
+)
+
+for /f "delims=" %%V in ('"%PYTHON_CMD%" --version') do echo Using environment Python: %%V
+
+rem --- Phase 2: Dependency sync ---
+echo --- Phase 2: Synchronizing dependencies ---
+"%PYTHON_CMD%" -m pip install --upgrade pip >nul 2>&1
+
+set "DEP_FILE=requirements.txt"
+if exist "requirements.lock" set "DEP_FILE=requirements.lock"
+echo Using dependency list: %DEP_FILE%
+
+"%PYTHON_CMD%" -m pip install -r "%DEP_FILE%" >nul 2>&1
+if errorlevel 1 (
+ echo Silent install failed, retrying with logs...
+ "%PYTHON_CMD%" -m pip install -r "%DEP_FILE%"
+)
+
+"%PYTHON_CMD%" -c "import PyQt6" >nul 2>&1 || "%PYTHON_CMD%" -m pip install PyQt6
+"%PYTHON_CMD%" -c "import plyfile" >nul 2>&1 || "%PYTHON_CMD%" -m pip install plyfile
+echo Dependencies synchronized.
+
+rem --- Phase 3: Engine check ---
+echo --- Phase 3: Verifying engines and external binaries ---
+"%PYTHON_CMD%" -m app.scripts.setup_dependencies --startup
+echo System check complete.
+
+rem --- Phase 4: Launch ---
+echo --- Phase 4: Launching CorbeauSplat ---
+echo ------------------------------------------------
+"%PYTHON_CMD%" main.py%FILTERED_ARGS%
+
+endlocal
diff --git a/run.command b/run.command
deleted file mode 100755
index c0b3c25..0000000
--- a/run.command
+++ /dev/null
@@ -1,264 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-# Move to script directory
-cd "$(dirname "$0")"
-echo "Working directory: $(pwd)"
-
-# --- Phase 0: Clean Reset (--clean flag) ---
-CLEAN_MODE=false
-FILTERED_ARGS=()
-for arg in "$@"; do
- if [ "$arg" = "--clean" ]; then
- CLEAN_MODE=true
- else
- FILTERED_ARGS+=("$arg")
- fi
-done
-
-if [ "$CLEAN_MODE" = true ]; then
- echo ""
- echo "â ïž MODE CLEAN DÃTECTÃ"
- echo " Ceci va supprimer :"
- echo " - .venv, .venv_sharp, .venv_360 (environnements Python)"
- echo " - engines/ (binaires COLMAP, Brush, Glomap...)"
- echo " - config.json (configuration)"
- echo ""
- read -p " Confirmer la réinitialisation complÚte ? (o/n) : " -n 1 -r
- echo
- if [[ $REPLY =~ ^[OoYy]$ ]]; then
- echo "ð§¹ Nettoyage en cours..."
- rm -rf ".venv" ".venv_sharp" ".venv_360" "engines" "config.json"
- echo "â
Réinitialisation complÚte effectuée."
- else
- echo "Annulé. Lancement normal."
- CLEAN_MODE=false
- fi
- echo ""
-fi
-
-# --- Phase 0.5: Prerequisites (Xcode CLT + Homebrew) ---
-echo "--- Phase 0.5: Checking prerequisites ---"
-
-# 1. Xcode Command Line Tools
-if ! xcode-select -p > /dev/null 2>&1; then
- echo ""
- echo "â ïž Xcode Command Line Tools not found."
- echo " Required for: git, compilers, build tools."
- read -p " Install now? (y/n) " -n 1 -r
- echo
- if [[ $REPLY =~ ^[Yy]$ ]]; then
- echo ">>> Launching Xcode CLT installer (a dialog will open)..."
- xcode-select --install 2>/dev/null
- echo ""
- echo " Complete the installation in the dialog, then press Enter to continue."
- read -p " Press Enter when done..."
- if ! xcode-select -p > /dev/null 2>&1; then
- echo "â Xcode CLT still not detected. Please install manually and relaunch."
- exit 1
- fi
- echo "â
Xcode Command Line Tools installed."
- else
- echo "â ïž Skipped. Some features may not work without Xcode CLT."
- fi
-else
- echo "â
Xcode Command Line Tools: $(xcode-select -p)"
-fi
-
-# 2. Homebrew
-BREW_BIN=""
-# Check known locations before relying on PATH (especially after a fresh Apple Silicon install)
-if [[ -x "/opt/homebrew/bin/brew" ]]; then BREW_BIN="/opt/homebrew/bin/brew"
-elif [[ -x "/usr/local/bin/brew" ]]; then BREW_BIN="/usr/local/bin/brew"
-elif command -v brew > /dev/null 2>&1; then BREW_BIN="$(command -v brew)"
-fi
-
-if [ -z "$BREW_BIN" ]; then
- echo ""
- echo "â ïž Homebrew not found."
- echo " Required for: ffmpeg, COLMAP, Node.js, libomp, cmake..."
- read -p " Install Homebrew now? (y/n) " -n 1 -r
- echo
- if [[ $REPLY =~ ^[Yy]$ ]]; then
- echo ">>> Downloading Homebrew install script..."
- BREW_INSTALL_SCRIPT="/tmp/homebrew-install.sh"
- # Pin to a specific commit tag for integrity (avoids MITM on the raw.githubusercontent CDN)
- # Update BREW_TAG and BREW_SHA256 when a new Homebrew release is needed.
- BREW_TAG="4.4.23"
- BREW_SHA256="c63e04915a08f4ded2f5f710fb6b83d8070245e5e30bdffb5d6b462fd1c9089e"
- curl -fsSL "https://raw.githubusercontent.com/Homebrew/install/${BREW_TAG}/install.sh" -o "$BREW_INSTALL_SCRIPT" || {
- echo "â Failed to download Homebrew install script."
- exit 1
- }
- echo ">>> Verifying checksum..."
- COMPUTED_SHA=$(shasum -a 256 "$BREW_INSTALL_SCRIPT" | cut -d' ' -f1)
- if [ "$COMPUTED_SHA" != "$BREW_SHA256" ]; then
- echo "â SHA256 mismatch!"
- echo " Expected: $BREW_SHA256"
- echo " Got: $COMPUTED_SHA"
- echo " Install manually from https://brew.sh and relaunch."
- rm -f "$BREW_INSTALL_SCRIPT"
- exit 1
- fi
- echo "â
Checksum verified. Installing Homebrew..."
- /bin/bash "$BREW_INSTALL_SCRIPT"
- rm -f "$BREW_INSTALL_SCRIPT"
- # Activate Homebrew in the current shell session
- if [[ -x "/opt/homebrew/bin/brew" ]]; then
- eval "$(/opt/homebrew/bin/brew shellenv)"
- BREW_BIN="/opt/homebrew/bin/brew"
- elif [[ -x "/usr/local/bin/brew" ]]; then
- eval "$(/usr/local/bin/brew shellenv)"
- BREW_BIN="/usr/local/bin/brew"
- fi
- if [ -z "$BREW_BIN" ]; then
- echo "â Homebrew installation failed or not found."
- echo " Install manually from https://brew.sh and relaunch."
- exit 1
- fi
- echo "â
Homebrew installed: $("$BREW_BIN" --version | head -1)"
- else
- echo "â ïž Skipped. System tools (ffmpeg, COLMAP...) may fail to install."
- fi
-else
- # Ensure brew is in PATH for the rest of this session
- eval "$("$BREW_BIN" shellenv)" 2>/dev/null
- echo "â
Homebrew: $("$BREW_BIN" --version | head -1)"
-fi
-
-# --- Phase 1: Update Check ---
-if [ -d ".git" ]; then
- echo "--- Phase 1: Checking for updates ---"
- git fetch > /dev/null 2>&1 || true
-
- if git rev-parse --abbrev-ref --symbolic-full-name @{u} > /dev/null 2>&1; then
- BEHIND_COUNT=$(git rev-list --count HEAD..@{u})
- AHEAD_COUNT=$(git rev-list --count @{u}..HEAD)
-
- if [ "$AHEAD_COUNT" -gt 0 ]; then
- echo "â¹ïž Local version is ahead of GitHub ($AHEAD_COUNT commit(s)). No update applied."
- elif [ "$BEHIND_COUNT" -gt 0 ]; then
- echo ">>> A new version is available ($BEHIND_COUNT commits behind)."
- read -p ">>> Would you like to update now? (y/n) " -n 1 -r
- echo
- if [[ $REPLY =~ ^[Yy]$ ]]; then
- echo "Updating..."
- git pull
- echo "Update complete."
- else
- echo "Update skipped."
- fi
- else
- echo "â
Software is up to date."
- fi
- fi
-else
- echo "--- Phase 1: Skipping update check (not a git repository) ---"
-fi
-
-# --- Phase 2: Environment & Venv Health ---
-echo "--- Phase 2: Environment configuration ---"
-VENV_DIR=".venv"
-PYTHON_CMD="$VENV_DIR/bin/python3"
-
-if [ ! -d "$VENV_DIR" ] || [ ! -f "$PYTHON_CMD" ]; then
- echo "Creating virtual environment..."
- if [ -d "$VENV_DIR" ]; then echo "â ïž Venv corrupted. Rebuilding..."; rm -rf "$VENV_DIR"; fi
-
- PY_CANDIDATES=("python3.13" "python3.12" "python3.11" "python3.10" "python3")
- SELECTED_PY=""
- for py in "${PY_CANDIDATES[@]}"; do
- if command -v $py >/dev/null 2>&1; then SELECTED_PY=$py; break; fi
- done
-
- if [ -z "$SELECTED_PY" ]; then
- echo "â ERROR: Python 3 not found. Please install Python 3.13+."
- exit 1
- fi
- echo "Detected Python candidate: $SELECTED_PY"
- $SELECTED_PY -m venv $VENV_DIR
- echo "â
Virtual environment created."
-fi
-
-echo "Using environment Python: $($PYTHON_CMD --version)"
-echo "â
Environment configured."
-
-# Integrity check
-_REBUILD_COUNT="${_REBUILD_COUNT:-0}"
-if ! "$PYTHON_CMD" -c "import json, os, sys" > /dev/null 2>&1; then
- _REBUILD_COUNT=$((_REBUILD_COUNT + 1))
- if [ "$_REBUILD_COUNT" -gt 2 ]; then
- echo "â FATAL: Environment rebuild loop detected. Aborting."
- exit 1
- fi
- echo "â FAILURE: Python environment is unstable. Forcing rebuild (attempt ${_REBUILD_COUNT}/2)..."
- rm -rf "$VENV_DIR"
- exec env _REBUILD_COUNT="$_REBUILD_COUNT" "$0" "$@"
- exit 1
-fi
-echo "â
Python environment integrity verified."
-
-# --- Phase 3: Dependency Sync ---
-echo "--- Phase 3: Synchronizing dependencies ---"
-echo "Checking for pip updates..."
-"$PYTHON_CMD" -m pip install --upgrade pip > /dev/null 2>&1
-
-if [ -f "requirements.lock" ]; then
- DEP_FILE="requirements.lock"
- echo "Found lockfile: $DEP_FILE"
-else
- DEP_FILE="requirements.txt"
- echo "Found dependency list: $DEP_FILE"
-fi
-
-echo "Verifying installed packages (this may take a moment)..."
-if ! "$PYTHON_CMD" -m pip install -r $DEP_FILE > /dev/null 2>&1; then
- echo "â ïž Silent installation failed. Attempting with logs..."
- "$PYTHON_CMD" -m pip install -r $DEP_FILE
-fi
-echo "â
Dependencies synchronized and verified."
-
-# PyQt6 specific check
-if ! "$PYTHON_CMD" -c "import PyQt6" > /dev/null 2>&1; then
- echo "ð§ Corrective installation of PyQt6..."
- "$PYTHON_CMD" -m pip install PyQt6
-fi
-
-# send2trash specific check
-if ! "$PYTHON_CMD" -c "import send2trash" > /dev/null 2>&1; then
- echo "ð§ Corrective installation of send2trash..."
- "$PYTHON_CMD" -m pip install send2trash
-fi
-
-# Export module dependencies check
-if ! "$PYTHON_CMD" -c "import plyfile" > /dev/null 2>&1; then
- echo "ð§ Installation de plyfile (export PLY)..."
- "$PYTHON_CMD" -m pip install plyfile
-fi
-
-# trimesh for GLB export
-if ! "$PYTHON_CMD" -c "import trimesh" > /dev/null 2>&1; then
- echo "ð§ Installation de trimesh (export GLB)..."
- "$PYTHON_CMD" -m pip install trimesh
-fi
-
-# --- Phase 4: Engine & Core Component Monitoring ---
-echo "--- Phase 4: Verifying engines and external binaries ---"
-echo "Running system check..."
-"$PYTHON_CMD" -m app.scripts.setup_dependencies --startup
-echo "â
System check complete (Engines & Binaries)."
-
-if [[ $(uname -m) == 'arm64' ]]; then
- echo "â
Architecture: Apple Silicon detected (Optimizations active)."
-else
- echo "â¹ïž Architecture: x86_64 detected."
-fi
-
-# --- Phase 5: Launch ---
-echo "--- Phase 5: Launching CorbeauSplat ---"
-echo "------------------------------------------------"
-if [ ${#FILTERED_ARGS[@]} -gt 0 ]; then
- "$PYTHON_CMD" main.py "${FILTERED_ARGS[@]}"
-else
- "$PYTHON_CMD" main.py
-fi
diff --git a/tests/test_base_engine.py b/tests/test_base_engine.py
index 064888e..1ad5458 100644
--- a/tests/test_base_engine.py
+++ b/tests/test_base_engine.py
@@ -38,9 +38,10 @@ def test_traversal_attempt_blocked(self, engine, tmp_path):
result = engine.validate_path(str(malicious))
assert result is None
- def test_absolute_path_outside_allowed(self, engine):
- result = engine.validate_path("/opt/corbeausplat_secret")
- assert result is None
+ def test_absolute_path_outside_project_allowed(self, engine):
+ # Desktop tool: user-chosen absolute paths (any drive/folder) are allowed.
+ result = engine.validate_path("/opt/corbeausplat_output")
+ assert result == Path("/opt/corbeausplat_output").resolve()
def test_empty_path_returns_none(self, engine):
assert engine.validate_path("") is None
diff --git a/tests/test_brush_engine.py b/tests/test_brush_engine.py
index 0bcae0c..8b7699f 100644
--- a/tests/test_brush_engine.py
+++ b/tests/test_brush_engine.py
@@ -103,18 +103,24 @@ def test_none_params_are_skipped(self, engine):
class TestBuildCommandEnv:
- def test_mps_device_sets_metal(self, engine):
- params = {"device": "mps"}
+ def test_cuda_device_sets_vulkan_default(self, engine):
+ params = {"device": "cuda"}
cmd, env = engine.build_command("/input", "/output", params)
- assert env["WGPU_BACKEND"] == "metal"
+ assert env["WGPU_BACKEND"] == "vulkan"
assert env["WGPU_POWER_PREF"] == "high_performance"
- def test_cuda_device_sets_vulkan(self, engine):
- params = {"device": "cuda"}
+ def test_auto_device_sets_vulkan_default(self, engine):
+ params = {"device": "auto"}
cmd, env = engine.build_command("/input", "/output", params)
assert env["WGPU_BACKEND"] == "vulkan"
assert env["WGPU_POWER_PREF"] == "high_performance"
+ def test_wgpu_backend_override(self, engine):
+ cmd, env = engine.build_command("/input", "/output", {"device": "cuda"}, backend_override="dx12")
+ assert env["WGPU_BACKEND"] == "dx12"
+ cmd, env = engine.build_command("/input", "/output", {"device": "cuda", "wgpu_backend": "dx12"})
+ assert env["WGPU_BACKEND"] == "dx12"
+
def test_cpu_device_no_wgpu_override(self, engine):
params = {"device": "cpu"}
cmd, env = engine.build_command("/input", "/output", params)
@@ -123,10 +129,10 @@ def test_cpu_device_no_wgpu_override(self, engine):
class TestBuildCommandCustomArgs:
def test_allowed_flag_included(self, engine):
- params = {"custom_args": "--log-level debug"}
+ params = {"custom_args": "--ssim-weight 0.3"}
cmd, env = engine.build_command("/input", "/output", params)
- assert "--log-level" in cmd
- assert "debug" in cmd
+ assert "--ssim-weight" in cmd
+ assert "0.3" in cmd
def test_disallowed_flag_filtered(self, engine):
params = {"custom_args": "--malicious-flag value"}
@@ -134,8 +140,17 @@ def test_disallowed_flag_filtered(self, engine):
assert "--malicious-flag" not in cmd
def test_mixed_allowed_and_disallowed(self, engine):
- params = {"custom_args": "--log-level info --evil-flag payload --test-split 0.1"}
+ params = {"custom_args": "--lpips-loss-weight 0.05 --evil-flag payload --eval-split-every 8"}
cmd, env = engine.build_command("/input", "/output", params)
- assert "--log-level" in cmd
- assert "--test-split" in cmd
+ assert "--lpips-loss-weight" in cmd
+ assert "--eval-split-every" in cmd
assert "--evil-flag" not in cmd
+
+ def test_phantom_brush_flags_rejected(self, engine):
+ # These flags do NOT exist in Brush v0.3.0 â passing them makes Brush
+ # abort with a clap "unexpected argument" error, so the whitelist must
+ # filter them out (they used to be wrongly whitelisted).
+ params = {"custom_args": "--refine-pose --test-split 0.1 --log-level debug --save-iterations 5000"}
+ cmd, env = engine.build_command("/input", "/output", params)
+ for phantom in ("--refine-pose", "--test-split", "--log-level", "--save-iterations"):
+ assert phantom not in cmd
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 9189180..4386d42 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,7 +1,6 @@
"""Tests pour app.cli â CLI dispatch et parsing argparse."""
import sys
-from pathlib import Path
-from unittest.mock import Mock, patch, MagicMock, call, PropertyMock
+from unittest.mock import MagicMock, patch
import pytest
@@ -81,7 +80,7 @@ def test_pipeline_with_all_options(self):
"--preset", "dense",
"--iterations", "50000",
"--sh_degree", "3",
- "--device", "mps",
+ "--device", "cuda",
"--with_viewer",
"--ply_name", "result.ply",
])
@@ -96,7 +95,7 @@ def test_pipeline_with_all_options(self):
assert args.preset == "dense"
assert args.iterations == 50000
assert args.sh_degree == 3
- assert args.device == "mps"
+ assert args.device == "cuda"
assert args.with_viewer is True
assert args.ply_name == "result.ply"
@@ -133,39 +132,12 @@ def test_brush_command(self):
"-o", "/output",
"--preset", "fast",
"--iterations", "7000",
- "--device", "mps",
+ "--device", "cuda",
])
assert args.command == "brush"
assert args.preset == "fast"
assert args.iterations == 7000
- assert args.device == "mps"
-
- def test_sharp_command(self):
- """Sous-commande sharp avec mode image (défaut)."""
- from app.cli.parser import get_parser
- parser = get_parser()
- args = parser.parse_args([
- "sharp",
- "-i", "/input/photo.jpg",
- "-o", "/output",
- ])
- assert args.command == "sharp"
- assert args.mode == "image"
-
- def test_sharp_video_command(self):
- """Sous-commande sharp en mode vidéo."""
- from app.cli.parser import get_parser
- parser = get_parser()
- args = parser.parse_args([
- "sharp",
- "-i", "/input/video.mp4",
- "-o", "/output",
- "--mode", "video",
- "--skip_frames", "3",
- ])
- assert args.command == "sharp"
- assert args.mode == "video"
- assert args.skip_frames == 3
+ assert args.device == "cuda"
def test_upscale_command(self):
"""Sous-commande upscale."""
@@ -240,25 +212,23 @@ class TestCLIDispatch:
def test_main_no_args_launches_gui(self):
"""main() sans argument â _launch_gui() est appelé."""
- with patch("app.cli._launch_gui") as mock_gui:
- with patch("app.cli.check_dependencies", return_value=[]):
- with patch.object(sys, "argv", ["main.py"]):
- from app.cli import main
- main()
- mock_gui.assert_called_once()
+ with patch("app.cli._launch_gui") as mock_gui, patch("app.cli.check_dependencies", return_value=[]):
+ with patch.object(sys, "argv", ["main.py"]):
+ from app.cli import main
+ main()
+ mock_gui.assert_called_once()
def test_main_gui_flag(self):
"""main() avec --gui â _launch_gui() est appelé."""
- with patch("app.cli._launch_gui") as mock_gui:
- with patch("app.cli.check_dependencies", return_value=[]):
- with patch.object(sys, "argv", ["main.py", "--gui"]):
- from app.cli import main
- main()
- mock_gui.assert_called_once()
+ with patch("app.cli._launch_gui") as mock_gui, patch("app.cli.check_dependencies", return_value=[]):
+ with patch.object(sys, "argv", ["main.py", "--gui"]):
+ from app.cli import main
+ main()
+ mock_gui.assert_called_once()
def test_main_pipeline_dispatch(self):
"""main() avec pipeline â run_pipeline est appelé."""
- with patch("app.cli.DISPATCH", new_callable=dict) as mock_dispatch:
+ with patch("app.cli.commands.DISPATCH", new_callable=dict) as mock_dispatch:
handler = MagicMock()
mock_dispatch["pipeline"] = handler
@@ -270,7 +240,7 @@ def test_main_pipeline_dispatch(self):
def test_main_colmap_dispatch(self):
"""main() avec colmap â run_colmap est appelé."""
- with patch("app.cli.DISPATCH", new_callable=dict) as mock_dispatch:
+ with patch("app.cli.commands.DISPATCH", new_callable=dict) as mock_dispatch:
handler = MagicMock()
mock_dispatch["colmap"] = handler
@@ -283,7 +253,7 @@ def test_main_colmap_dispatch(self):
def test_main_unknown_command_shows_help(self):
"""Commande inconnue â print_help() est appelé."""
with patch("app.cli.get_parser") as mock_get_parser:
- with patch("app.cli.DISPATCH", new_callable=dict) as mock_dispatch:
+ with patch("app.cli.commands.DISPATCH", new_callable=dict) as mock_dispatch:
mock_parser = MagicMock()
mock_get_parser.return_value = mock_parser
mock_args = MagicMock()
@@ -297,21 +267,21 @@ def test_main_unknown_command_shows_help(self):
main()
mock_parser.print_help.assert_called_once()
- def test_main_sharp_dispatch(self):
- """main() avec sharp â run_sharp est appelé."""
- with patch("app.cli.DISPATCH", new_callable=dict) as mock_dispatch:
+ def test_main_colmap_dispatch(self):
+ """main() avec colmap â le handler est appelé."""
+ with patch("app.cli.commands.DISPATCH", new_callable=dict) as mock_dispatch:
handler = MagicMock()
- mock_dispatch["sharp"] = handler
+ mock_dispatch["colmap"] = handler
with patch("app.cli.check_dependencies", return_value=[]):
- with patch.object(sys, "argv", ["main.py", "sharp", "-i", "/in.jpg", "-o", "/out"]):
+ with patch.object(sys, "argv", ["main.py", "colmap", "-i", "/in", "-o", "/out"]):
from app.cli import main
main()
handler.assert_called_once()
def test_main_dependencies_missing(self):
"""Dépendances manquantes â message affiché (avec sous-commande)."""
- with patch("app.cli.DISPATCH", new_callable=dict) as mock_dispatch:
+ with patch("app.cli.commands.DISPATCH", new_callable=dict) as mock_dispatch:
handler = MagicMock()
mock_dispatch["pipeline"] = handler
with patch("app.cli.check_dependencies", return_value=["ffmpeg", "colmap"]):
@@ -343,7 +313,7 @@ def test_run_colmap(self, mock_params_cls, mock_engine_cls):
args.max_image_size = 3200
args.max_num_features = 8192
args.estimate_affine_shape = False
- args.no_domain_size_pooling = False
+ args.domain_size_pooling = False
args.max_ratio = 0.8
args.max_distance = 0.7
args.no_cross_check = False
@@ -402,47 +372,6 @@ def test_run_brush(self, mock_get_mode, mock_engine_cls):
mock_engine.train.assert_called_once()
- @patch("app.cli.commands.SharpEngine")
- def test_run_sharp_image(self, mock_engine_cls):
- """run_sharp en mode image exécute predict()."""
- mock_engine = MagicMock()
- mock_engine.predict.return_value = 0
- mock_engine_cls.return_value = mock_engine
-
- args = MagicMock()
- args.mode = "image"
- args.checkpoint = None
- args.device = "default"
- args.verbose = False
- args.input = "/in/photo.jpg"
- args.output = "/out"
-
- from app.cli.commands import run_sharp
- run_sharp(args)
-
- mock_engine.predict.assert_called_once()
-
- @patch("app.cli.commands.SharpEngine")
- def test_run_sharp_video(self, mock_engine_cls):
- """run_sharp en mode vidéo exécute process_video_frames()."""
- mock_engine = MagicMock()
- mock_engine.process_video_frames.return_value = 10
- mock_engine_cls.return_value = mock_engine
-
- args = MagicMock()
- args.mode = "video"
- args.checkpoint = None
- args.device = "default"
- args.verbose = False
- args.input = "/in/video.mp4"
- args.output = "/out"
- args.skip_frames = 1
-
- from app.cli.commands import run_sharp
- run_sharp(args)
-
- mock_engine.process_video_frames.assert_called_once()
-
class TestPipelineRun:
"""Tests pour run_pipeline (pipeline complet COLMAP â Brush)."""
@@ -485,3 +414,22 @@ def test_pipeline_success(self, mock_get_mode, mock_brush_cls, mock_colmap_cls):
mock_colmap.run.assert_called_once()
mock_brush.train.assert_called_once()
+
+
+class TestRobustMode:
+ """Tests pour le mode robuste (anti-crash COLMAP)."""
+
+ def test_apply_robust_sets_stable_params(self):
+ from app.cli.commands import _apply_robust
+ from app.core.params import ColmapParams
+ p = _apply_robust(ColmapParams(camera_model="SIMPLE_RADIAL"))
+ assert p.camera_model == "PINHOLE"
+ assert p.ba_refine_extra_params is False
+ assert p.ba_refine_principal_point is False
+ assert p.multiple_models is True
+ assert p.filter_blurry is True
+
+ def test_robust_flag_parses(self):
+ from app.cli.parser import get_parser
+ args = get_parser().parse_args(["colmap", "-i", "x", "-o", "y", "--robust"])
+ assert args.robust is True
diff --git a/tests/test_colmap_engine.py b/tests/test_colmap_engine.py
index f0714b0..6d1431f 100644
--- a/tests/test_colmap_engine.py
+++ b/tests/test_colmap_engine.py
@@ -29,7 +29,7 @@ def test_path_inside_project_root(self, tmp_path):
subdir.mkdir()
with patch("app.core.system.resolve_project_root", return_value=tmp_path):
- with patch("app.core.engine.send2trash.send2trash") as mock_trash:
+ with patch("send2trash.send2trash") as mock_trash:
result, msg = ColmapEngine.delete_project_content(subdir)
assert result is True
assert "corbeille" in msg
@@ -43,7 +43,7 @@ def test_path_inside_home_blocked(self):
try:
with patch("app.core.system.resolve_project_root", return_value=Path("/tmp/fake_project")):
- with patch("app.core.engine.send2trash.send2trash") as mock_trash:
+ with patch("send2trash.send2trash") as mock_trash:
result, msg = ColmapEngine.delete_project_content(home_subdir)
assert result is False
assert "bloquée" in msg
@@ -110,7 +110,7 @@ def test_images_skipped(self, tmp_path):
other_dir.mkdir()
with patch("app.core.system.resolve_project_root", return_value=tmp_path):
- with patch("app.core.engine.send2trash.send2trash") as mock_trash:
+ with patch("send2trash.send2trash") as mock_trash:
result, msg = ColmapEngine.delete_project_content(project)
assert result is True
# other should be trashed, images should NOT be trashed
@@ -125,7 +125,7 @@ class TestBuildCommand:
"""Tests pour la construction des commandes COLMAP."""
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_feature_extraction_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""feature_extraction construit la bonne commande COLMAP."""
mock_silicon.return_value = False
@@ -161,7 +161,7 @@ def test_feature_extraction_command(self, mock_silicon, mock_resolve_binary, tmp
assert "--SiftExtraction.max_num_features" in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_sequential_matcher_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""sequential_matcher est utilisé quand matcher_type='sequential'."""
mock_silicon.return_value = False
@@ -190,7 +190,7 @@ def test_sequential_matcher_command(self, mock_silicon, mock_resolve_binary, tmp
assert "--SequentialMatching.overlap" in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_exhaustive_matcher_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""exhaustive_matcher est utilisé quand matcher_type='exhaustive'."""
mock_silicon.return_value = False
@@ -217,7 +217,7 @@ def test_exhaustive_matcher_command(self, mock_silicon, mock_resolve_binary, tmp
assert "sequential_matcher" not in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_mapper_colmap_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""Mapper utilise COLMAP par défaut."""
mock_silicon.return_value = False
@@ -248,7 +248,7 @@ def test_mapper_colmap_command(self, mock_silicon, mock_resolve_binary, tmp_path
assert "glomap" not in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_mapper_glomap_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""Mapper utilise GLOMAP quand use_glomap=True."""
mock_silicon.return_value = False
@@ -271,7 +271,7 @@ def test_mapper_glomap_command(self, mock_silicon, mock_resolve_binary, tmp_path
assert "mapper" in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_image_undistorter_command(self, mock_silicon, mock_resolve_binary, tmp_path):
"""image_undistorter construit la bonne commande."""
mock_silicon.return_value = False
@@ -294,10 +294,10 @@ def test_image_undistorter_command(self, mock_silicon, mock_resolve_binary, tmp_
assert "--output_type" in cmd
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
- def test_feature_extraction_hwaccel_apple_silicon(self, mock_silicon, mock_resolve_binary, tmp_path):
- """Test que Apple Silicon active l'accélération matérielle via videotoolbox dans extract_frames."""
- mock_silicon.return_value = True
+ @patch("app.core.engine.has_cuda")
+ def test_feature_extraction_hwaccel_cuda(self, mock_cuda, mock_resolve_binary, tmp_path):
+ """Test qu'un GPU CUDA active l'accélération matérielle (-hwaccel cuda) dans extract_frames."""
+ mock_cuda.return_value = True
mock_resolve_binary.side_effect = lambda x: x
from app.core.engine import ColmapEngine
@@ -316,8 +316,8 @@ def test_feature_extraction_hwaccel_apple_silicon(self, mock_silicon, mock_resol
"video", 5, logger_callback=print
)
- # Check that is_silicon flag is set
- assert engine.is_silicon is True
+ # Check that has_cuda flag is set
+ assert engine.has_cuda is True
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
@@ -328,7 +328,7 @@ class TestCheckAndNormalizeResolution:
"""Tests pour _check_and_normalize_resolution()."""
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_cv2_not_loaded_returns_true(self, mock_silicon, mock_resolve_binary, tmp_path):
"""cv2 non chargé â retourne True immédiatement."""
mock_silicon.return_value = False
@@ -347,7 +347,7 @@ def test_cv2_not_loaded_returns_true(self, mock_silicon, mock_resolve_binary, tm
assert result is True
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_uniform_resolution(self, mock_silicon, mock_resolve_binary, tmp_path):
"""Toutes les images ont la même résolution â True."""
mock_silicon.return_value = False
@@ -377,7 +377,7 @@ def test_uniform_resolution(self, mock_silicon, mock_resolve_binary, tmp_path):
assert result is True
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_fewer_than_2_images_returns_true(self, mock_silicon, mock_resolve_binary, tmp_path):
"""Moins de 2 images â True (pas besoin de normaliser)."""
mock_silicon.return_value = False
@@ -408,7 +408,7 @@ class TestColmapUtils:
"""Tests pour les méthodes utilitaires de ColmapEngine."""
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_project_path_property(self, mock_silicon, mock_resolve_binary, tmp_path):
"""project_path retourne le output_path."""
mock_silicon.return_value = False
@@ -424,7 +424,7 @@ def test_project_path_property(self, mock_silicon, mock_resolve_binary, tmp_path
assert engine.project_path == engine.output_path
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_validate_and_setup_paths_success(self, mock_silicon, mock_resolve_binary, tmp_path):
"""_validate_and_setup_paths crée la structure de dossiers."""
mock_silicon.return_value = False
@@ -453,7 +453,7 @@ def test_validate_and_setup_paths_success(self, mock_silicon, mock_resolve_binar
assert checkpoints_dir.exists()
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_validate_project_name_with_dots_blocked(self, mock_silicon, mock_resolve_binary, tmp_path):
"""Nom de projet avec '..' â None."""
mock_silicon.return_value = False
@@ -475,7 +475,7 @@ def test_validate_project_name_with_dots_blocked(self, mock_silicon, mock_resolv
assert result is None
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_convert_db_journal_mode(self, mock_silicon, mock_resolve_binary, tmp_path):
"""_convert_db_journal_mode s'exécute sans erreur."""
mock_silicon.return_value = False
@@ -495,7 +495,7 @@ def test_convert_db_journal_mode(self, mock_silicon, mock_resolve_binary, tmp_pa
# No exception means success
@patch("app.core.engine.resolve_binary")
- @patch("app.core.engine.is_apple_silicon")
+ @patch("app.core.engine.has_cuda")
def test_create_brush_config(self, mock_silicon, mock_resolve_binary, tmp_path):
"""create_brush_config génÚre le fichier JSON."""
mock_silicon.return_value = False
@@ -528,3 +528,38 @@ def test_create_brush_config(self, mock_silicon, mock_resolve_binary, tmp_path):
config = json.loads(config_file.read_text())
assert config["dataset_type"] == "colmap"
assert config["parameters"]["test"] is True
+
+
+# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+# Blur filtering selection logic
+# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+
+class TestSelectBlurryFiles:
+ """Tests pour engine.select_blurry_files()."""
+
+ def test_discards_below_factor_of_median(self):
+ from app.core.engine import select_blurry_files
+ # median of [10,100,100,100,100] = 100; factor 0.7 -> threshold 70
+ scores = {"a": 10.0, "b": 100.0, "c": 100.0, "d": 100.0, "e": 100.0}
+ rejected, threshold = select_blurry_files(scores, 0.7)
+ assert threshold == 70.0
+ assert rejected == ["a"]
+
+ def test_disabled_when_factor_zero(self):
+ from app.core.engine import select_blurry_files
+ scores = {"a": 1.0, "b": 100.0}
+ rejected, _ = select_blurry_files(scores, 0.0)
+ assert rejected == []
+
+ def test_empty_scores(self):
+ from app.core.engine import select_blurry_files
+ assert select_blurry_files({}, 0.7) == ([], 0.0)
+
+ def test_cap_limits_removals_to_blurriest(self):
+ from app.core.engine import select_blurry_files
+ # 3 blurry (score 1) + 7 sharp (score 100); median 100, threshold 70 -> 3 below.
+ # With a 10% cap on 10 files, only the single blurriest may be removed.
+ scores = {"a": 1.0, "b": 2.0, "c": 3.0}
+ scores.update({f"s{i}": 100.0 for i in range(7)})
+ rejected, _ = select_blurry_files(scores, 0.7, max_remove_frac=0.1)
+ assert rejected == ["a"] # cap = int(10 * 0.1) = 1, blurriest kept
diff --git a/tests/test_four_dgs_engine.py b/tests/test_four_dgs_engine.py
index 19371fa..57dfc55 100644
--- a/tests/test_four_dgs_engine.py
+++ b/tests/test_four_dgs_engine.py
@@ -113,12 +113,12 @@ def test_extract_frames(self, tmp_path):
assert result is True
assert output_dir.exists()
- def test_extract_frames_apple_silicon(self, tmp_path):
- """extract_frames ajoute -hwaccel videotoolbox sur Apple Silicon."""
+ def test_extract_frames_cuda(self, tmp_path):
+ """extract_frames ajoute -hwaccel cuda quand un GPU NVIDIA est présent."""
with patch("app.core.four_dgs_engine.resolve_project_root", return_value=tmp_path):
with patch("app.core.four_dgs_engine.resolve_binary") as mock_resolve:
mock_resolve.side_effect = lambda x: x
- with patch("app.core.four_dgs_engine.is_apple_silicon", return_value=True):
+ with patch("app.core.four_dgs_engine.has_cuda", return_value=True):
from app.core.four_dgs_engine import FourDGSEngine
diff --git a/tests/test_managers.py b/tests/test_managers.py
index 1a770ef..2124691 100644
--- a/tests/test_managers.py
+++ b/tests/test_managers.py
@@ -23,16 +23,16 @@ class TestAppLifecycleResetFactory:
@patch("shutil.rmtree")
@patch("app.gui.managers.resolve_project_root")
def test_reset_factory_light(self, mock_root, mock_rmtree, mock_popen, tmp_path):
- """reset_factory(deep=False) supprime .venv, .venv_sharp, .venv_360."""
+ """reset_factory(deep=False) supprime .venv, .venv_360, .venv_4dgs."""
mock_root.return_value = tmp_path
# Create the venv dirs
(tmp_path / ".venv").mkdir()
- (tmp_path / ".venv_sharp").mkdir()
(tmp_path / ".venv_360").mkdir()
- # Create run.command for relaunch
- run_cmd = tmp_path / "run.command"
- run_cmd.write_text("#!/bin/bash\necho run")
+ (tmp_path / ".venv_4dgs").mkdir()
+ # Create run.bat for relaunch
+ run_cmd = tmp_path / "run.bat"
+ run_cmd.write_text("@echo off\necho run")
from app.gui.managers import AppLifecycle
@@ -55,12 +55,12 @@ def test_reset_factory_deep(self, mock_root, mock_rmtree, mock_popen, tmp_path):
# Create dirs
(tmp_path / ".venv").mkdir()
- (tmp_path / ".venv_sharp").mkdir()
(tmp_path / ".venv_360").mkdir()
+ (tmp_path / ".venv_4dgs").mkdir()
(tmp_path / "engines").mkdir()
(tmp_path / "config.json").write_text("{}")
- run_cmd = tmp_path / "run.command"
- run_cmd.write_text("#!/bin/bash")
+ run_cmd = tmp_path / "run.bat"
+ run_cmd.write_text("@echo off")
from app.gui.managers import AppLifecycle
@@ -78,13 +78,13 @@ def test_reset_factory_path_outside_root_blocked(self, mock_root, mock_rmtree, m
# Create a symlink that points outside (simulate)
(tmp_path / ".venv").mkdir()
- (tmp_path / ".venv_sharp").mkdir()
+ (tmp_path / ".venv_360").mkdir()
from app.gui.managers import AppLifecycle
with patch.object(sys, "exit") as mock_exit:
AppLifecycle.reset_factory(deep=False)
- # Should only try to remove .venv and .venv_sharp (within project_root)
+ # Should only try to remove .venv and .venv_360 (within project_root)
# Not calling rmtree on paths outside root
assert mock_rmtree.call_count >= 2
@@ -122,19 +122,19 @@ def test_reset_factory_rmtree_error_handled(self, mock_root, mock_rmtree, mock_p
@patch("shutil.rmtree")
@patch("app.gui.managers.resolve_project_root")
def test_reset_factory_relaunch_via_run_command(self, mock_root, mock_rmtree, mock_popen, tmp_path):
- """reset_factory relance via run.command."""
+ """reset_factory relance via run.bat."""
mock_root.return_value = tmp_path
- run_cmd = tmp_path / "run.command"
- run_cmd.write_text("#!/bin/bash")
+ run_cmd = tmp_path / "run.bat"
+ run_cmd.write_text("@echo off")
from app.gui.managers import AppLifecycle
with patch.object(sys, "exit") as mock_exit:
AppLifecycle.reset_factory(deep=False)
- # Should use "open" for run.command
+ # Should use cmd/start to launch run.bat
popen_args = mock_popen.call_args[0][0]
- assert "open" in popen_args
- assert str(run_cmd) in popen_args or "run.command" in str(popen_args)
+ assert "cmd" in popen_args
+ assert str(run_cmd) in popen_args or "run.bat" in str(popen_args)
@patch("app.gui.managers.subprocess.Popen")
@patch("shutil.rmtree")
@@ -227,8 +227,9 @@ def session_manager(self, request, tmp_path):
main_window = MagicMock()
# Mock tabs with get_state returning serializable dicts
- for tab_name in ["config_tab", "params_tab", "brush_tab", "sharp_tab",
- "upscale_tab", "extractor_360_tab", "four_dgs_tab", "superplat_tab"]:
+ for tab_name in ["config_tab", "params_tab", "brush_tab",
+ "upscale_tab", "extractor_360_tab", "four_dgs_tab", "superplat_tab",
+ "cleaner_tab"]:
tab = MagicMock()
tab.get_state = MagicMock(return_value={"param1": "value1"})
setattr(main_window, tab_name, tab)
@@ -258,7 +259,7 @@ def test_save_and_load_roundtrip(self, session_manager, tmp_path):
session_manager.load()
# Verify set_state was called
- for tab_name in ["config_tab", "params_tab", "brush_tab", "sharp_tab"]:
+ for tab_name in ["config_tab", "params_tab", "brush_tab"]:
tab = getattr(session_manager.mw, tab_name)
tab.set_state.assert_called_with({"param1": "value1"})
diff --git a/tests/test_ply_cleaner.py b/tests/test_ply_cleaner.py
new file mode 100644
index 0000000..d7356f9
--- /dev/null
+++ b/tests/test_ply_cleaner.py
@@ -0,0 +1,75 @@
+"""Tests pour app/core/ply_cleaner.py â logique de nettoyage des splats."""
+import math
+
+import numpy as np
+
+from app.core.ply_cleaner import PRESETS, compute_keep_mask, resolve_params
+
+
+def _logit(alpha):
+ return math.log(alpha / (1 - alpha))
+
+
+class TestComputeKeepMask:
+ def test_removes_transparent_splats(self):
+ # alphas: 0.01 (transparent), 0.9, 0.9 -> opacity_min 0.1 drops the first
+ opacity = np.array([_logit(0.01), _logit(0.9), _logit(0.9)])
+ zeros = np.zeros(3)
+ keep, stats = compute_keep_mask(
+ zeros, zeros, zeros, opacity, zeros, zeros, zeros,
+ opacity_min=0.1, scale_pct=100.0, outlier_pct=100.0,
+ )
+ assert list(keep) == [False, True, True]
+ assert stats["removed_opacity"] == 1
+ assert stats["kept"] == 2
+
+ def test_removes_oversized_splats(self):
+ # one giant splat (log-scale large) vs small ones
+ n = 10
+ opacity = np.full(n, _logit(0.9))
+ scales = np.full(n, math.log(0.01))
+ scales[0] = math.log(100.0) # giant
+ zeros = np.zeros(n)
+ keep, stats = compute_keep_mask(
+ zeros, zeros, zeros, opacity, scales, scales, scales,
+ opacity_min=0.0, scale_pct=95.0, outlier_pct=100.0,
+ )
+ assert keep[0] == False # noqa: E712 - the giant is dropped
+ assert stats["removed_scale"] >= 1
+
+ def test_removes_spatial_outlier(self):
+ # 9 points near origin, 1 far away
+ x = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1000.0])
+ y = np.zeros(10)
+ z = np.zeros(10)
+ opacity = np.full(10, _logit(0.9))
+ zeros = np.zeros(10)
+ keep, stats = compute_keep_mask(
+ x, y, z, opacity, zeros, zeros, zeros,
+ opacity_min=0.0, scale_pct=100.0, outlier_pct=90.0,
+ )
+ assert keep[-1] == False # noqa: E712 - far floater dropped
+ assert stats["removed_outlier"] >= 1
+
+ def test_disabled_thresholds_keep_all(self):
+ opacity = np.array([_logit(0.5), _logit(0.5)])
+ zeros = np.zeros(2)
+ keep, stats = compute_keep_mask(
+ zeros, zeros, zeros, opacity, zeros, zeros, zeros,
+ opacity_min=0.0, scale_pct=100.0, outlier_pct=100.0,
+ )
+ assert keep.all()
+ assert stats["removed"] == 0
+
+
+class TestPresets:
+ def test_presets_exist(self):
+ assert set(PRESETS) == {"light", "medium", "strong"}
+
+ def test_resolve_params_overrides(self):
+ p = resolve_params("medium", {"opacity_min": 0.42})
+ assert p["opacity_min"] == 0.42
+ assert p["scale_pct"] == PRESETS["medium"]["scale_pct"]
+
+ def test_resolve_params_unknown_falls_back_to_medium(self):
+ assert resolve_params("nope") == PRESETS["medium"]
diff --git a/tests/test_setup_dependencies.py b/tests/test_setup_dependencies.py
index 1306cc3..94ec07a 100644
--- a/tests/test_setup_dependencies.py
+++ b/tests/test_setup_dependencies.py
@@ -185,12 +185,12 @@ def test_check_cargo_not_found(self):
from app.scripts.installers.tools import check_cargo
assert check_cargo() is False
- def test_check_brew(self):
- """check_brew vérifie la présence de brew."""
+ def test_check_winget(self):
+ """check_winget vérifie la présence de winget."""
with patch("app.scripts.installers.tools.shutil.which") as mock_which:
- mock_which.return_value = "/opt/homebrew/bin/brew"
- from app.scripts.installers.tools import check_brew
- assert check_brew() is True
+ mock_which.return_value = r"C:\\Windows\\winget.exe"
+ from app.scripts.installers.tools import check_winget
+ assert check_winget() is True
def test_check_node(self):
"""check_node vérifie node et npm."""
@@ -217,27 +217,6 @@ def test_check_cmake_ninja(self):
from app.scripts.installers.tools import check_cmake_ninja
assert check_cmake_ninja() is True
- def test_check_xcode_tools_present(self):
- """check_xcode_tools retourne True si xcode-select -p réussit."""
- with patch("app.scripts.installers.tools.subprocess.check_call") as mock_check:
- from app.scripts.installers.tools import check_xcode_tools
- assert check_xcode_tools() is True
- mock_check.assert_called_once_with(
- ["xcode-select", "-p"],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
-
- def test_check_xcode_tools_missing(self):
- """check_xcode_tools retourne False si xcode-select échoue."""
- import sys as _sys
- if _sys.platform != "darwin":
- pytest.skip("xcode-select test only relevant on macOS")
- with patch("app.scripts.installers.tools.subprocess.check_call") as mock_check:
- mock_check.side_effect = subprocess.CalledProcessError(1, "xcode-select")
- from app.scripts.installers.tools import check_xcode_tools
- assert check_xcode_tools() is False
-
def test_get_remote_version(self):
"""get_remote_version utilise git ls-remote."""
with patch("app.scripts.installers.tools.subprocess.check_output") as mock_check:
@@ -373,5 +352,39 @@ def test_is_installed_venv(self, tmp_path):
assert engine.is_installed() is True
-# Import subprocess for xcode test
+class TestColmapAssetSelection:
+ """Tests pour mapping.find_colmap_windows_asset()."""
+
+ def test_prefers_cuda_build(self):
+ from app.scripts.installers.mapping import find_colmap_windows_asset
+ assets = [
+ {"name": "colmap-x64-windows-nocuda.zip"},
+ {"name": "colmap-x64-windows-cuda.zip"},
+ {"name": "colmap-x64-linux.zip"},
+ ]
+ result = find_colmap_windows_asset(assets, prefer_cuda=True)
+ assert result is not None
+ assert result["name"] == "colmap-x64-windows-cuda.zip"
+
+ def test_never_picks_nocuda_as_cuda(self):
+ """nocuda must not be mistaken for a CUDA build."""
+ from app.scripts.installers.mapping import find_colmap_windows_asset
+ assets = [{"name": "colmap-x64-windows-nocuda.zip"}]
+ # prefer_cuda=True finds no cuda build, falls back to any windows zip
+ result = find_colmap_windows_asset(assets, prefer_cuda=True)
+ assert result["name"] == "colmap-x64-windows-nocuda.zip" # fallback, not CUDA match
+
+ def test_fallback_any_windows_zip(self):
+ from app.scripts.installers.mapping import find_colmap_windows_asset
+ assets = [{"name": "colmap-x64-windows-nocuda.zip"}, {"name": "src.tar.gz"}]
+ result = find_colmap_windows_asset(assets, prefer_cuda=False)
+ assert result["name"] == "colmap-x64-windows-nocuda.zip"
+
+ def test_no_windows_asset(self):
+ from app.scripts.installers.mapping import find_colmap_windows_asset
+ assets = [{"name": "colmap-x64-linux.zip"}, {"name": "src.tar.gz"}]
+ assert find_colmap_windows_asset(assets) is None
+
+
+# Import subprocess for legacy tests
import subprocess
diff --git a/tests/test_sharp_engine.py b/tests/test_sharp_engine.py
deleted file mode 100644
index 3853044..0000000
--- a/tests/test_sharp_engine.py
+++ /dev/null
@@ -1,272 +0,0 @@
-"""Tests pour app/core/sharp_engine.py â SharpEngine."""
-import os
-import sys
-import shutil
-from pathlib import Path
-from unittest.mock import Mock, patch, MagicMock, call, ANY
-
-import pytest
-
-# Patch missing modules at module level
-for _mod_name in ["cv2", "send2trash"]:
- if _mod_name not in sys.modules:
- sys.modules[_mod_name] = MagicMock()
-
-
-class TestProcessVideoFrames:
- """Tests pour SharpEngine.process_video_frames()."""
-
- def _make_engine(self, tmp_path):
- """Helper pour créer un SharpEngine mocké."""
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
- # Mock the runner to avoid subprocess calls
- engine.runner = MagicMock()
- return engine
-
- @patch("shutil.which")
- @patch("subprocess.run")
- def test_successful_frame_processing(self, mock_subprocess_run, mock_which, tmp_path):
- """Traitement vidéo réussi avec extraction FFmpeg et prédiction Sharp."""
- mock_which.return_value = "/usr/local/bin/ffmpeg"
-
- output_dir = tmp_path / "output"
- frames_dir = output_dir / "temp_frames"
-
- # Mock FFmpeg success by creating frame files as side effect
- def ffmpeg_side_effect(cmd, **kwargs):
- # This simulates what FFmpeg would do: create the frames
- frames_dir.mkdir(parents=True, exist_ok=True)
- for i in range(1, 4):
- (frames_dir / f"frame_{i:04d}.png").write_bytes(b"fake_png")
- mock_result = MagicMock()
- mock_result.returncode = 0
- return mock_result
-
- mock_subprocess_run.side_effect = ffmpeg_side_effect
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- # Mock the predict method to return 0 and create PLY files
- with patch.object(engine, 'predict', return_value=0) as mock_predict:
- def predict_side_effect(frame_path, frame_out_dir, params):
- out = Path(frame_out_dir)
- out.mkdir(parents=True, exist_ok=True)
- (out / "result.ply").write_bytes(b"ply_data")
- return 0
-
- mock_predict.side_effect = predict_side_effect
-
- result = engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(output_dir),
- params={},
- log_callback=print,
- status_callback=lambda s: None,
- progress_callback=lambda p: None,
- cancel_check=None,
- )
-
- assert result == 3 # 3 frames processed
-
- @patch("app.core.sharp_engine.shutil.which")
- @patch("app.core.sharp_engine.subprocess.run")
- def test_ffmpeg_not_found(self, mock_subprocess_run, mock_which, tmp_path):
- """FFmpeg introuvable â retourne 0."""
- mock_which.return_value = None # ffmpeg not found
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- result = engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(tmp_path / "output"),
- params={},
- log_callback=print,
- )
- assert result == 0
-
- @patch("app.core.sharp_engine.shutil.which")
- @patch("app.core.sharp_engine.subprocess.run")
- def test_ffmpeg_error(self, mock_subprocess_run, mock_which, tmp_path):
- """FFmpeg retourne une erreur â retourne 0."""
- mock_which.return_value = "/usr/local/bin/ffmpeg"
-
- mock_result = MagicMock()
- mock_result.returncode = 1
- mock_result.stderr = "Error processing file"
- mock_subprocess_run.return_value = mock_result
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- result = engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(tmp_path / "output"),
- params={},
- log_callback=print,
- )
- assert result == 0
-
- @patch("app.core.sharp_engine.shutil.which")
- @patch("app.core.sharp_engine.subprocess.run")
- def test_no_frames_extracted(self, mock_subprocess_run, mock_which, tmp_path):
- """Aucune frame extraite â retourne 0."""
- mock_which.return_value = "/usr/local/bin/ffmpeg"
-
- mock_result = MagicMock()
- mock_result.returncode = 0
- mock_subprocess_run.return_value = mock_result
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- # No frames in the temp_frames dir (let process_video_frames create it empty)
- result = engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(tmp_path / "output"),
- params={},
- log_callback=print,
- )
- assert result == 0
-
- @patch("shutil.which")
- @patch("subprocess.run")
- def test_cancel_callback_stops_processing(self, mock_subprocess_run, mock_which, tmp_path):
- """Callback d'annulation â arrêt aprÚs la frame en cours."""
- mock_which.return_value = "/usr/local/bin/ffmpeg"
-
- output_dir = tmp_path / "output"
- frames_dir = output_dir / "temp_frames"
-
- # Mock FFmpeg success: create 5 frames
- def ffmpeg_side_effect(cmd, **kwargs):
- frames_dir.mkdir(parents=True, exist_ok=True)
- for i in range(1, 6):
- (frames_dir / f"frame_{i:04d}.png").write_bytes(b"fake_png")
- mock_result = MagicMock()
- mock_result.returncode = 0
- return mock_result
-
- mock_subprocess_run.side_effect = ffmpeg_side_effect
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- # Cancel after the 2nd frame
- cancel_count = [0]
-
- def cancel_check():
- cancel_count[0] += 1
- return cancel_count[0] >= 3 # cancel after reading 2 frames (3rd cancel check)
-
- with patch.object(engine, 'predict', return_value=0) as mock_predict:
- def predict_side_effect(frame_path, frame_out_dir, params):
- Path(frame_out_dir).mkdir(parents=True, exist_ok=True)
- (Path(frame_out_dir) / "result.ply").write_bytes(b"ply_data")
- return 0
-
- mock_predict.side_effect = predict_side_effect
-
- result = engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(output_dir),
- params={},
- log_callback=print,
- cancel_check=cancel_check,
- )
-
- # Should have stopped early (2 frames processed before cancel)
- assert result < 5
- assert result >= 1 # at least 1 before cancellation
-
- @patch("app.core.sharp_engine.shutil.which")
- @patch("app.core.sharp_engine.subprocess.run")
- def test_skip_frames_param(self, mock_subprocess_run, mock_which, tmp_path):
- """skip_frames modifie la commande FFmpeg."""
- mock_which.return_value = "/usr/local/bin/ffmpeg"
-
- mock_result = MagicMock()
- mock_result.returncode = 0
- mock_subprocess_run.return_value = mock_result
-
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
- output_dir = tmp_path / "output"
-
- with patch.object(engine, 'predict', return_value=0):
- # Create empty frames_dir so glob returns empty â we just want to verify
- # the ffmpeg command construction
- engine.process_video_frames(
- video_path=str(tmp_path / "input.mp4"),
- output_dir=str(output_dir),
- params={"skip_frames": 3},
- log_callback=print,
- )
-
- # Check the ffmpeg command that was built
- cmd_args = mock_subprocess_run.call_args[0][0]
- assert "select=not(mod(n\\,3))" in cmd_args or "select=not(mod(n,3))" in cmd_args
-
-
-class TestSharpPredict:
- """Tests pour SharpEngine.predict()."""
-
- def test_predict_command_construction(self, tmp_path):
- """predict construit la bonne commande."""
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
- engine.runner = MagicMock()
- engine.runner.start.return_value = None
- engine.runner.stdout_iter.return_value = iter([])
- engine.runner.wait.return_value = 0
-
- input_path = tmp_path / "input.jpg"
- input_path.write_bytes(b"fake")
- output_path = tmp_path / "output"
- output_path.mkdir()
-
- with patch.object(engine, '_get_sharp_cmd', return_value=["sharp"]):
- result = engine.predict(str(input_path), str(output_path))
- assert result == 0
-
- def test_predict_with_checkpoint(self, tmp_path):
- """predict avec checkpoint ajoute -c."""
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
- engine.runner = MagicMock()
- engine.runner.start.return_value = None
- engine.runner.stdout_iter.return_value = iter([])
- engine.runner.wait.return_value = 0
-
- input_path = tmp_path / "input.jpg"
- input_path.write_bytes(b"fake")
- output_path = tmp_path / "output"
- output_path.mkdir()
- ckpt_path = tmp_path / "model.pt"
- ckpt_path.write_bytes(b"checkpoint")
-
- with patch.object(engine, '_get_sharp_cmd', return_value=["sharp"]):
- result = engine.predict(str(input_path), str(output_path), params={"checkpoint": str(ckpt_path)})
- assert result == 0
-
- def test_is_installed_no_sharp(self, tmp_path):
- """is_installed retourne False quand Sharp n'est pas installé."""
- from app.core.sharp_engine import SharpEngine
-
- engine = SharpEngine(logger_callback=print)
-
- with patch("app.core.sharp_engine.resolve_project_root", return_value=tmp_path):
- with patch("importlib.util.find_spec", return_value=None):
- with patch("shutil.which", return_value=None):
- assert engine.is_installed() is False
diff --git a/tests/test_upscayl_manager.py b/tests/test_upscayl_manager.py
index b8b69d2..3012457 100644
--- a/tests/test_upscayl_manager.py
+++ b/tests/test_upscayl_manager.py
@@ -298,14 +298,14 @@ def test_download_binary_success(
mock_fetch_release.return_value = {
"assets": [
{
- "name": "upscayl-macos-arm64.tar.gz",
+ "name": "upscayl-windows-x86_64.zip",
"size": 5 * 1024 * 1024,
- "browser_download_url": "https://example.com/upscayl.tar.gz",
+ "browser_download_url": "https://example.com/upscayl.zip",
}
]
}
mock_verify.return_value = True
- mock_load_checksums.return_value = {"darwin_upscayl": "aa" * 32}
+ mock_load_checksums.return_value = {"windows_upscayl": "aa" * 32}
# Mock HTTP download with context manager
mock_resp = MagicMock()
@@ -326,15 +326,15 @@ def test_download_binary_success(
assert mock_urlopen.call_count >= 1
@patch("app.upscayl_manager._fetch_release")
- def test_no_macos_asset(self, mock_fetch_release):
- """Aucun asset macOS â RuntimeError."""
+ def test_no_windows_asset(self, mock_fetch_release):
+ """Aucun asset Windows â RuntimeError."""
mock_fetch_release.return_value = {
"assets": [{"name": "upscayl-linux-x86_64.tar.gz"}]
}
from app.upscayl_manager import download_binary
- with pytest.raises(RuntimeError, match="No macOS release asset"):
+ with pytest.raises(RuntimeError, match="No Windows release asset"):
download_binary()
@patch("app.upscayl_manager._fetch_release")
@@ -350,9 +350,9 @@ def test_download_http_error(
mock_fetch_release.return_value = {
"assets": [
{
- "name": "upscayl-macos-arm64.tar.gz",
+ "name": "upscayl-windows-x86_64.zip",
"size": 5 * 1024 * 1024,
- "browser_download_url": "https://example.com/upscayl.tar.gz",
+ "browser_download_url": "https://example.com/upscayl.zip",
}
]
}
@@ -427,38 +427,38 @@ def test_no_model(self, mock_find_binary):
class TestUpscaylHelpers:
"""Tests pour les fonctions helper du module upscayl_manager."""
- def test_find_macos_asset(self):
- """_find_macos_asset trouve le bon asset."""
- from app.upscayl_manager import _find_macos_asset
+ def test_find_windows_asset(self):
+ """_find_windows_asset trouve le bon asset."""
+ from app.upscayl_manager import _find_windows_asset
assets = [
{"name": "upscayl-linux-x86_64.tar.gz"},
{"name": "upscayl-macos-arm64.tar.gz"},
{"name": "upscayl-windows-x86_64.zip"},
]
- result = _find_macos_asset(assets)
+ result = _find_windows_asset(assets)
assert result is not None
- assert "macos" in result["name"]
+ assert "windows" in result["name"]
- def test_find_macos_asset_fallback(self):
- """_find_macos_asset utilise le fallback 'mac' si arm64 manquant."""
- from app.upscayl_manager import _find_macos_asset
+ def test_find_windows_asset_fallback(self):
+ """_find_windows_asset utilise le fallback 'win' si nécessaire."""
+ from app.upscayl_manager import _find_windows_asset
assets = [
- {"name": "upscayl-macos-universal.tar.gz"},
+ {"name": "upscayl-win64.zip"},
]
- result = _find_macos_asset(assets)
+ result = _find_windows_asset(assets)
assert result is not None
- def test_find_macos_asset_none(self):
- """_find_macos_asset retourne None si aucun asset macOS."""
- from app.upscayl_manager import _find_macos_asset
+ def test_find_windows_asset_none(self):
+ """_find_windows_asset retourne None si aucun asset Windows."""
+ from app.upscayl_manager import _find_windows_asset
assets = [
{"name": "upscayl-linux-x86_64.tar.gz"},
- {"name": "upscayl-windows-x86_64.zip"},
+ {"name": "upscayl-macos-arm64.tar.gz"},
]
- result = _find_macos_asset(assets)
+ result = _find_windows_asset(assets)
assert result is None
def test_get_bin_dir(self, tmp_path):
diff --git a/tests/test_workers.py b/tests/test_workers.py
index 61d2575..9e38185 100644
--- a/tests/test_workers.py
+++ b/tests/test_workers.py
@@ -10,8 +10,7 @@
try:
from app.gui.base_worker import BaseWorker
from app.gui.workers import (
- ColmapWorker, BrushWorker, SharpWorker,
- SharpVideoWorker, Extractor360Worker,
+ ColmapWorker, BrushWorker, Extractor360Worker,
)
WORKERS_AVAILABLE = True
except (ImportError, AttributeError, ModuleNotFoundError) as e:
@@ -272,109 +271,6 @@ def test_rename_checkpoints_with_project_name(self, tmp_path):
assert (output_dir / "test_scene_iteration_2000.ply").exists()
-# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
-# SharpWorker tests
-# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
-
-class TestSharpWorker:
- """Tests pour SharpWorker."""
-
- def test_run_success(self):
- """SharpWorker.run() avec moteur mocké."""
- if not WORKERS_AVAILABLE:
- pytest.skip(WORKERS_REASON)
- engine = MagicMock()
- engine.predict.return_value = 0
-
- worker = SharpWorker.__new__(SharpWorker)
- with patch.object(worker, 'log_signal', MagicMock()):
- with patch.object(worker, 'status_signal', MagicMock()):
- with patch.object(worker, 'finished_signal', MagicMock()):
- with patch.object(worker, 'isInterruptionRequested', return_value=False):
- worker.engine = engine
- worker.input_path = "/in.jpg"
- worker.output_path = "/out"
- worker.params = {}
-
- worker.run()
- engine.predict.assert_called_once()
-
- def test_run_failure(self):
- """SharpWorker.run() en échec."""
- if not WORKERS_AVAILABLE:
- pytest.skip(WORKERS_REASON)
- engine = MagicMock()
- engine.predict.return_value = 1
-
- worker = SharpWorker.__new__(SharpWorker)
- with patch.object(worker, 'log_signal', MagicMock()):
- with patch.object(worker, 'status_signal', MagicMock()):
- with patch.object(worker, 'finished_signal', MagicMock()):
- with patch.object(worker, 'isInterruptionRequested', return_value=False):
- worker.engine = engine
- worker.input_path = "/in.jpg"
- worker.output_path = "/out"
- worker.params = {}
-
- worker.run()
- args, _ = worker.finished_signal.emit.call_args
- assert args[0] is False
-
-
-# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
-# SharpVideoWorker tests
-# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
-
-class TestSharpVideoWorker:
- """Tests pour SharpVideoWorker."""
-
- def test_run_success(self):
- """SharpVideoWorker.run() avec moteur mocké."""
- if not WORKERS_AVAILABLE:
- pytest.skip(WORKERS_REASON)
- engine = MagicMock()
- engine.process_video_frames.return_value = 5
-
- worker = SharpVideoWorker.__new__(SharpVideoWorker)
- with patch.object(worker, 'log_signal', MagicMock()):
- with patch.object(worker, 'status_signal', MagicMock()):
- with patch.object(worker, 'progress_signal', MagicMock()):
- with patch.object(worker, 'finished_signal', MagicMock()):
- with patch.object(worker, 'isInterruptionRequested', return_value=False):
- worker.engine = engine
- worker.video_path = "/in.mp4"
- worker.output_path = "/out"
- worker.params = {}
-
- worker.run()
- engine.process_video_frames.assert_called_once()
- args, _ = worker.finished_signal.emit.call_args
- assert args[0] is True
-
- def test_run_no_frames(self):
- """SharpVideoWorker.run() sans frames traitées."""
- if not WORKERS_AVAILABLE:
- pytest.skip(WORKERS_REASON)
- engine = MagicMock()
- engine.process_video_frames.return_value = 0
-
- worker = SharpVideoWorker.__new__(SharpVideoWorker)
- with patch.object(worker, 'log_signal', MagicMock()):
- with patch.object(worker, 'status_signal', MagicMock()):
- with patch.object(worker, 'progress_signal', MagicMock()):
- with patch.object(worker, 'finished_signal', MagicMock()):
- with patch.object(worker, 'isInterruptionRequested', return_value=False):
- worker.engine = engine
- worker.video_path = "/in.mp4"
- worker.output_path = "/out"
- worker.params = {}
-
- worker.run()
- args, _ = worker.finished_signal.emit.call_args
- assert args[0] is False
- assert "Aucune frame" in args[1]
-
-
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Extractor360Worker tests
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ