diff --git a/.gitignore b/.gitignore index fc7eca3e..7f6c2ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ engines/brush/** !engines/brush/README.md engines/linux/brush/** engines/macos/arm64/** + +# Collected Windows installer artifacts (large) are never committed. +dist-artifacts/ diff --git a/ROADMAP.md b/ROADMAP.md index f0c29072..40794f7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ | 优先级 | 事项 | 目标 | GitHub Issue | | --- | --- | --- | --- | | P0 | 失败或暂停后支持断点续跑 | 保留可复用的阶段结果,使中断任务能够从合适的处理阶段继续。 | 待创建 | -| P1 | 支持输入图片序列 | 允许用户以有序图片集作为重建输入,而不必先制作视频文件。 | 待创建 | +| P1 | 提升重建性能(GLOMAP / 词汇树环路) | 用 GLOMAP 全局建图替代增量 mapper 以提速并提高注册率;用词汇树/环路检测提升环绕视频首尾闭环、降低“注册率<50%”失败。依赖:内置 COLMAP 需含 `global_mapper`;词汇树需 faiss 兼容树(当前公开预训练树为 flann,与内置 faiss 版 COLMAP 不兼容)。 | 待创建 | | P3 | 全景视频支持 | 探索将全景视频作为输入并生成可用 Gaussian Splatting 结果的工作流。 | 待创建 | ## 已完成 @@ -31,6 +31,9 @@ | 已完成 | Apple Silicon macOS Alpha | 为 macOS 15+ arm64 提供随应用交付的 FFmpeg、FFprobe、CPU COLMAP 和 Brush 工作流。 | [#4 Add macOS support](https://github.com/ooolabdev/ooosplat/issues/4),由 [PR #8](https://github.com/ooolabdev/ooosplat/pull/8) 交付 | | 已完成 | 内嵌高斯泼溅预览 | 已支持加载 `.ply`、相机浏览、整体 Transform、撤销 / 重做、动画预览,以及非破坏式 Gaussian 和竖屏视频导出。 | [#3 关于集成查看功能](https://github.com/ooolabdev/ooosplat/issues/3) | | 已完成 | COLMAP CUDA 自动加速 | 已支持检测 NVIDIA 驱动和 Compute Capability,满足要求时自动启用 GPU 特征提取与匹配,否则无中断地回退 CPU。 | [#6 Add CUDA-accelerated pipeline for NVIDIA GPUs](https://github.com/ooolabdev/ooosplat/issues/6);相关用户反馈 [#2](https://github.com/ooolabdev/ooosplat/issues/2) | +| 已完成 | 支持输入图片序列 | 新增“图片文件夹 / 图片序列”输入入口:前端可选视频或图片文件夹;后端按输入类型自动选择匹配器(图片→穷举匹配,视频→顺序匹配)并复用整条 COLMAP + Brush 链路,无需先制作视频。 | 本次实现(待建 Issue) | +| 已完成 | 重建性能基础优化 | 特征提取 `max_num_features=8192` 提升特征密度;图片序列默认 `exhaustive_matcher` 提升无序图集注册率;新增 GLOMAP 与词汇树/环路匹配的代码接入(运行时检测 + 自动回退,避免旧引擎/缺资产时破坏流程)。 | 本次实现(待建 Issue) | +| 已完成 | Windows NSIS 客户端打包 | 补齐构建环境(Rust MSVC + VS2022 BuildTools + SDK),使用国内镜像下载并校验内置引擎(FFmpeg/COLMAP/Brush),产出 `OOOSplat-0.3.0-x64-setup.exe`(含内置引擎,本地未签名)。 | 本次实现(待建 Issue) | ## 跟踪与贡献 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3f0fcf80..31372d28 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -25,7 +25,7 @@ use crate::{ }, reconstruction::{ply::inspect_gaussian_ply, splat_transform::export_transformed_ply}, telemetry::{PipelineTelemetrySession, TelemetryPreferences, TelemetryService}, - video::{FramePlan, FrameSelectionStrategy, UniformRatioFrameSelection, VideoInfo}, + video::{create_image_plan, list_images, FramePlan, FrameSelectionStrategy, UniformRatioFrameSelection, VideoInfo}, }; #[derive(Default)] @@ -105,7 +105,7 @@ pub struct GaussianVideoExportResult { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ProbeAndPlan { - video: VideoInfo, + video: Option, plan: FramePlan, } @@ -129,9 +129,23 @@ pub async fn probe_and_plan( path: String, quality: Quality, ) -> std::result::Result { - let video = probe_video(&paths_for_app(&app).ffprobe, &PathBuf::from(path), None).await?; + let input = PathBuf::from(&path); + if input.is_dir() { + let images = list_images(&input)?; + if images.is_empty() { + return Err(SplatError::InvalidVideo( + "图片序列为空,未找到支持的图片文件".into(), + )); + } + let plan = create_image_plan(images.len() as u64, &quality.preset()); + return Ok(ProbeAndPlan { video: None, plan }); + } + let video = probe_video(&paths_for_app(&app).ffprobe, &input, None).await?; let plan = UniformRatioFrameSelection.create_plan(&video, &quality.preset()); - Ok(ProbeAndPlan { video, plan }) + Ok(ProbeAndPlan { + video: Some(video), + plan, + }) } #[tauri::command] diff --git a/src-tauri/src/engines/colmap.rs b/src-tauri/src/engines/colmap.rs index 0800fe6f..b90160ee 100644 --- a/src-tauri/src/engines/colmap.rs +++ b/src-tauri/src/engines/colmap.rs @@ -178,6 +178,167 @@ pub async fn match_sequential( .await } +/// Pairwise exhaustive matching. +/// +/// Used for image-sequence inputs: unlike sequential matching it connects every +/// image pair, so an unordered photo set (and the closing loop of an orbit set) +/// is much more likely to register. It does not need a vocabulary tree, and the +/// `exhaustive_matcher` command exists on both the legacy (3.x) and modern +/// (4.x) engine families, so it is safe to use with the bundled COLMAP. +pub async fn match_exhaustive( + executable: &Path, + database: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, + gpu_index: Option, +) -> Result<()> { + let (use_gpu_option, gpu_index_option) = matching_gpu_options(executable, manager).await?; + run_colmap( + executable, + exhaustive_matching_args(database, gpu_index, use_gpu_option, gpu_index_option), + database.parent().unwrap_or(Path::new(".")), + log, + manager, + observer, + ) + .await +} + +fn exhaustive_matching_args( + database: &Path, + gpu_index: Option, + use_gpu_option: &str, + gpu_index_option: &str, +) -> Vec { + let mut args = vec![ + "exhaustive_matcher".into(), + "--database_path".into(), + database.into(), + use_gpu_option.into(), + (if gpu_index.is_some() { "1" } else { "0" }).into(), + ]; + if let Some(index) = gpu_index { + args.push(gpu_index_option.into()); + args.push(index.to_string().into()); + } + args +} + +/// Vocabulary-tree matching: robust and much faster than full pairwise +/// matching for larger un-ordered image sets. Requires a pre-trained +/// vocabulary tree (`--VocabTreeMatching.vocab_tree_path`). +#[allow(clippy::too_many_arguments)] +pub async fn match_vocab_tree( + executable: &Path, + database: &Path, + vocab_tree: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, + gpu_index: Option, +) -> Result<()> { + let (use_gpu_option, gpu_index_option) = matching_gpu_options(executable, manager).await?; + run_colmap( + executable, + vocab_tree_matching_args( + database, + vocab_tree, + gpu_index, + use_gpu_option, + gpu_index_option, + ), + database.parent().unwrap_or(Path::new(".")), + log, + manager, + observer, + ) + .await +} + +fn vocab_tree_matching_args( + database: &Path, + vocab_tree: &Path, + gpu_index: Option, + use_gpu_option: &str, + gpu_index_option: &str, +) -> Vec { + let mut args = vec![ + "vocab_tree_matcher".into(), + "--database_path".into(), + database.into(), + "--VocabTreeMatching.vocab_tree_path".into(), + vocab_tree.as_os_str().to_os_string(), + use_gpu_option.into(), + (if gpu_index.is_some() { "1" } else { "0" }).into(), + ]; + if let Some(index) = gpu_index { + args.push(gpu_index_option.into()); + args.push(index.to_string().into()); + } + args +} + +/// Sequential matching with loop detection, which closes the first/last-frame +/// cycle of an orbit video (the most common cause of a sub-50% registration +/// ratio). Requires a vocabulary tree for the retrieval step. +#[allow(clippy::too_many_arguments)] +pub async fn match_sequential_loop( + executable: &Path, + database: &Path, + vocab_tree: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, + gpu_index: Option, +) -> Result<()> { + let (use_gpu_option, gpu_index_option) = matching_gpu_options(executable, manager).await?; + run_colmap( + executable, + sequential_loop_args( + database, + vocab_tree, + gpu_index, + use_gpu_option, + gpu_index_option, + ), + database.parent().unwrap_or(Path::new(".")), + log, + manager, + observer, + ) + .await +} + +fn sequential_loop_args( + database: &Path, + vocab_tree: &Path, + gpu_index: Option, + use_gpu_option: &str, + gpu_index_option: &str, +) -> Vec { + let mut args = vec![ + "sequential_matcher".into(), + "--database_path".into(), + database.into(), + use_gpu_option.into(), + (if gpu_index.is_some() { "1" } else { "0" }).into(), + ]; + if let Some(index) = gpu_index { + args.push(gpu_index_option.into()); + args.push(index.to_string().into()); + } + args.extend([ + OsString::from("--SequentialMatching.overlap"), + OsString::from("10"), + OsString::from("--SequentialMatching.loop_detection"), + OsString::from("1"), + OsString::from("--SequentialMatching.vocab_tree_path"), + vocab_tree.as_os_str().to_os_string(), + ]); + args +} + fn feature_extraction_args( database: &Path, images: &Path, @@ -202,6 +363,14 @@ fn feature_extraction_args( args.push(gpu_index_option.into()); args.push(index.to_string().into()); } + // More SIFT features -> richer matches -> higher registration and a more + // stable initial model. The option prefix follows the detected CLI family + // (--FeatureExtraction.* on COLMAP 4.x, --SiftExtraction.* on 3.x). + let prefix = use_gpu_option + .strip_suffix(".use_gpu") + .unwrap_or(use_gpu_option); + args.push(format!("{prefix}.max_num_features").into()); + args.push("8192".into()); args } @@ -258,6 +427,76 @@ pub async fn map( .await } +/// Detect whether the installed COLMAP exposes the `global_mapper` command +/// (GLOMAP's global SfM pipeline). Older builds (for example the Ubuntu 3.9 +/// system package) do not ship it, so callers should fall back to the +/// incremental `mapper`. +pub async fn detect_global_mapper( + executable: &Path, + manager: &ProcessManager, +) -> Result { + match command_help(executable, "help", manager).await { + Ok(help) => Ok(help.contains("global_mapper")), + Err(_) => Ok(false), + } +} + +/// Run the global mapper (GLOMAP). +/// +/// As COLMAP recommends, a view-graph calibrator pass runs first on a copy of +/// the database so the global solver has reasonable focal-length priors; the +/// original database is left untouched. A calibration failure is tolerated so +/// the global mapper can still run with the default focal-length priors. +pub async fn map_global( + executable: &Path, + database: &Path, + images: &Path, + output: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, +) -> Result<()> { + tokio::fs::create_dir_all(output).await?; + let global_db = database.with_file_name("database_global.db"); + tokio::fs::copy(database, &global_db).await?; + let working_directory = database.parent().unwrap_or(output); + let calibration = run_colmap( + executable, + vec![ + "view_graph_calibrator".into(), + "--database_path".into(), + global_db.clone().into(), + ], + working_directory, + log.clone(), + manager, + None, + ) + .await; + if let Err(calibration_error) = calibration { + // Non-fatal: fall back to the default focal-length priors. The full + // calibration output is already captured in the COLMAP log. + let _ = calibration_error; + } + run_colmap( + executable, + vec![ + "global_mapper".into(), + "--database_path".into(), + global_db.into(), + "--image_path".into(), + images.into(), + "--output_path".into(), + output.into(), + ], + working_directory, + log, + manager, + observer, + ) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/engines/health.rs b/src-tauri/src/engines/health.rs index d5506c3e..ddf3fb87 100644 --- a/src-tauri/src/engines/health.rs +++ b/src-tauri/src/engines/health.rs @@ -141,6 +141,13 @@ impl EnginePaths { paths } + /// Optional COLMAP vocabulary tree used for `vocab_tree_matcher` and for + /// `sequential_matcher` loop detection. When absent those features are + /// skipped and OOOSplat falls back to the existing matchers. + pub fn vocab_tree(&self) -> PathBuf { + self.root.join("vocab_tree.bin") + } + fn from_candidates(root: PathBuf) -> Self { #[cfg(windows)] let paths = { diff --git a/src-tauri/src/pipeline/runner.rs b/src-tauri/src/pipeline/runner.rs index c1d29e61..052ae0e4 100644 --- a/src-tauri/src/pipeline/runner.rs +++ b/src-tauri/src/pipeline/runner.rs @@ -30,11 +30,15 @@ use crate::{ ply::inspect_gaussian_ply, validator::{ReconstructionQuality, ReconstructionReport, ReconstructionValidator}, }, - video::{FramePlan, FrameSelectionStrategy, UniformRatioFrameSelection, VideoInfo}, + video::{ + create_image_plan, list_images, validate_image_sequence, FramePlan, FrameSelectionStrategy, + UniformRatioFrameSelection, VideoInfo, + }, }; pub struct PreparedFrames { - pub video: VideoInfo, + /// Video metadata when the input is a video; `None` for image sequences. + pub video: Option, pub plan: FramePlan, pub extracted_frames: u64, } @@ -217,6 +221,9 @@ impl PipelineRunner { output: &Path, logs: Option<&Path>, ) -> Result { + if input.is_dir() { + return self.prepare_image_frames(input, quality, output, logs).await; + } self.events .stage(PipelineStage::ProbingVideo, 0.0, "正在读取视频信息"); let video = probe_video( @@ -267,12 +274,86 @@ impl PipelineRunner { format!("已提取 {extracted_frames} 帧"), ); Ok(PreparedFrames { - video, + video: Some(video), plan, extracted_frames, }) } + /// Prepare frames from a folder of images instead of a video. + /// + /// Skips FFprobe/FFmpeg entirely: the user's ordered image set is copied + /// (renamed to zero-padded `frame_%05d` for a stable COLMAP sort) into + /// `output` (i.e. `work/frames/`), then the rest of the pipeline continues + /// unchanged. `sampling_fps` in the plan is informational only (0.0). + async fn prepare_image_frames( + &self, + input: &Path, + quality: Quality, + output: &Path, + _logs: Option<&Path>, + ) -> Result { + validate_image_sequence(input)?; + self.events + .stage(PipelineStage::ProbingVideo, 0.0, "正在读取图片序列"); + let images = list_images(input)?; + let count = images.len() as u64; + self.events.stage( + PipelineStage::ProbingVideo, + 1.0, + format!("读取到 {count} 张图片"), + ); + + self.events + .stage(PipelineStage::PlanningFrames, 0.0, "正在规划图片序列"); + let plan = create_image_plan(count, &quality.preset()); + self.events.stage( + PipelineStage::PlanningFrames, + 1.0, + format!("共 {count} 张图片"), + ); + + self.events + .stage(PipelineStage::ExtractingFrames, 0.0, "正在整理图片序列"); + tokio::fs::create_dir_all(output).await?; + let mut extracted = 0_u64; + let total = images.len(); + for (index, path) in images.iter().enumerate() { + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("jpg") + .to_ascii_lowercase(); + let destination = output.join(format!("frame_{index:05}.{extension}")); + tokio::fs::copy(path, &destination).await?; + extracted += 1; + if extracted % 25 == 0 || extracted == count { + self.events.send( + PipelineStage::ExtractingFrames, + Some(PipelineEngine::Ffmpeg), + EventKind::Progress, + EventLevel::Info, + Some(extracted as f32 / total as f32), + false, + format!("已整理 {extracted} 帧"), + Some(extracted), + Some(count), + Some("张"), + ); + } + } + self.events.stage( + PipelineStage::ExtractingFrames, + 1.0, + format!("已整理 {extracted} 帧"), + ); + Ok(PreparedFrames { + video: None, + plan, + extracted_frames: extracted, + }) + } + pub async fn generate( &self, input: &Path, @@ -362,8 +443,9 @@ impl PipelineRunner { Some(&paths.logs), ) .await?; - let source_duration_seconds = prepared.video.duration; - state.video = Some(prepared.video); + let is_image_sequence = prepared.video.is_none(); + let source_duration_seconds = prepared.video.as_ref().map(|v| v.duration).unwrap_or(0.0); + state.video = prepared.video; let mut frames = FrameState::from(&prepared.plan); frames.extracted_frames = Some(prepared.extracted_frames); state.frames = Some(frames); @@ -410,53 +492,131 @@ impl PipelineRunner { format!("{backend_label} 特征提取完成"), ); + let vocab_tree = self.engines.vocab_tree(); + let use_vocab_tree = vocab_tree.is_file(); + let matcher_label = if is_image_sequence { + if use_vocab_tree { "词汇树" } else { "穷举" } + } else { + if use_vocab_tree { "顺序+环路" } else { "顺序" } + }; self.events.stage( PipelineStage::Matching, 0.0, - format!("COLMAP 正在进行 {backend_label} 顺序匹配"), + format!("COLMAP 正在进行 {backend_label} {matcher_label}匹配"), ); - colmap::match_sequential( - &self.engines.colmap, - &database, - colmap_log.clone(), - &self.process_manager, - Some(self.process_observer( - PipelineStage::Matching, - PipelineEngine::Colmap, - Some(prepared.extracted_frames), - ObserverMode::BracketProgress, - )), - gpu_index, - ) - .await?; + // Image sequences: vocab-tree matching when a tree is bundled (robust to + // an unordered photo set); otherwise full pairwise matching. Videos: + // sequential matching, plus loop detection when a tree is bundled so the + // orbit's first/last-frame cycle closes (the usual cause of a low + // registration ratio). All commands exist on the 3.x and 4.x families. + let observer = self.process_observer( + PipelineStage::Matching, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::BracketProgress, + ); + if is_image_sequence { + if use_vocab_tree { + colmap::match_vocab_tree( + &self.engines.colmap, + &database, + &vocab_tree, + colmap_log.clone(), + &self.process_manager, + Some(observer), + gpu_index, + ) + .await?; + } else { + colmap::match_exhaustive( + &self.engines.colmap, + &database, + colmap_log.clone(), + &self.process_manager, + Some(observer), + gpu_index, + ) + .await?; + } + } else if use_vocab_tree { + colmap::match_sequential_loop( + &self.engines.colmap, + &database, + &vocab_tree, + colmap_log.clone(), + &self.process_manager, + Some(observer), + gpu_index, + ) + .await?; + } else { + colmap::match_sequential( + &self.engines.colmap, + &database, + colmap_log.clone(), + &self.process_manager, + Some(observer), + gpu_index, + ) + .await?; + } state.stage = PipelineStage::Matching; state.matching_complete = true; project_manager.write_state(&paths.state, &state).await?; - self.events - .stage(PipelineStage::Matching, 1.0, "顺序匹配完成"); + self.events.stage( + PipelineStage::Matching, + 1.0, + format!("{matcher_label}匹配完成"), + ); - self.events - .stage(PipelineStage::Reconstructing, 0.0, "正在增量重建相机轨迹"); - colmap::map( + let use_global_mapper = colmap::detect_global_mapper( &self.engines.colmap, - &database, - colmap_images, - &sparse, - colmap_log, &self.process_manager, - Some(self.process_observer( - PipelineStage::Reconstructing, - PipelineEngine::Colmap, - Some(prepared.extracted_frames), - ObserverMode::Mapper, - )), ) .await?; + let mapper_label = if use_global_mapper { "全局(GLOMAP)" } else { "增量" }; + self.events.stage( + PipelineStage::Reconstructing, + 0.0, + format!("正在{mapper_label}重建相机轨迹"), + ); + let observer = self.process_observer( + PipelineStage::Reconstructing, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::Mapper, + ); + if use_global_mapper { + colmap::map_global( + &self.engines.colmap, + &database, + colmap_images, + &sparse, + colmap_log, + &self.process_manager, + Some(observer), + ) + .await?; + } else { + colmap::map( + &self.engines.colmap, + &database, + colmap_images, + &sparse, + colmap_log, + &self.process_manager, + Some(observer), + ) + .await?; + } state.stage = PipelineStage::Reconstructing; state.reconstruction_complete = true; project_manager.write_state(&paths.state, &state).await?; - self.events - .stage(PipelineStage::Reconstructing, 1.0, "增量重建完成"); + self.events.stage( + PipelineStage::Reconstructing, + 1.0, + format!("{mapper_label}重建完成"), + ); self.events.stage( PipelineStage::ValidatingReconstruction, diff --git a/src-tauri/src/project/manager.rs b/src-tauri/src/project/manager.rs index 51f6e309..73900d94 100644 --- a/src-tauri/src/project/manager.rs +++ b/src-tauri/src/project/manager.rs @@ -70,13 +70,25 @@ impl ProjectManager { source_video: &Path, quality: Quality, ) -> Result<(ProjectPaths, ProjectMetadata)> { - validate_video_path(source_video)?; + let is_image_dir = source_video.is_dir(); + if is_image_dir { + crate::video::validate_image_sequence(source_video)?; + } else { + validate_video_path(source_video)?; + } Self::validate_root(&self.projects_root).await?; let id = Uuid::new_v4(); - let stem = source_video - .file_stem() - .and_then(|v| v.to_str()) - .unwrap_or("project"); + let stem = if is_image_dir { + source_video + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("images") + } else { + source_video + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or("project") + }; let base = format!( "{}_{}", Local::now().format("%Y%m%d-%H%M%S"), @@ -92,13 +104,24 @@ impl ProjectManager { for directory in [&source, &frames, &colmap, &brush, &logs] { tokio::fs::create_dir_all(directory).await?; } - let extension = source_video - .extension() - .and_then(|v| v.to_str()) - .unwrap_or("mp4") - .to_ascii_lowercase(); - let stored_source = source.join(format!("input.{extension}")); - tokio::fs::copy(source_video, &stored_source).await?; + let stored_source = if is_image_dir { + let images_dir = source.join("images"); + tokio::fs::create_dir_all(&images_dir).await?; + for path in crate::video::list_images(source_video)? { + let dest = images_dir.join(path.file_name().unwrap()); + tokio::fs::copy(&path, &dest).await?; + } + images_dir + } else { + let extension = source_video + .extension() + .and_then(|v| v.to_str()) + .unwrap_or("mp4") + .to_ascii_lowercase(); + let stored = source.join(format!("input.{extension}")); + tokio::fs::copy(source_video, &stored).await?; + stored + }; let now = Utc::now(); let metadata = ProjectMetadata { schema_version: crate::project::metadata::schema_version(), diff --git a/src-tauri/src/video/image_sequence.rs b/src-tauri/src/video/image_sequence.rs new file mode 100644 index 00000000..ad41e51a --- /dev/null +++ b/src-tauri/src/video/image_sequence.rs @@ -0,0 +1,126 @@ +//! Image-sequence (image folder) input support. +//! +//! OOOSplat originally only accepted a single video. This module lets the user +//! hand OOOSplat an ordered set of images (a folder of photos) and reconstruct +//! from them directly, bypassing FFprobe/FFmpeg frame extraction. The images +//! are later normalised into `work/frames/` so the COLMAP + Brush pipeline is +//! unchanged. Design follows nerfstudio's image-input abstraction +//! (`ImagesToNerfstudioDataset`): collect, sort by filename, then hand a stable +//! image set to COLMAP. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Result, SplatError}, + presets::QualityPreset, + video::FramePlan, +}; + +/// Image extensions accepted as a sequence input. Mirrors the formats most +/// COLMAP builds can load (other formats such as CR2 require extra codecs). +pub const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "tif", "tiff", "bmp", "webp"]; + +/// A lightweight description of an image-sequence input, shown in the UI. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageSequenceInfo { + pub count: u64, +} + +/// Returns true when `path` looks like a supported still image file. +pub fn is_image_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str())) +} + +/// Collect image files under `dir`, sorted by full path so ordering is +/// deterministic. This mirrors nerfstudio's `sorted()` filename sort and is the +/// ordering that makes COLMAP `sequential_matcher` meaningful. +pub fn list_images(dir: &Path) -> Result> { + if !dir.is_dir() { + return Err(SplatError::InvalidPath(dir.to_path_buf())); + } + let mut files: Vec = std::fs::read_dir(dir)? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_file() && is_image_file(path)) + .collect(); + files.sort(); + Ok(files) +} + +/// How many images matched in `dir`. +pub fn image_count(dir: &Path) -> Result { + Ok(list_images(dir)?.len() as u64) +} + +/// Build a frame plan for an image sequence. +/// +/// Images are not sampled by FPS the way video is: the user's chosen set is +/// what reaches COLMAP, so we retain all of them. `retention_ratio` stays at +/// 1.0 and `estimated_frames` equals the image count (a cap can be applied by +/// callers that want to downsample very large sets). +pub fn create_plan(count: u64, _preset: &QualityPreset) -> FramePlan { + FramePlan { + retention_ratio: 1.0, + sampling_fps: 0.0, + estimated_frames: count, + } +} + +/// Validate that `dir` contains at least one supported image. +pub fn validate_image_sequence(dir: &Path) -> Result<()> { + if !dir.is_dir() { + return Err(SplatError::InvalidPath(dir.to_path_buf())); + } + if list_images(dir)?.is_empty() { + return Err(SplatError::InvalidVideo( + "图片序列为空,未找到支持的图片文件".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_supported_image_extensions() { + assert!(is_image_file(Path::new("a.JPG"))); + assert!(is_image_file(Path::new("b.jpeg"))); + assert!(is_image_file(Path::new("c.PNG"))); + assert!(!is_image_file(Path::new("d.mp4"))); + assert!(!is_image_file(Path::new("e.txt"))); + } + + #[test] + fn lists_and_sorts_images_only() { + let dir = tempfile::tempdir().unwrap(); + for name in ["z.png", "a.png", "m.jpeg", "skip.mp4"] { + std::fs::write(dir.path().join(name), b"x").unwrap(); + } + let images = list_images(dir.path()).unwrap(); + let names: Vec<_> = images + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, ["a.png", "m.jpeg", "z.png"]); + } + + #[test] + fn empty_folders_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + assert!(validate_image_sequence(dir.path()).is_err()); + } + + #[test] + fn image_plan_retains_all_images() { + let plan = create_plan(12, &crate::presets::Quality::Balanced.preset()); + assert_eq!(plan.retention_ratio, 1.0); + assert_eq!(plan.estimated_frames, 12); + } +} diff --git a/src-tauri/src/video/mod.rs b/src-tauri/src/video/mod.rs index dcc5241a..bc3319fb 100644 --- a/src-tauri/src/video/mod.rs +++ b/src-tauri/src/video/mod.rs @@ -1,6 +1,11 @@ pub mod extract; pub mod frame_plan; +pub mod image_sequence; pub mod probe; pub use frame_plan::{FramePlan, FrameSelectionStrategy, UniformRatioFrameSelection}; +pub use image_sequence::{ + create_plan as create_image_plan, ImageSequenceInfo, image_count, is_image_file, list_images, + validate_image_sequence, +}; pub use probe::{parse_ffprobe_json, VideoInfo}; diff --git a/src/app/App.tsx b/src/app/App.tsx index f63dfad3..67eebf55 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,7 +8,7 @@ import appLogo from "../../assets/app-icon.svg"; import { TelemetryPreferences } from "../components/TelemetryPreferences"; import { cancelPipeline, checkEngines, confirmAndDeleteProject, getProjectOverview, - onPipelineEvent, probeAndPlan, revealProject, selectProjectsRoot, selectVideo, + onPipelineEvent, probeAndPlan, revealProject, selectImageFolder, selectProjectsRoot, selectVideo, setProjectsRoot, startPipeline, prepareGaussianPreview, releaseGaussianPreview, initializeTelemetry, setTelemetryConsent, } from "../lib/backend"; @@ -233,6 +233,11 @@ export function App() { if (selected) { store.setVideoPath(selected); await analyze(selected, store.quality); } }; + const chooseImageFolder = async () => { + const selected = await selectImageFolder(); + if (selected) { store.setVideoPath(selected); await analyze(selected, store.quality); } + }; + const chooseRoot = async () => { const selected = await selectProjectsRoot(store.projectsRoot); if (!selected) return; @@ -367,9 +372,12 @@ export function App() {

01 创建新任务

{isRunning ? "运行中" : "待命"}
- + +
@@ -398,11 +406,15 @@ export function App() { - {store.video && store.plan &&
+ {store.plan && (store.video ?
时长{formatVideoDuration(store.video.duration)} 分辨率{store.video.width} × {store.video.height} 预计帧数约 {store.plan.estimatedFrames.toLocaleString()} -
} +
:
+ 图片数量{store.plan.estimatedFrames.toLocaleString()} 张 + 输入类型图片序列 + 预计帧数全部保留 +
)} {!isRunning &&