Using DVC Pipelines for Automated Dataset Snapshots
A declarative dvc.yaml pipeline turns annotation archival from a manual chore into a reproducible, hash-gated stage. DVC normalises all inputs to a consistent coordinate reference system — by default EPSG:4326 — validates spatial integrity, computes SHA-256 checksums for every changed asset, then pushes only the modified chunks to remote storage. The precise geometry, projection, and label schema used in each training run become permanently traceable without zip archives, timestamp-based backups, or guesswork.
Why Annotation Drift Breaks Geospatial Training Pipelines
A single reprojected raster, shifted polygon vertex, or corrected .prj file can silently invalidate months of training metrics without triggering any Git diff on binary assets. Teams that version spatial data only via Git LFS hit two compounding problems: large binary blobs slow every clone, and LFS provides no built-in mechanism to assert geometric consistency before archival. The result is annotation drift — where the dataset a model was trained on differs from the dataset recorded in the experiment tracker, making rollback guesswork. Tracking annotation changes with SHA hashing solves the detection problem; DVC pipelines solve the enforcement problem by making the hash check a mandatory gate before any output artifact is written.
Step-by-Step DVC Pipeline Implementation
Step 1 — Install DVC with Your Storage Backend
# S3 backend
pip install "dvc[s3]>=3.50.0"
# GCS backend
pip install "dvc[gs]>=3.50.0"
# Azure Blob backend
pip install "dvc[azure]>=3.50.0"
# Initialise inside your project root (alongside .git/)
dvc init
git add .dvc .dvcignore
git commit -m "chore: initialise DVC"
Step 2 — Declare the Snapshot Pipeline Stage
Create dvc.yaml at the project root. The deps list is the contract DVC hashes; any change there triggers a re-run:
# dvc.yaml
stages:
snapshot_annotations:
cmd: >-
python scripts/validate_and_snapshot.py
--input data/raw/annotations/
--output data/snapshots/
--target-crs 4326
deps:
- data/raw/annotations/
- scripts/validate_and_snapshot.py
outs:
- data/snapshots/latest_snapshot.parquet
- data/snapshots/metadata.json
metrics:
- data/snapshots/pipeline_metrics.json:
cache: false
Setting cache: false on the metrics file lets DVC track it in Git without pushing it to remote storage on every run.
Step 3 — Write the Validation and Hashing Script
The script below normalises all input annotations to the requested target CRS before hashing, so the stored SHA-256 always reflects a deterministic geometric state rather than the arbitrary projection delivered by individual annotators.
# scripts/validate_and_snapshot.py
"""
Validate CRS + geometry for every GeoJSON file in the input directory,
compute per-file SHA-256 hashes, and write a Parquet snapshot manifest.
Requirements:
geopandas>=0.14.0
pyproj>=3.6.0
shapely>=2.0.0
pandas>=2.1.0
pyarrow>=14.0.0
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import pathlib
from datetime import datetime, timezone
from typing import Any
import geopandas as gpd
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
log = logging.getLogger(__name__)
def sha256_file(path: pathlib.Path) -> str:
"""Stream-hash a file without loading it fully into memory."""
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(65_536), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_and_normalise(
path: pathlib.Path,
target_epsg: int,
) -> gpd.GeoDataFrame | None:
"""
Load, validate, and reproject a GeoJSON annotation file.
Returns None and logs a warning if the file cannot be safely processed.
"""
try:
gdf: gpd.GeoDataFrame = gpd.read_file(path)
except Exception as exc:
log.error("Cannot read %s: %s", path.name, exc)
return None
if gdf.crs is None:
log.warning("No CRS declared in %s — skipping.", path.name)
return None
if gdf.crs.to_epsg() != target_epsg:
log.info("Reprojecting %s → EPSG:%d", path.name, target_epsg)
gdf = gdf.to_crs(epsg=target_epsg)
invalid_mask = ~gdf.geometry.is_valid
if invalid_mask.any():
n = int(invalid_mask.sum())
log.warning("Repairing %d invalid geometries in %s via buffer(0).", n, path.name)
gdf.loc[invalid_mask, "geometry"] = (
gdf.loc[invalid_mask, "geometry"].buffer(0)
)
still_invalid = ~gdf.geometry.is_valid
if still_invalid.any():
log.error(
"%d geometries in %s could not be repaired — skipping file.",
int(still_invalid.sum()),
path.name,
)
return None
return gdf
def main(input_dir: str, output_dir: str, target_crs: int) -> None:
input_path = pathlib.Path(input_dir)
output_path = pathlib.Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
if not input_path.is_dir():
raise FileNotFoundError(f"Input directory not found: {input_path}")
records: list[dict[str, Any]] = []
for geojson in sorted(input_path.glob("*.geojson")):
gdf = validate_and_normalise(geojson, target_crs)
if gdf is None:
continue
file_hash = sha256_file(geojson)
records.append(
{
"filename": geojson.name,
"sha256": file_hash,
"feature_count": len(gdf),
"crs_epsg": target_crs,
"geometry_types": sorted(gdf.geometry.geom_type.unique().tolist()),
"snapshot_utc": datetime.now(timezone.utc).isoformat(),
}
)
log.info("Processed %s sha256=%s…", geojson.name, file_hash[:12])
if not records:
raise ValueError("No valid GeoJSON files were processed — aborting snapshot.")
df = pd.DataFrame(records)
parquet_path = output_path / "latest_snapshot.parquet"
df.to_parquet(parquet_path, index=False)
pipeline_hash = hashlib.sha256(
json.dumps(records, sort_keys=True).encode()
).hexdigest()
metadata: dict[str, Any] = {
"schema_version": "1.1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"total_files": len(records),
"target_crs_epsg": target_crs,
"pipeline_hash": pipeline_hash,
}
(output_path / "metadata.json").write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
metrics: dict[str, Any] = {
"files_processed": len(records),
"total_features": int(df["feature_count"].sum()),
"pipeline_hash": pipeline_hash,
}
(output_path / "pipeline_metrics.json").write_text(
json.dumps(metrics, indent=2), encoding="utf-8"
)
log.info(
"Snapshot complete: %d file(s) → %s pipeline_hash=%s…",
len(records),
parquet_path,
pipeline_hash[:12],
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Validate and snapshot geospatial annotations."
)
parser.add_argument("--input", required=True, help="Raw annotations directory")
parser.add_argument("--output", required=True, help="Snapshot output directory")
parser.add_argument(
"--target-crs",
type=int,
default=4326,
help="EPSG code to normalise all annotations to before hashing (default: 4326)",
)
args = parser.parse_args()
main(args.input, args.output, args.target_crs)
Step 4 — Configure Remote Storage and Run the Pipeline
# Add a versioned S3 prefix as the default DVC remote
dvc remote add -d spatial-remote s3://your-bucket/dvc-cache
dvc remote modify spatial-remote credentialpath ~/.aws/credentials
# Reproduce the pipeline (no-op if deps are unchanged)
dvc repro
# Push only the new/changed cache chunks to S3
dvc push
# Commit the lightweight .dvc lock files and metrics to Git
git add dvc.lock data/snapshots/pipeline_metrics.json
git commit -m "data: snapshot annotations v$(date +%Y%m%d)"
dvc repro checks every dep hash against what is stored in dvc.lock. If even one .geojson file or the validation script changes, the stage re-runs in full and produces a new set of output hashes. This keeps the repository under 1 MB while tracking terabytes of spatial data in the remote cache.
For raster assets such as multi-gigabyte GeoTIFF mosaics, avoid local cache bloat by using external tracking:
dvc add --external s3://your-bucket/raw-imagery/mosaic_2024.tif
DVC writes a .dvc pointer file containing only the hash — the binary never enters the local cache.
Step 5 — Automate Snapshots in CI/CD
A GitHub Actions workflow that gates pull requests on pipeline success prevents annotation drift from reaching production training jobs. The metadata.json manifest output integrates with experiment trackers such as MLflow — see Preserving Metadata Across Dataset Versions for how to embed CRS, geometry type, and label schema into versioned manifests consumed by training scripts.
# .github/workflows/annotation-snapshot.yml
name: Annotation Snapshot
on:
pull_request:
paths:
- "data/raw/annotations/**"
- "scripts/validate_and_snapshot.py"
- "dvc.yaml"
jobs:
snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "dvc[s3]>=3.50.0" geopandas>=0.14.0 pyarrow>=14.0.0
- name: Configure DVC remote
env:
AWS_ACCESS_KEY_ID: $
AWS_SECRET_ACCESS_KEY: $
run: |
dvc remote add -d spatial-remote s3://your-bucket/dvc-cache
dvc pull --run-cache
- name: Reproduce snapshot pipeline
run: dvc repro --pull
- name: Push updated cache artifacts
if: github.event_name == 'push'
run: dvc push
Spatial Parameters and Pipeline Thresholds
| Parameter | Value | Purpose |
|---|---|---|
--target-crs |
4326 (default) |
EPSG code for normalisation before hashing |
| DVC remote type | s3, gs, azure, ssh |
Backend for content-addressable cache |
| Chunk size (hash) | 65 536 bytes | Balances memory use and I/O throughput for large .geojson files |
| Parquet compression | snappy (pandas default) | Efficient columnar storage for the manifest |
cache: false (metrics) |
— | Keeps pipeline_metrics.json in Git rather than S3 |
--external flag |
on .tif paths |
Avoids copying multi-GB rasters into local DVC cache |
Common Errors and Fixes
ERROR: No valid GeoJSON files processed.
: Every file in data/raw/annotations/ has a missing CRS or unrepairable geometries. Run ogrinfo -al -so <file>.geojson to confirm CRS is declared, and audit geometry health with python -c "import geopandas as gpd; gdf=gpd.read_file('<file>'); print(gdf.geometry.is_valid.value_counts())" before the pipeline run.
dvc repro reports stage is cached after editing a .geojson file
: DVC hashes file content, not modification time. If content did change, confirm the file is listed under deps in dvc.yaml — DVC tracks directories by recursively hashing their contents, so parent-directory entries usually resolve this. Run dvc status --verbose to compare stored vs current hashes.
pyproj.exceptions.CRSError: Invalid projection
: The annotation tool exported a .geojson with a non-standard or missing crs member. Use pyproj.CRS.from_user_input(gdf.crs) for a fuzzy match, or force gdf.crs = pyproj.CRS.from_epsg(4326) when the source CRS is known from project documentation.
dvc push transfers unchanged chunks repeatedly
: The remote is misconfigured with a path prefix that varies between runs (e.g. a date-stamped folder). Pin a stable url in .dvc/config and use dvc gc --cloud -w sparingly to prune truly unreferenced objects rather than deleting valid cache entries.
This workflow is one component of the broader Tracking Annotation Changes with SHA Hashing strategy for deterministic spatial data lineage.
Related
- Tracking Annotation Changes with SHA Hashing — parent: how SHA-256 digests surface annotation drift in geospatial datasets
- Implementing DVC for Geospatial Training Data — full DVC setup for spatial ML, from remote config to
.dvcfile conventions - Preserving Metadata Across Dataset Versions — embedding CRS, geometry type, and label schema into versioned manifests consumed by training scripts
- Rollback Strategies for Corrupted Spatial Datasets — recovering a known-good snapshot hash when a pipeline stage writes corrupt outputs
- Dataset Versioning & Spatial Data Sync — content-addressable storage, rollback, and sync strategies across annotation environments