Project deep-dive

Photogram

Video or photos → metric-scaled 3D. · A testbed for CV and VLM ideas.

Three scene modes. Fourteen stages. Fully automated — video or stills in, dense point cloud, mesh, and Gaussian splat out. Built as a research platform, not a product. Fork it. Swap a stage. Answer your own question. Six VLM integrations were tried here. The findings are documented.

Overview

Photogram is a working photogrammetry pipeline and a clean, extensible UI — built not as a product but as a research platform. Three scene modes are supported — indoor room, outdoor survey, and object orbit — each with a separate pipeline branch, mesh algorithm, and coverage boundary. The UI surfaces whatever stages produce: progress tracking with per-stage timings and live GPU telemetry, a 3D viewer with point cloud, mesh, Gaussian splat, scene overview, and camera walkthrough tabs, coverage heatmap, and re-shoot suggestions. Swap a stage, add a new one, and the interface picks it up.

The specific question explored here: where does a vision-language model add real value inside a well-understood classical CV pipeline? Photogrammetry was chosen as the domain because the baseline is solid and well-tooled — feature matching, SfM, and MVS are solved problems. That makes it a good research surface: failures are informative rather than confounded by a weak foundation.

The original target was the two known failure modes of classical MVS — featureless surfaces and thin structures — where VLMs might guide re-sampling or post-processing. A prerequisite step (metric scale) consumed the available experimentation budget. Six VLM integration attempts later, the findings are substantial even if the original question remains open.

If you work in this space and have your own "what if" question — this is a ready-made sandbox. The pipeline is modular, the queue system handles GPU and CPU stages separately, and the UI surfaces whatever your stage produces.

6 VLM integrations attempted
3 Scene modes — indoor, outdoor, object
~2% Scale accuracy with ArUco markers

Camera walkthrough

Step through the real SfM camera poses. Rotate freely in place — FOV matched to the recording lens. Blend the original frame over the point cloud or mesh to see exactly where the reconstruction diverges from the real scene. Toggle point cloud ↔ mesh without leaving the camera position.

Screenshots

Project list with quality scores Project detail — pipeline accordion and GPU gauge RGB point cloud — colourised from extracted frames Mesh tab — Ball Pivoting or Poisson by scene type Re-shoot suggestions — scene-aware, quality-aware Scene overview — camera trajectory with gap markers
Project list — color-coded quality score per reconstruction (click to expand)
Project list with quality scores Project detail — pipeline accordion and GPU gauge RGB point cloud — colourised from extracted frames Mesh tab — Ball Pivoting or Poisson by scene type Re-shoot suggestions — scene-aware, quality-aware Scene overview — camera trajectory with gap markers

What was tried — and what was learned

Six integration attempts across two model tiers (local via Ollama, paid via Gemini) and two phases: first targeting metric scale derivation, then mesh quality improvement. Same wall hit both times. The full research arc — every approach, every failure mode, and what was learned — is documented in docs/journey.md.

