Pinning GDAL, PROJ and OpenCV Versions Reproducibly

A survey is reprocessed a year after the original run, from the same imagery, with the same parameters, in the same container image, and the coordinates come out eleven centimetres different. Nothing in the pipeline changed. The base image’s tag was rebuilt by its publisher six months ago against a newer PROJ, which ships a revised transformation grid for that region.

Reproducibility in a geospatial pipeline is mostly a question of what “the same” means, and a tag does not mean it. This page covers pinning the stack so that “same image, same input, same answer” actually holds. It is the version-control detail behind containerising photogrammetry workers with Docker.

Four things that move under a fixed tag

The base image. gdal:3.8.0 identifies a build, and the publisher can push a new build under the same tag. The version number is unchanged and the binaries are not.

Transitive Python dependencies. pip install rasterio==1.3.9 pins rasterio and not the twelve packages it depends on, several of which release weekly.

System packages. apt-get install pdal installs whatever the distribution’s archive holds today, which changes with every security update.

PROJ grid data. Transformation grids are versioned separately from PROJ itself and are revised as national agencies publish improvements. A newer grid is more accurate and produces different coordinates.

The last is the one that surprises people, because it is the only one where the change is an improvement and the result is still a reproducibility failure.

What each level of pinning actually fixes Four rows showing what a pinning strategy does and does not fix. Pinning by tag fixes nothing: the base image, transitive dependencies, system packages and PROJ grids can all change. Pinning the base by digest fixes the base image only. Adding a Python lockfile fixes the transitive dependencies as well. Baking in the PROJ grid data and pinning system packages fixes all four. A note records that only the last row gives a pipeline that reproduces a coordinate a year later. base image python deps system pkgs PROJ grids tag only + digest + lockfile + grids and apt pins Only the bottom row reproduces a coordinate a year later. The grid column is the one that changes because somebody improved something.

Figure 1 — Four levels of pinning, and what each leaves free to move.

Minimal reproducible solution

# 1. The base, by digest. The tag is a comment, not a reference.
FROM ghcr.io/osgeo/gdal@sha256:0f2a1c2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8
# gdal:ubuntu-small-3.8.0 as of 2026-01-14

# 2. System packages with explicit versions. An unpinned apt install is a
#    different pipeline every time the archive updates.
RUN apt-get update && apt-get install -y --no-install-recommends \
        pdal=2.6.0+ds-1 \
        libpdal-base14=2.6.0+ds-1 \
    && rm -rf /var/lib/apt/lists/*

# 3. Python from a fully resolved lockfile, hashes included.
COPY requirements.lock /tmp/requirements.lock
RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock

# 4. PROJ grids baked in and network fetching disabled, so a transformation
#    cannot silently improve between runs.
COPY proj-data/ /usr/share/proj/
ENV PROJ_NETWORK=OFF PROJ_DATA=/usr/share/proj

--require-hashes is what turns a lockfile from a convention into an enforcement. Without it, a lockfile pins versions and a compromised or re-released artefact with the same version number still installs; with it, the bytes must match.

Generating and maintaining the lockfile

import subprocess
from pathlib import Path


def regenerate_lockfile(requirements_in: str, lockfile: str,
                        *, image: str) -> dict:
    """Resolve dependencies inside the target image, not on a developer machine.

    Resolution is platform-dependent: a wheel chosen on macOS may not exist
    for the image's Linux and Python version, and the difference surfaces as
    a build failure weeks later. Resolving inside the image removes it.
    """
    result = subprocess.run(
        ["docker", "run", "--rm", "-v", f"{Path(requirements_in).parent}:/w",
         "-w", "/w", image,
         "sh", "-c", f"pip install pip-tools && pip-compile --generate-hashes "
                     f"--output-file {Path(lockfile).name} {Path(requirements_in).name}"],
        capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(result.stderr[-600:])
    return {"lockfile": lockfile, "lines": len(Path(lockfile).read_text().splitlines())}

Regenerating deliberately, on a schedule, is the counterpart to pinning hard. A pipeline that never updates accumulates security debt; one that updates on every build is not reproducible. The practice that works is pinning everything, updating on a cadence, and re-running a reference dataset after each update to see what moved.

Three levels of pinning, and what each actually guarantees Three rows. Pinning a tag guarantees almost nothing, because a tag is a moving reference and the same tag pulled a month later can carry different library versions entirely. Pinning explicit package versions guarantees the named libraries but not their transitive dependencies, so a rebuild can still pick up a changed dependency beneath them. Pinning the image digest guarantees the exact bytes, which is the only level at which a recorded run can be reproduced, and is why the digest rather than the tag belongs in a run's provenance record. a tag a moving reference — the same tag a month later is a different image explicit package versions the named libraries, but not their transitive dependencies the image digest the exact bytes — the only level at which a run can be reproduced Build against versions, record the digest, and reproduce from the digest.

Figure 3 — Three levels, and only the third is a guarantee.

Edge-case matrix

Situation Risk Handling
Base referenced by tag Rebuilt under the same name Reference by digest
pip install package==x Transitive deps float Full lockfile with hashes
apt-get install pkg Archive moves Pin versions, or snapshot the archive
PROJ network fetching on Grids appear and improve Bake grids in, PROJ_NETWORK=OFF
Conda environment Solver picks new builds Export an explicit environment with builds
Registry garbage collection Old digests deleted Retain images for the archive period
Distribution end of life Package versions unavailable Snapshot or vendor the packages
GPU driver on the host Outside the image entirely Record it in the run manifest

The GPU driver row is worth stating because it cannot be fixed by any amount of image pinning. The driver lives on the host, the container uses it, and it therefore belongs in the run record alongside the image digest rather than in the image.

Verification snippet

import json
import subprocess


PROBE = """
import hashlib, json, os, sys
out = {"python": sys.version.split()[0]}
try:
    from osgeo import gdal
    out["gdal"] = gdal.__version__
except Exception as e:
    out["gdal"] = f"error: {e}"
try:
    import pyproj
    out["proj"] = pyproj.proj_version_str
    d = pyproj.datadir.get_data_dir()
    out["proj_data_dir"] = d
    names = sorted(f for f in os.listdir(d) if f.endswith((".tif", ".gsb")))
    h = hashlib.sha256()
    for n in names:
        h.update(n.encode())
        h.update(str(os.path.getsize(os.path.join(d, n))).encode())
    out["proj_grid_digest"] = h.hexdigest()[:16]
    out["proj_grid_count"] = len(names)
except Exception as e:
    out["proj"] = f"error: {e}"
try:
    import cv2
    out["opencv"] = cv2.__version__
except Exception as e:
    out["opencv"] = f"error: {e}"
print(json.dumps(out))
"""


def stack_fingerprint(image: str) -> dict:
    """A single record identifying every component that can change an answer."""
    result = subprocess.run(["docker", "run", "--rm", image, "python3", "-c", PROBE],
                            capture_output=True, text=True, check=True)
    fingerprint = json.loads(result.stdout)
    fingerprint["image_digest"] = subprocess.run(
        ["docker", "inspect", "--format", "{{index .RepoDigests 0}}", image],
        capture_output=True, text=True, check=True).stdout.strip()
    return fingerprint

Hashing the grid inventory rather than the grid contents is a deliberate compromise: it is fast, it catches a grid appearing, disappearing or being replaced by a different-sized file, and it does not require reading gigabytes. For most pipelines that is the right balance.

Coordinate shift from a revised PROJ transformation grid A survey's coordinates computed with two versions of a national transformation grid. The revised grid moves every point by between eight and fourteen centimetres in a smooth spatial pattern across the site, with the largest shift in the north-east. A note states that the revision is an improvement in accuracy and still a reproducibility failure, because the same pipeline over the same input now returns different numbers. shift from 8 cm in the south-west to 14 cm in the north-east A more accurate grid, and a reproducibility failure. Both statements are true, which is why the grid must be pinned and updated deliberately.

Figure 2 — The change that happens because somebody improved something.

Updating without losing reproducibility

Pinning hard and never updating is not a strategy; it is deferred maintenance with a security dimension. The practice that keeps both properties is to make updates discrete, dated events with a measured consequence.

Update on a cadence, not on a build. Monthly or quarterly, depending on the programme. Between updates the stack is frozen and every result is comparable.

Re-run a reference dataset after every update. A small survey kept for this purpose, processed before and after, gives a concrete number for what the update changed: coordinates moved by so many millimetres, this many points differ, the runtime changed by this much. That number is worth far more than a changelog.

Record the update as a version of the pipeline. Results produced before and after are comparable within each period and carry a documented step between them, which is exactly how a monitoring series should treat any methodological change.

def reference_comparison(before: dict, after: dict) -> dict:
    """What an update changed, measured on a reference dataset."""
    shifts = [abs(before["points"][k] - after["points"][k]) for k in before["points"]
              if k in after["points"]]
    return {"points_compared": len(shifts),
            "max_shift_m": max(shifts) if shifts else 0.0,
            "median_shift_m": sorted(shifts)[len(shifts) // 2] if shifts else 0.0,
            "runtime_change": after["runtime_s"] / max(before["runtime_s"], 1e-9),
            "material": bool(shifts and max(shifts) > 0.01)}

A material flag on that comparison is what decides whether the update needs to be announced to clients or can simply be recorded. Most updates move nothing measurable; the ones that do are exactly the ones worth a sentence in the next report.

When to escalate

  • An old image digest is no longer in the registry. The result cannot be reproduced exactly. Record the fingerprint with every run so at least the components are known, and set a retention policy that matches the archive period.
  • A transformation grid must be updated for accuracy. Do it deliberately, re-run a reference dataset, and record the step in the project history. The change is legitimate and the discontinuity must be documented.
  • Bit-identical output is contractually required. Pin the thread count and the hardware as well; floating-point reduction order depends on both, and no image pins either.

Containerising Photogrammetry Workers with Docker