Zum Inhalt

pipeline/tests/test_sfm.py

# SPDX-License-Identifier: AGPL-3.0-or-later

"""Unit tests for COLMAP command builders. No real COLMAP invoked."""

from __future__ import annotations

import struct
from pathlib import Path
from unittest.mock import patch

import pytest

from autosplat.config import ColmapConfig
from autosplat.sfm import (
    _parse_mapper_stats,
    build_feature_extractor_command,
    build_mapper_command,
    build_matcher_command,
    run_colmap,
)


@pytest.fixture
def cfg() -> ColmapConfig:
    return ColmapConfig(matcher="sequential", quality="medium", single_camera=True)


def test_run_colmap_wipes_stale_workspace(tmp_path: Path, cfg: ColmapConfig) -> None:
    """A fresh SfM run must NOT inherit a previous run's database.db / sparse.

    Re-running `process` on the same capture (e.g. quick-iter → quality-run)
    re-extracts frames with the same names but different content; COLMAP would
    otherwise append to the stale DB and reuse old features, mixing the two
    runs (observed: 397 cameras registered from 351 on-disk frames). run_colmap
    must reset the workspace first. Resume never calls run_colmap, so this is
    safe for the resume path.
    """
    workspace = tmp_path / "colmap"
    workspace.mkdir()
    (workspace / "database.db").write_bytes(b"STALE-DB")
    (workspace / "database.db-wal").write_bytes(b"WAL")  # SQLite side files too
    stale_sparse = workspace / "sparse" / "0"
    stale_sparse.mkdir(parents=True)
    (stale_sparse / "images.bin").write_bytes(b"OLD-MODEL")

    frames = tmp_path / "frames"
    frames.mkdir()

    # Mock out the actual COLMAP subprocess calls + stats parse.
    with (
        patch("autosplat.sfm._run_logged") as run,
        patch("autosplat.sfm._parse_mapper_stats", return_value=(10, 1000)),
    ):
        result = run_colmap(frames, workspace, cfg)

    # Stale artefacts gone (the mocked COLMAP doesn't recreate them).
    assert not (workspace / "database.db").exists()
    assert not (workspace / "database.db-wal").exists()
    assert not (stale_sparse / "images.bin").exists()
    # …but the workspace + a fresh empty sparse/ dir are ready for COLMAP.
    assert (workspace / "sparse").is_dir()
    assert run.call_count == 3  # feature_extractor, matcher, mapper
    assert result.cameras_registered == 10


def test_feature_extractor_command(tmp_path: Path, cfg: ColmapConfig) -> None:
    cmd = build_feature_extractor_command(tmp_path / "db.db", tmp_path / "frames", cfg)
    assert cmd[0] == "colmap"
    assert "feature_extractor" in cmd
    assert "--database_path" in cmd
    assert "--ImageReader.single_camera" in cmd
    # medium preset
    idx = cmd.index("--SiftExtraction.max_num_features")
    assert cmd[idx + 1] == "8192"
    # COLMAP 4.0 surface: max_image_size belongs to FeatureExtraction, not SiftExtraction
    assert "--FeatureExtraction.max_image_size" in cmd
    # GPU off — Homebrew COLMAP is built without CUDA
    idx_gpu = cmd.index("--FeatureExtraction.use_gpu")
    assert cmd[idx_gpu + 1] == "0"


def test_matcher_command_sequential(tmp_path: Path, cfg: ColmapConfig) -> None:
    cmd = build_matcher_command(tmp_path / "db.db", cfg)
    assert "sequential_matcher" in cmd


def test_matcher_command_exhaustive(tmp_path: Path) -> None:
    cfg = ColmapConfig(matcher="exhaustive", quality="high", single_camera=False)
    cmd = build_matcher_command(tmp_path / "db.db", cfg)
    assert "exhaustive_matcher" in cmd


def test_mapper_command(tmp_path: Path) -> None:
    cmd = build_mapper_command(tmp_path / "db.db", tmp_path / "frames", tmp_path / "sparse")
    assert cmd[0] == "colmap"
    assert "mapper" in cmd
    assert str(tmp_path / "sparse") in cmd


def test_single_camera_flag_off(tmp_path: Path) -> None:
    cfg = ColmapConfig(matcher="sequential", quality="low", single_camera=False)
    cmd = build_feature_extractor_command(tmp_path / "db.db", tmp_path / "frames", cfg)
    idx = cmd.index("--ImageReader.single_camera")
    assert cmd[idx + 1] == "0"


def test_parse_mapper_stats_returns_zero_when_no_sparse(tmp_path: Path) -> None:
    assert _parse_mapper_stats(tmp_path / "missing") == (0, 0)


def test_parse_mapper_stats_binary(tmp_path: Path) -> None:
    """COLMAP 4.0+ defaults: images.bin / points3D.bin with uint64 count header."""
    out = tmp_path / "sparse" / "0"
    out.mkdir(parents=True)
    (out / "images.bin").write_bytes(struct.pack("<Q", 42) + b"\0" * 16)
    (out / "points3D.bin").write_bytes(struct.pack("<Q", 1234) + b"\0" * 16)
    assert _parse_mapper_stats(tmp_path / "sparse") == (42, 1234)


def test_parse_mapper_stats_text(tmp_path: Path) -> None:
    """Older COLMAP / --Mapper.bin 0: images.txt and points3D.txt."""
    out = tmp_path / "sparse" / "0"
    out.mkdir(parents=True)
    # 3 images = 6 non-comment lines (metadata line + points line per image)
    (out / "images.txt").write_text(
        "# header\n"
        "1 0 0 0 0 0 0 0 1 a.jpg\n"
        "10 20 30\n"
        "2 0 0 0 0 0 0 0 1 b.jpg\n"
        "10 20 30\n"
        "3 0 0 0 0 0 0 0 1 c.jpg\n"
        "10 20 30\n",
        encoding="utf-8",
    )
    (out / "points3D.txt").write_text(
        "# header\n1 x y z r g b err 1 2\n2 x y z r g b err 1 2\n",
        encoding="utf-8",
    )
    assert _parse_mapper_stats(tmp_path / "sparse") == (3, 2)