1
Grounding DINO — object detection for scale Scanned frames for 18 known objects (keyboards, credit cards, RPi boards, CD cases…), derived metric scale by comparing pixel span to known physical dimensions. Failed: the model confidently detected "keyboard" on decorative patterns and "credit card" on any rectangular object. After VLM validation, useful detection rate dropped to near zero on real scans.
2
LLaVA 1.5 7B — scene classification and validation Used to classify scene type (indoor/outdoor/object), validate GDINO plane fits, and flag featureless surfaces. Failed: structured JSON output was inconsistent run-to-run — hallucinated fields, wrong key names, invalid JSON. Added 4 fallback parsing passes; the root problem remained. Most useful contribution (scene type classification) could be replicated with a simple heuristic.
3
Depth Anything v2 — depth fusion for featureless areas DA2 predicts relative depth per frame; SfM sparse points anchor it to metric scale; aligned depth maps fill in plain walls and ceilings MVS couldn't recover. Failed: scale alignment fragile with fewer than ~15 anchor points (common in texture-poor scenes); disparity convention was reversed causing silent output drops; back-projection at 8K produced 26M points per frame, crashing on 12 GB VRAM. Worth revisiting. Every failure here was an implementation problem, not a conceptual one. ArUco now provides the reliable metric scale that made alignment fragile; the disparity bug is a one-line fix; feeding the pipeline's 2K frames instead of raw 8K eliminates the VRAM crash. The original research target was never actually tested because the prerequisite wasn't in place. It is now.
4
Render-and-compare hallucination flagger Depth fusion (approach 3) added geometry in featureless areas — but sometimes added geometry that wasn't really there. The idea: render the 3D cloud from each camera's exact viewpoint and compare it pixel-by-pixel against the original photo. Areas where the rendered cloud diverged significantly from the real frame were flagged as likely hallucinations and their points suppressed. In principle, a VLM-guided quality gate for AI-added geometry. In practice, it addressed a symptom rather than the cause. The underlying problem was that depth fusion was adding incorrect geometry because the scale alignment was unreliable — fixing the alignment would have eliminated the hallucinations at the source. Removed alongside depth fusion; the lesson was that adding a validation layer on top of an unreliable stage is a losing position.
5
Local vs. paid VLM — a cost/consistency experiment After repeated failures with paid API calls, one hypothesis was that cost was the constraint. Running an LLM on every frame of a video is expensive — so in practice you sample, batch, and limit retries, which means less iteration and fewer attempts to recover from bad outputs. The hypothesis: if inference were free, you could run it on every frame, retry aggressively on malformed output, and average across multiple passes. To test this, the pipeline was restructured in two tiers: Ollama running Qwen 2.5 7B and LLaMA 3.1 8B on-device (essentially free per call) for high-frequency tasks — per-frame classification, structured JSON extraction, dimension parsing — and Gemini for the higher-stakes reasoning steps where the local model wasn't confident. The hypothesis was wrong. Cost was not the bottleneck. The failure mode was consistency: the local model produced malformed JSON, hallucinated field names, and made wrong identifications at the same rate as the hosted one — just faster and cheaper. A confidently wrong answer is still wrong at any price. The two-tier architecture is worth keeping for tasks where approximate output is acceptable, but for pipeline stages where correctness is binary it changes nothing.
6
TripoSR — single-image 3D reconstruction for mesh completion After Ball Pivoting produced rough meshes for dark indoor scans (reflective black surfaces, incomplete coverage), the question was whether a generative 3D model could fill in missing geometry. TripoSR (Stability AI) was tested on the best-lit photo from the scan. Background removal worked cleanly. Inference ran in 0.5 s on an RTX 4070 Ti. The output mesh was an unrecognisable blob. TripoSR is trained on centred, front-facing product photos of symmetric objects — a lawn mower photographed diagonally from below, handle extending off-frame, is outside its distribution. The lesson is the same as every previous attempt: the bottleneck is the input data, not the reconstruction algorithm. Better-lit photos with fuller orbital coverage improve the result more than any post-hoc AI approach. Worth a follow-up experiment. The test used a scan frame — diagonal, from below, handle cut off — the worst possible input for a model trained on product photos. The untested case is a deliberate front or side-facing shot taken at mid-height, mower filling the frame. The scan already contains 40 frames; a few are likely much closer to TripoSR's training distribution than the one tested. The interesting experiment: run TripoSR across several candidate frames and see whether the output is recognisable before attempting any alignment. If it is, the next question is whether ICP can align it to the photogrammetry cloud accurately enough to be useful — given that TripoSR would produce a plausible lawn mower shape, not this specific mower's exact geometry.

Three failure modes no prompt engineering could fix

Observed consistently across both local and paid models.

Over-specification When asked to identify an object, the model would go too deep. A ZX Spectrum 48K on a desk would come back as "the rubber key variant" — irrelevant for scale derivation and itself potentially wrong. Every prompt variation tried eventually broke down. The model's training pushes toward specificity, and that instinct can't be reliably suppressed for objects it knows well.
Context hallucination A frame showing a vintage computer, a disk drive, and a controller would produce a detection for a monitor — because a monitor belongs in that scene. The model was completing a plausible scene description rather than reporting what was actually in frame. Confidence scores were high on these hallucinated detections, making them indistinguishable from correct ones.
Bounding box drift Even on correctly identified objects, the bounding box would shift between runs on the same frame — clipping the object's edges or expanding to include nearby objects. Since scale derivation depends on pixel span vs. known physical dimensions, a box 20% too wide produces a scale factor 20% off. Silent, undetectable from the model's output.

Why ArUco markers won

The scale problem is fundamentally an information problem, not a perception problem. The video contains no absolute length reference — and rather than trying to extract one post-hoc with AI (unreliable), the right move is to put one in the scene before filming.

ArUco markers are printed squares — standardised in OpenCV, detectable in any lighting, available in dictionaries of up to 1,000 unique IDs. Print them at a known physical size, place them in the scene, film them as part of your walkthrough. The pipeline triangulates each marker's 3D world position from the SfM reconstruction and derives scale from the ratio of known physical distance to SfM-unit distance. With two or more markers co-visible in at least one frame, accuracy is consistently around 2%.

A consistent physical process — print markers, place them, shoot — turned out to be less frustrating, faster, and far more accurate than any AI-based approach. The VLM work was genuinely useful for understanding where the limits are.

~2% Scale accuracy from inter-marker triangulation
0 User confirmation steps — fully automated

Current pipeline

14–15 stages, fully automated. Branches by scene_type: indoor rooms get floor-plane fill and Poisson mesh; outdoor and object scans use Ball Pivoting; object scans additionally run Gaussian Splatting after MVS. Fails fast at detect_aruco before any expensive COLMAP stages run. Outputs: PLY point cloud · OBJ mesh · LAS · .splat for WebGL Gaussian Splat viewer.

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '15px', 'fontFamily': 'Inter, ui-sans-serif, sans-serif', 'lineColor': '#a5b4fc', 'primaryTextColor': '#e2e4ea', 'primaryColor': '#1e2330', 'primaryBorderColor': '#6366f1', 'secondaryColor': '#252b3b', 'tertiaryColor': '#181c26', 'background': '#0d0f14', 'clusterBkg': '#181c26', 'clusterBorder': '#374151', 'titleColor': '#e2e4ea'}}}%%
flowchart TD
    subgraph extract["Extract (CPU)"]
        A["extract_metadata\nfocal length, GPS, rotation"]
        B["extract_frames\nstep-based, sharpest of burst"]
        C["detect_aruco\nFAIL FAST if no markers"]
        A --> B --> C
    end

    subgraph geometry["Geometry (GPU)"]
        D["feature_matching\nLightGlue + DISK"]
        E["sfm\npycolmap incremental"]
        F["mvs\nCOLMAP patch_match_stereo"]
        D --> E --> F
    end

    subgraph correction["Correction (CPU)"]
        G["correct_trajectory_jumps\nICP fix for teleport blocks"]
    end

    subgraph scale["Scale (CPU)"]
        H["detect_aruco_sfm\nre-scan with COLMAP intrinsics"]
        I["scale_from_aruco\ntriangulate markers → m/unit"]
        J["apply_known_scale\nscale dense cloud to metric"]
        H --> I --> J
    end

    subgraph postprocess["Post-process (CPU)"]
        K["fill_planes\nfloor/wall projection + void fill"]
        L["refine_cloud\nSOR outlier removal + voxel downsample"]
        M["coverage\nHPR + DBSCAN visibility scoring"]
        N["export\nPLY · OBJ · LAS"]
        K --> L --> M --> N
    end

    extract --> geometry --> correction --> scale --> postprocess
        

Scout + Full mode

The problem A full pipeline run takes 2–3 hours. If the footage is poor quality, that's 2 hours to produce a poor result. Scout mode takes 40 frames, runs the full geometry pipeline up to SfM (skipping MVS), and completes in ~15 minutes — giving early quality feedback before committing to the full run.
What it measures and tunes Registration rate (% cameras registered) and mean reprojection error drive three parameters: target_frames (120–400; more frames if registration was poor), match_window (10–25; wider for better loop closure), and mvs_min_consistent (2–4; tighter when geometry is clean). The full run fires automatically with the calibrated parameters.
/reprocess endpoint After the post-SfM checkpoint is saved, POST /api/projects/{"{id}"}/reprocess?from_stage=scale_from_aruco re-runs any downstream stage without re-running COLMAP. Useful when iterating on scale derivation or post-processing after a code change.

