Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
55add0f
Port packaging from poetry to uv with PEP 621 metadata
romleiaj Jul 4, 2026
6267277
Install the postproc image with uv sync --frozen
romleiaj Jul 4, 2026
ddb3491
Let the py3.8 GUI image install past the new requires-python floor
romleiaj Jul 4, 2026
5a5ce69
Add a conda env to support gdal cross-platform
romleiaj Jul 5, 2026
7ed5fc0
Unify postproc install around conda + make install, in Docker and nat…
romleiaj Jul 6, 2026
f06bd06
Add pycolmap>=4.0 to the conda env
romleiaj Jul 6, 2026
57a8d6d
Replace setup_postproc scripts with documented conda + make install s…
romleiaj Jul 6, 2026
db1ed0d
Document pip install for Windows, tighten README and comments
romleiaj Jul 6, 2026
0375745
Add kamera-calibrate: multi-sensor rig calibration with pycolmap 4.2
romleiaj Sep 16, 2026
53c628a
Fit homographies at a ground range and report implied exposure delays
romleiaj Sep 16, 2026
f473cf8
Report exposure timing relative to the reference camera
romleiaj Sep 16, 2026
178e61e
Document the camera exposure timing findings
romleiaj Sep 16, 2026
289a664
Add a plain-language walkthrough of the rig calibration
romleiaj Sep 16, 2026
84ea0b4
Adopt ruff as the formatter and linter at line length 88
romleiaj Sep 21, 2026
f2a3292
Tidy the calibration package for clarity
romleiaj Sep 21, 2026
cf40a82
camera_models: drop save_to_krtd, fix the two pre-existing lint errors
romleiaj Sep 21, 2026
6a00e99
Add bootstrap.py: one-step environment build on Linux, macOS and Windows
romleiaj Sep 21, 2026
0166882
Adjust language in conda yaml
romleiaj Sep 21, 2026
ea2f494
Warn on an unreliable rig seed and report per-camera observation counts
romleiaj Sep 21, 2026
a968646
Hold distortion fixed during pass 1 mapping
romleiaj Sep 21, 2026
f4646b3
Fix review findings in the calibration pipeline and bootstrap
romleiaj Sep 21, 2026
5ce5507
camera_models: share the yaml writer across save_to_file
romleiaj Sep 21, 2026
918a1b2
Remove the legacy test_camera_models script
romleiaj Sep 21, 2026
98d6c96
create_flight_summary: honour output_dir
romleiaj Sep 21, 2026
d254436
kamera-calibrate: write a postflight sys_config.json for the new models
romleiaj Sep 21, 2026
650bd07
Seed lens distortion per modality instead of holding it at zero
romleiaj Sep 21, 2026
aa2ae68
Drop the ir->uv homography pair
romleiaj Sep 21, 2026
5457d4e
Rename 'models' to 'camera_models' to avoid name collision with the 3…
romleiaj Sep 21, 2026
a832761
Rework the calibration report and rename the output directory
romleiaj Sep 22, 2026
c376ca0
Update md language, move error source section to README
romleiaj Sep 22, 2026
89c3721
Clean up ruff findings in the legacy geo and postflight modules
romleiaj Sep 22, 2026
cffab6c
Report error in source pixels as well as target pixels
romleiaj Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.venv
**/__pycache__
**/*.pyc
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
**.swp
**.pyc
**.DS_Store
uv.lock
artifacts
build
devel
provision/ansible/.password
.catkin_tools
.venv
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `kamera-calibrate`: multi-sensor rig calibration from a calibration flight
(`kamera/calibration`). One COLMAP model with trigger-synchronized frames, INS
position priors, rig bundle adjustment; writes camera model yamls, `rig.yaml`,
DIVE v2 registration JSON, GIFs and a PDF report.

### Changed

- Post-processing env moves to Python 3.13 and pycolmap 4.2 (conda-forge, CUDA build).
- `bootstrap.py` builds the conda env and `.venv` in one step on Linux, macOS and
Windows; `make install` calls it. `.venv` is recreated instead of failing when it exists.
- ruff (line length 88) is the project formatter and linter, installed with the `dev` group.

### Removed

- Old per-camera calibration scripts under `kamera/postflight/scripts`.

## [0.5.0] - 2026-07-21

### Added
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
ROS_DISTRO ?= noetic
CONDA_ENV ?= kamera

.PHONY: build core viame gui postflight follower leader all clean
.PHONY: install build core viame gui postflight follower leader all clean

# Conda env from environment.yml plus .venv on top of it (see bootstrap.py)
install:
@python bootstrap.py --name $(CONDA_ENV)

build:
docker compose build
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,41 @@ KAMERA, or the **K**nowledge-guided Image **A**cquisition **M**anag**ER** and **

## Installation

### Post-processing (native, Windows or Linux)

GDAL and pycolmap come from conda-forge (Python 3.13); [uv](https://docs.astral.sh/uv/)
installs the rest into `.venv` from the lockfile. Requires conda:
[Miniforge](https://conda-forge.org/download/) is recommended since it defaults to the
conda-forge channel these packages come from, but
[Miniconda](https://www.anaconda.com/download/success) or a full Anaconda install also
work because `environment.yml` pins the channel. The same steps work on Linux, macOS
and Windows (PowerShell or Miniforge/Anaconda Prompt):

```bash
git clone https://github.com/Kitware/kamera.git
cd kamera
# For the pure post-processing and generating flight summary, you can install
# the requirements in requirements.txt, or use the provided dockerfile
python bootstrap.py
conda activate kamera
source .venv/bin/activate # Windows: .venv\Scripts\activate
```

`bootstrap.py` creates the `kamera` conda env from `environment.yml` (or updates it
if it exists) and builds `.venv` on top of it; `make install` does the same on Linux.
Pass `--name` to build a second env beside an existing one. Afterwards, activating
the conda env and then `.venv` is all you need. Conda installs the CUDA build of
pycolmap automatically with NVIDIA driver 575+ (CUDA 12.9), otherwise the CPU build;
GPU only matters for full camera model calibration.

### Rig calibration

`kamera-calibrate <flight_dir>` calibrates every camera on the rig from a calibration
flight and writes camera models, the rig geometry, DIVE registration files and a PDF
report. See [kamera/calibration/README.md](kamera/calibration/README.md).

### Docker images

```bash
# post-processing / flight summary image
make postflight
# Builds the core docker images for use in the onboard sytems
make nuvo
Expand Down
108 changes: 108 additions & 0 deletions bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Build the post-processing environment on Linux, macOS or Windows.

Creates (or updates) the conda env from environment.yml, then builds .venv on top
of it with uv from the lockfile. Run it with any Python, e.g. the conda base one:

python bootstrap.py # env named as in environment.yml
python bootstrap.py --name test # a second env beside it

Afterwards activate the conda env and then .venv (the script prints the commands).
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys

ROOT = os.path.dirname(os.path.abspath(__file__))
ENV_FILE = os.path.join(ROOT, "environment.yml")


def read_environment_yml() -> tuple[str, str]:
"""Return (env name, python version) without needing pyyaml."""
text = open(ENV_FILE).read()
name = re.search(r"^name:\s*(\S+)", text, re.M)
python = re.search(r"^\s*-\s*python\s*=\s*([\d.]+)", text, re.M)
if not name or not python:
sys.exit(f"could not read name and python version from {ENV_FILE}")
return name.group(1), python.group(1)


def find_conda() -> str:
"""CONDA_EXE if it still points at a real conda (a shell can carry a stale one
after an uninstall), else whatever conda is on PATH, else micromamba (the docker
image has nothing else)."""
conda = os.environ.get("CONDA_EXE", "")
if not os.path.isfile(conda):
conda = shutil.which("conda")
if not conda:
conda = os.environ.get("MAMBA_EXE", "")
if not os.path.isfile(conda):
conda = shutil.which("micromamba")
if not conda:
sys.exit(
"conda not found; install Miniforge from https://conda-forge.org/download/"
)
return conda


def is_micromamba(conda: str) -> bool:
return "micromamba" in os.path.basename(conda).lower()


def run(cmd: list[str], dry_run: bool) -> None:
print("+", " ".join(cmd), flush=True)
if not dry_run:
subprocess.run(cmd, cwd=ROOT, check=True)


def conda_env_exists(conda: str, name: str) -> bool:
"""Ask this conda whether it can resolve the env by name (a path match is not
enough when several conda installs share a machine)."""
probe = [conda, "run", "-n", name, "python", "--version"]
return subprocess.run(probe, capture_output=True).returncode == 0


def main() -> None:
default_name, python_version = read_environment_yml()
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--name", default=default_name, help="conda env name")
parser.add_argument(
"--dry-run", action="store_true", help="print the commands without running"
)
args = parser.parse_args()

conda = find_conda()
# micromamba prompts before installing unless told not to; conda env does not.
yes = ["-y"] if is_micromamba(conda) else []
verb = "update" if conda_env_exists(conda, args.name) else "create"
run([conda, "env", verb, "-n", args.name, "-f", ENV_FILE] + yes, args.dry_run)

# uv runs inside the conda env so .venv is built on the conda python and sees
# the conda GDAL and pycolmap through --system-site-packages. micromamba run
# never captures output and rejects conda's flag for that.
stream = [] if is_micromamba(conda) else ["--no-capture-output"]
uv = [conda, "run", "-n", args.name] + stream + ["uv"]
run(
uv
+ ["venv", "--clear", "--system-site-packages", f"--python={python_version}"],
args.dry_run,
)
run(uv + ["sync", "--frozen", "--no-cache"], args.dry_run)

activate = (
r".venv\Scripts\activate" if os.name == "nt" else "source .venv/bin/activate"
)
tool = "micromamba" if is_micromamba(conda) else "conda"
print(
"\nInstallation finished. To use kamera:"
f"\n {tool} activate {args.name}\n {activate}"
)


if __name__ == "__main__":
main()
8 changes: 4 additions & 4 deletions docker/gui.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ RUN mkdir -p /home/user/.config/kamera && \
RUN ln -sv /usr/bin/python3 /usr/bin/python || true
RUN find /home/user -not -user user -execdir chown user {} \+

# Install kamera for wxpython_gui imports (e.g. colmap_processing.camera_models).
# Use --no-deps: base images already provide runtime deps, and a full install
# fails trying to replace distutils-installed PyYAML from ROS/Noetic.
# Install kamera for wxpython_gui imports. --no-deps: deps come from the base
# image (a full install trips on ROS's distutils PyYAML).
# --ignore-requires-python: ROS Noetic pins python 3.8, below our 3.10 floor.
RUN pip install --no-cache-dir matplotlib \
&& pip install --no-cache-dir --no-deps -e $REPO_DIR
&& pip install --no-cache-dir --no-deps --ignore-requires-python -e $REPO_DIR

# use the exec form of run because we need bash syntax
USER user
Expand Down
37 changes: 29 additions & 8 deletions docker/kamerapy.dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
FROM python:3.10.15-bookworm
FROM debian:bookworm-slim

SHELL ["/bin/bash", "-c"]

RUN apt-get update && apt-get install -yq \
libgdal-dev \
python3-gdal \
libgl1-mesa-glx \
curl \
bzip2 \
ca-certificates \
make \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
redis \
dnsutils \
gdal-bin
&& rm -rf /var/lib/apt/lists/*

# Install micromamba
ARG MAMBA_VERSION=2.3.3
RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/${MAMBA_VERSION} \
| tar -xvj -C /usr/local/bin --strip-components=1 bin/micromamba
ENV MAMBA_ROOT_PREFIX=/opt/conda

# Conda env supplies python + GDAL + uv; make install layers .venv on top
COPY environment.yml /tmp/environment.yml
RUN micromamba create -y -n kamera -f /tmp/environment.yml \
&& micromamba clean --all -y

RUN pip install --upgrade pip
RUN pip install setuptools==57.0.0
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

COPY ./ /src/kamera
WORKDIR /src/kamera
RUN pip install -e .

RUN eval "$(micromamba shell hook --shell bash)" \
&& micromamba activate kamera \
&& make install

ENV PATH="/src/kamera/.venv/bin:/opt/conda/envs/kamera/bin:$PATH"

ENTRYPOINT ["bash"]
11 changes: 11 additions & 0 deletions environment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Binary deps (GDAL, pycolmap) with no reliable cross-platform wheels.
# `make install` layers the rest on top. See README.md for setup.
name: kamera
channels:
- conda-forge
dependencies:
- python=3.13 # must match PYTHON_VERSION in the Makefile
- gdal>=3.10
- pycolmap>=4.2
- pip
- uv
57 changes: 57 additions & 0 deletions kamera/calibration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Rig Calibration

Calibrates every camera on a KAMERA rig from one calibration flight (figure eights at several altitudes) and expresses them in the INS body frame. One COLMAP model holds all modalities: the trigger-synchronized images of each event form a *frame* with a single rig pose, so IR ties into the EO model through the rig without any cross-modal matching.

```bash
conda activate kamera
kamera-calibrate /data/052025_Calibration # everything
kamera-calibrate /data/052025_Calibration --max_frames 150 --frame_stride 3 # quick look
kamera-calibrate --help
```

For a walkthrough of every stage, see [how_it_works.md](how_it_works.md).

## Stages

Each stage resumes from `<flight>/calibration/`, skipping whatever already exists.

1. **frames** — `*_meta.json` grouped by trigger time into frames; camera names are `<channel>_<modality>` (`C_rgb`, `L_ir`, ...). IR and UV are contrast stretched to 8 bit; RGB is symlinked.
2. **features** — SIFT per camera with an initial focal length and distortion per modality, then an INS position prior per image (`InsTrajectory` interpolates the meta.json samples).
3. **match** — spatial matching from the priors, across all cameras, so figure-eight crossovers are matched as well as neighbours in time. Thermal-to-visible pairs are dropped: SIFT cannot match them and their few spurious inliers mislead the mapper.
4. **pass1** — incremental mapping with independent cameras and position priors. EO and IR come out as separate models, both in INS ENU. Needs three-view overlap along track: at 64 m/s and 1 frame/s that means flying above roughly 600 m AGL for these lenses; lower legs only register through crossovers with higher passes.
5. **pass2** — `cam_from_rig` for every camera is averaged from pass 1 (frames shared with the reference camera, both models being in INS ENU), the rig and frames are written to the database and onto the largest pass-1 model, and the images pass 1 never posed (IR) are added to their frames. Every image is then triangulated from the rig poses and bundle adjusted against the INS position priors, twice: rig poses and `sensor_from_rig` first, then with the intrinsics free as well.
6. **calibrate** — INS boresight (`ins_from_rig`) and lever arm as a robust average over frames, per-camera models, and `rig.yaml`.
7. **registration** — per channel `ir->rgb` and `uv->rgb` homographies as DIVE camera-registration JSON (format v2), plus flip GIFs.
8. **report** — PDF with the flight summary, intrinsics, rig geometry and registration overlays.

## Outputs

All output is directed to `<flight>/calibration/camera_models/`:

- `<rig>_<camera>.yaml` — `standard` camera model readable by `kamera.colmap_processing.camera_models.load_from_file`, with the rig and calibration provenance in extra keys.
- `<rig>_rig.yaml` — `cam_from_rig` per camera, `ins_from_rig`, lever arm, quality statistics.
- `dive_registration/<left>_to_<right>_registration.json`, `gifs/`, `<rig>_calibration_report.pdf`.

## Exposure Timing

The cameras do not expose at the same instant after the shared trigger; see [exposure_timing.md](exposure_timing.md) for the measurements, the manuals, and what the pipeline does about it.

## Error Sources

What limits the accuracy of the result, and how each source shows up in the outputs.

**INS attitude at the trigger.** Each meta.json carries one INS sample taken shortly before the trigger, so the attitude used for a frame can be up to 10 ms old. In a figure-eight turn at about 5 degrees per second that is up to 0.05 degrees, roughly 25 RGB pixels on the ground, and it enters every frame's boresight estimate as noise. An event-stamped INS sample or a full-rate INS log would remove it; `InsTrajectory` accepts either without code changes.

**Model drift.** The INS position priors pin the model's scale, heading and position, but its orientation still drifts slowly along the flight, and that drift is what dominates the per-frame boresight scatter reported in the rig yaml. The rig constraint removes any freedom between cameras within a frame, so the relative camera geometry, and therefore the homographies, is far better determined than the absolute boresight.

**Exposure timing.** A camera that exposes later than the reference camera sees the ground further along track, by ground speed times the delay. A bundle adjustment on a moving rig cannot tell that from a camera mounted that far forward, so the delay shows up as an along-track lever arm. The rig table in the report reads that lever arm back into a time difference at the flight's ground speed, positive when the camera exposes after the reference. Only the relative timing between cameras is observable, since a delay shared by the whole rig is absorbed by the position priors. The camera yaml positions carry these offsets, which is correct at similar ground speeds.

**Lever arms.** Beyond that timing signal the lever arms are weakly determined: at 400 to 900 m range a 30 cm baseline subtends less than one IR pixel. Read the reported translations with that in mind. The INS lever arm is the median offset of the rig origin from the INS position over all frames.

**Homographies.** A homography maps one camera onto another exactly only for flat ground at one range, and the timing baseline above makes the range matter. Each pair is fit for the range in its page title (the survey altitude if given, otherwise the calibration flight's median scene range). The fit residual, rms and 95th percentile in RGB pixels, measures the lens distortion a single matrix cannot carry, and the overlay shows it visually as coloured fringes.

## Conventions

- `camera_quaternion` (x, y, z, w) rotates camera vectors into the INS body frame (forward, right, down); `camera_position` is in that frame, in metres.
- COLMAP's `cam_from_rig` maps rig (= reference camera) coordinates into the camera.
- The INS body-to-ENU rotation is `NED_TO_ENU * R_z(heading) R_y(pitch) R_x(roll)`, identical to `kamera.sensor_models.nav_state`.
5 changes: 5 additions & 0 deletions kamera/calibration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Multi-sensor rig calibration from a KAMERA calibration flight.

Pipeline: synchronized frames -> COLMAP SfM with INS position priors -> rig bundle
adjustment -> camera models, rig geometry, INS boresight, DIVE homographies, PDF report.
"""
Loading
Loading