Skip to content

Repository files navigation

BhuMe Cadastral Boundary Correction

Automated correction of georeferencing drift in Maharashtra land-record plot boundaries, using satellite imagery and pre-computed field-boundary hints — no deep learning, no hand-edited geometry, no example-truth fitting.


The problem

Official plot outlines in Maharashtra's cadastral records were digitised from paper maps and sit metres off the true on-the-ground field edges. The drift is real, measurable, and systematic — but it is not uniform. A village-wide shift explains most of the error; a per-plot residual explains the rest. This project estimates and corrects both layers, assigns a calibrated confidence to every corrected plot, and flags the plots it cannot place.


Results on example truths

Village Plots Official IoU After correction Improvement Accurate (IoU ≥ 0.5)
Vadnerbhairav 2,457 0.612 0.864 +0.252 100%
Malatavadi 2,508 0.510 0.810 +0.300 50% (1 plot flagged)

Malatavadi's weakest-evidence plot is deliberately flagged rather than corrected with low confidence — restraint is scored.


Approach

The correction pipeline runs in two stages, both driven entirely by raster evidence. No example truths are read during estimation.

Stage 1 — Global drift estimation (bhume/global_drift.py)

The entire village shares a common georeferencing origin, so the bulk of the error is a single translation. We find it by searching every integer-pixel shift within ±30 m and scoring each candidate by how well the official boundary pixels align with two independent signals:

  • Boundary-hint affinity — distance transform on the pre-computed boundaries.tif, converted to an exponential affinity surface (exp(-distance / 5 m)).
  • Imagery gradient — Sobel magnitude on the satellite mosaic resampled to the hint grid, normalised to [0, 1].

Both score surfaces are z-score-normalised and averaged. The composite optimum is selected with deterministic tie-breaking (smallest shift magnitude). The two independent optima (hint-only and imagery-only) are compared to produce a signal-agreement diagnostic — when they disagree, confidence is suppressed.

Estimated shifts:

Village dx (UTM) dy (UTM) Signal agreement
Vadnerbhairav −6.65 m +13.41 m 2.39 m
Malatavadi +8.08 m +12.52 m 4.92 m

Stage 2 — Per-plot local refinement (bhume/local_search.py)

The global estimate is a village-wide prior. Individual plots have residual displacement on top of it. For each plot:

  1. Translate the official geometry by the global drift.
  2. Crop a raster patch around the globally-shifted plot bounding box (±30 m padding + ±15 m local search radius).
  3. Score all integer-pixel candidates within ±15 m using the same hint-affinity and imagery-gradient signals computed per-patch.
  4. Record the composite gain over the zero-shift candidate, peak separatedness, hint/imagery optimum agreement, and boundary pixel count.

The zero-shift candidate is always included, so the search can return "no local refinement" — restraint is built in structurally.

Stage 3 — Confidence scoring and flagging (bhume/confidence.py)

Four diagnostics from the local search are combined into a single confidence score in [0, 1]:

Signal Weight Rationale
Composite gain over zero shift 35% Larger gain = more trustworthy correction
Peak separatedness (margin) 25% Unambiguous optimum = reliable alignment
Hint/imagery optimum agreement 25% Consistent evidence from two independent sources
Shift magnitude penalty 15% Very large shifts are penalised

Plots with confidence < 0.35 are marked flagged and returned at their official geometry. Every corrected prediction carries a method_note with all four sub-scores for full explainability.


Project structure

bhume/
  io.py              # load(), patch_for_plot(), write_predictions()
  geo.py             # CRS helpers, raster windowing
  baseline.py        # global_median_shift() — naive floor to beat
  global_drift.py    # Stage 1: village-wide translation search
  local_search.py    # Stage 2: per-plot local translation refinement
  confidence.py      # Stage 3: confidence model and flagging logic
  pipeline.py        # End-to-end orchestration
  score.py           # Self-scoring against example truths (IoU, calibration, restraint)
  visualize.py       # Debug visualisation toolkit
  truth_analysis.py  # Example-truth diagnostics

docs/               # Phase-by-phase experiment log (01–10)
outputs/
  analysis/         # global_shift_estimate.json, global_shift_surface.csv
  debug/            # Per-plot visualisation PNGs