Architecture

Described using the C4 model. The pipeline flowchart above covers the processing detail — these diagrams show how the system is built. Full technical detail including the mesh export pipeline, data model, storage backends, and deployment topology: docs/architecture.md.

System Context

Who uses the system and what external dependencies it has. The platform is designed for researchers and engineers who want to experiment with CV and VLM ideas on top of a working photogrammetry baseline.

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '14px', 'fontFamily': 'Inter, ui-sans-serif, sans-serif', 'lineColor': '#a5b4fc', 'primaryTextColor': '#e2e4ea', 'primaryColor': '#1e2330', 'primaryBorderColor': '#6366f1', 'secondaryColor': '#252b3b', 'tertiaryColor': '#181c26', 'background': '#0d0f14', 'clusterBkg': '#181c26', 'clusterBorder': '#374151', 'titleColor': '#e2e4ea'}}}%%
flowchart TD
    RES(["👤 Researcher / Engineer\nruns scans · reviews 3D output"])

    subgraph platform["Photogram Platform"]
        APP["Photogram\nphotogrammetry pipeline\n& research platform"]
    end

    GPU["⚡ NVIDIA GPU\nCUDA acceleration"]
    HF["🤗 Hugging Face Hub\nLightGlue · nerfstudio weights"]
    STORE["🗄️ Object Storage\nLocal FS · WebDAV · S3"]

    RES -->|"HTTPS + WebSocket"| APP
    APP -->|"CUDA"| GPU
    APP -->|"HTTPS — first use only"| HF
    APP -->|"read/write frames,\nclouds, exports"| STORE
            

Containers

Six deployable containers coordinated by Docker Compose. The GPU and CPU workers consume from separate Celery queues — GPU-bound stages (feature matching, SfM, MVS, Gaussian Splatting) are routed to the GPU worker; everything else goes to the CPU worker. Redis serves triple duty: Celery broker, result backend, and WebSocket pub/sub relay for live pipeline progress.

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, ui-sans-serif, sans-serif', 'lineColor': '#a5b4fc', 'primaryTextColor': '#e2e4ea', 'primaryColor': '#1e2330', 'primaryBorderColor': '#6366f1', 'secondaryColor': '#252b3b', 'tertiaryColor': '#181c26', 'background': '#0d0f14', 'clusterBkg': '#181c26', 'clusterBorder': '#374151', 'titleColor': '#e2e4ea'}}}%%
flowchart TD
    USER(["👤 Researcher"])

    subgraph compose["Docker Compose"]
        FE["Frontend\nNext.js 15 · Three.js\n3D viewer · pipeline monitor\nWebSocket live progress"]
        API["API\nFastAPI · uvicorn\nREST + WebSocket relay\nproject & job management"]
        GPU["GPU Worker\nCelery · CUDA\nfeature matching · SfM\nMVS · Gaussian Splatting"]
        CPU["CPU Worker\nCelery · open3d\nArUco · scale · coverage\nrefine · export"]
        PG[("PostgreSQL\nprojects · jobs · uploads\npipeline_results")]
        REDIS[("Redis\nCelery broker\nresult backend\nWebSocket pub/sub")]
        STORE[("Storage\nLocal FS / S3 / WebDAV\nframes · clouds · exports")]
    end

    USER -->|"HTTPS"| FE
    FE -->|"REST + WS"| API
    API -->|"asyncpg"| PG
    API -->|"pub/sub"| REDIS
    API -->|"Celery task"| GPU
    API -->|"Celery task"| CPU
    GPU -->|"progress"| REDIS
    CPU -->|"progress"| REDIS
    GPU <-->|"read/write"| STORE
    CPU <-->|"read/write"| STORE
    FE <-->|"WS relay"| REDIS
            

Components

The major modules inside the two Celery workers. Each pipeline stage is a self-contained module under backend/workers/pipeline/ — it receives prev_result, does its work, updates prev_result, and returns it. The Celery task wrapper in tasks.py handles job tracking, heartbeats, progress events, and cleanup. Neither layer knows about the other's concerns.

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, ui-sans-serif, sans-serif', 'lineColor': '#a5b4fc', 'primaryTextColor': '#e2e4ea', 'primaryColor': '#1e2330', 'primaryBorderColor': '#6366f1', 'secondaryColor': '#252b3b', 'tertiaryColor': '#181c26', 'background': '#0d0f14', 'clusterBkg': '#181c26', 'clusterBorder': '#374151', 'titleColor': '#e2e4ea'}}}%%
flowchart TB
    subgraph gpu["⚡ GPU Worker (gpu queue)"]
        direction LR
        FM["feature_matching\nLightGlue + DISK"] --> SFM["sfm\npycolmap"] --> MVS["mvs\nCOLMAP CUDA"] --> GS["gaussian_splatting\nnerfstudio · object only"]
    end

    subgraph cpu["🖥️ CPU Worker (celery queue)"]
        direction LR
        EM["extract_metadata"] --> EF["extract_frames"] --> AD["detect_aruco"] --> TC["correct_trajectory"] --> AS["detect_aruco_sfm"] --> SF["scale_from_aruco"] --> AK["apply_scale"] --> FP["fill_planes"] --> RC["refine_cloud"] --> COV["coverage"] --> EXP["export"]
    end

    gpu --> cpu
            

Code Patterns

Three patterns that hold the system together: the Celery chain that wires stages into a pipeline, the WebSocket relay that surfaces live progress to the frontend, and the reprocess checkpoint that lets any post-SfM stage be re-run without repeating the hours-long COLMAP stages.

%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, ui-sans-serif, sans-serif', 'lineColor': '#a5b4fc', 'primaryTextColor': '#e2e4ea', 'primaryColor': '#1e2330', 'primaryBorderColor': '#6366f1', 'secondaryColor': '#252b3b', 'tertiaryColor': '#181c26', 'background': '#0d0f14', 'clusterBkg': '#181c26', 'clusterBorder': '#374151', 'titleColor': '#e2e4ea'}}}%%
flowchart TB
    subgraph row1[" "]
        direction LR
        subgraph chain["Celery chain — pipeline dispatch"]
            direction LR
            C1["extract_metadata\nextract_frames\ndetect_aruco"] --> C2["GPU stages\nfeature · sfm · mvs"] --> C3["detect_aruco_sfm"] -->|"checkpoint → Redis"| C4["scale_from_aruco\napply_scale · refine · export"]
        end
        subgraph ws["WebSocket — live progress"]
            direction LR
            W1["publish_progress()\nin each stage"] --> W2["Redis\npub/sub"] --> W3["API WS\nrelay"] --> W4["Browser\nlive update"]
        end
    end

    subgraph row2[" "]
        direction LR
        subgraph reprocess["POST /reprocess — partial re-run"]
            direction LR
            R1["read checkpoint\nfrom Redis"] --> R2["dispatch chain\nfrom any stage"] --> R3["minutes not hours\nno COLMAP re-run"]
        end
        subgraph emit["emit_stage_complete — persistence"]
            direction LR
            E1["stage\nfinishes"] --> E2["WS event +\nDB write"] --> E3["pipeline_results\nmetrics · timings"] --> E4["page reload\nno WS needed"]
        end
    end

    row1 --> row2

    style row1 fill:none,stroke:none
    style row2 fill:none,stroke:none
            

Tech Stack

Pipeline

  • Python
  • Celery + Redis
  • Docker + NVIDIA CDI

Geometry

  • COLMAP CLI (CUDA)
  • pycolmap
  • LightGlue + DISK
  • open3d
  • trimesh
  • nerfstudio splatfacto
  • OpenCV ArUco

API & Frontend

  • FastAPI
  • Next.js 15
  • Three.js PLY viewer
  • WebSockets

Storage & DB

  • PostgreSQL
  • Local FS / WebDAV
  • MinIO S3
  • laspy (LAS export)
Back to portfolio