predict.py          # CLI: generate predictions.geojson
evaluate.py         # CLI: score predictions against example truths
estimate_global_drift.py  # CLI: estimate and save village drift
quickstart.py       # 15-line worked example (load → score)

Setup

Requires uv. Install it once, then:

uv sync

This reads pyproject.toml / uv.lock, installs Python 3.12, and sets up a local .venv with geopandas, rasterio, shapely, numpy, scipy, and pillow. No system GDAL installation needed — wheels bundle it.


Running the pipeline

Quickstart (worked example)

uv run quickstart.py data/vadnerbhairav

Runs the global-median-shift baseline, writes predictions.geojson, and prints the score — the floor to beat.

Full pipeline (recommended)

# Step 1: estimate and save the global drift (skip if already in outputs/)
uv run estimate_global_drift.py data/vadnerbhairav data/malatavadi

# Step 2: run local refinement + confidence scoring + write predictions
uv run predict.py data/vadnerbhairav data/malatavadi \
    --load-drift-from outputs/analysis

# Step 3: evaluate against example truths
uv run evaluate.py data/vadnerbhairav data/malatavadi

Options

predict.py options:
  --load-drift-from DIR       Load pre-computed drift from outputs/analysis (recommended)
  --search-radius-m FLOAT     Local search radius (default: 15.0 m)
  --step-size-m FLOAT         Candidate step size (default: 1.0 m, use 3–4 for speed)
  --confidence-threshold F    Flag below this confidence (default: 0.35)
  --no-local-search           Apply global drift only; skip per-plot refinement
  --verbose                   Show per-village timing and statistics

evaluate.py options:
  --output-dir DIR            Write scorecard JSON here
  --predictions-path PATH     Evaluate a specific predictions file

Example: conservative flagging

uv run predict.py data/vadnerbhairav --confidence-threshold 0.45

Example: global-drift-only ablation

uv run predict.py data/vadnerbhairav --no-local-search

Design decisions

No example truths during estimation. All thresholds, weights, and scoring parameters are derived from raster evidence or first principles. The nine public example truths are used only to report IoU after the fact — they do not appear in estimate_global_drift.py, local_search.py, or confidence.py.

Zero-shift is always a candidate. The local search includes (0, 0) in its candidate set. If the raster evidence genuinely prefers no shift, the pipeline returns no shift and flags accordingly. This prevents the algorithm from imposing corrections on already-correct plots.

Two independent signals, not one. Hint affinity and imagery gradient are scored separately and compared. When they disagree, the confidence agreement_sub sub-score decays, suppressing false corrections in areas where either signal is unreliable.

Explainability first. Every corrected prediction includes a method_note like:

corrected | gain=3.69 | margin=0.96 | agree=7.2m | shift=21.2m | conf=0.85

This gives full visibility into why a particular plot was corrected at a particular confidence.


Performance

~77 ms per plot on a single CPU core (step_size_m=3). Full village of 2,457 plots: approximately 3 minutes. The iterator-based architecture (search_village_plots) streams results without holding all patches in memory; parallelisation via ThreadPoolExecutor is straightforward for production use.


Limitations

  • The search is a discrete pixel grid. Sub-pixel accuracy requires fitting a 2D quadratic to the score surface near the peak.
  • Very small plots (< 4 pixels across at raster resolution) are skipped and flagged.
  • The correction family is translation-only. Plots with true rotational or shape deformation will be flagged rather than corrected.
  • Confidence scores are evidence-strength proxies, not calibrated probabilities. Proper calibration requires a larger labelled set than the nine public example truths provide.
  • The global estimate assumes one dominant drift direction per village. Zones of locally differing drift are handled by the local search but will have reduced confidence.

Dependencies

Package Version Purpose
geopandas ≥ 1.0 Geospatial dataframe I/O and CRS handling
rasterio ≥ 1.3 GeoTIFF reading, windowing, rasterisation
shapely ≥ 2.0 Geometry operations, affine translation
numpy ≥ 1.24 Array arithmetic and candidate scoring
scipy ≥ 1.10 Distance transforms, Sobel gradient, Spearman rank
pillow ≥ 10.0 Debug image output

All dependencies are pinned in uv.lock. Install with uv sync.

About

Automated correction of georeferencing drift in Maharashtra land-record plot boundaries, using satellite imagery and pre-computed field-boundary hints no deep learning, no hand-edited geometry, no example-truth fitting.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages