This repository contains my solution to the BhuMe engineering take-home: for each cadastral plot in a village, decide whether the official (georeferenced) boundary can be nudged onto the real field visible in satellite imagery — and if so, where it should go and how confident the correction is.
Maharashtra's village land maps were originally hand-drawn at scales of 1:4,000–1:10,000 and later georeferenced onto satellite imagery by MRSAC. Because the original drawings were never tied to GPS, each plot's official outline sits a few metres away from the real field on the ground — a drift. The task is to look at the satellite image for each plot and:
- Correct it (move/reshape the outline onto the real field) when there's enough evidence to do so confidently, or
- Flag it (leave the official shape alone) when the evidence is weak, ambiguous, or the problem looks like an area mismatch rather than a placement mismatch (moving an outline can't fix a shape that disagrees with the recorded area).
The two error types worth distinguishing:
| Kind of wrong | What it means | Fixable by moving? |
|---|---|---|
| Placement | Shape is roughly right, sitting in the wrong spot | Yes |
| Area | Drawn shape disagrees with the recorded 7/12 area | No |
A quick signal: drawn area ÷ recorded area near 1.0 suggests a placement problem; far from
1.0 suggests an area/record problem.
| Tool / Library | Used for |
|---|---|
| Python 3.12 | Core implementation language |
| uv | Dependency management and environment setup (uv sync, uv run) |
| geopandas | Tabular handling of plot geometries (read/write GeoJSON, CRS reprojection) |
| shapely | Geometry operations — translation, validity checks, boundary point sampling |
| rasterio | Reading GeoTIFF imagery and boundary-hint rasters, windowed reads, WarpedVRT reprojection |
| pyproj | Coordinate transforms between EPSG:4326 (lon/lat) and EPSG:3857 (imagery) |
| numpy | Array/grid maths — distance maps, candidate-offset scoring |
| scipy | sobel edge detection, distance_transform_edt, spearmanr for calibration scoring |
| pillow (PIL) | Saving the example image patch in quickstart.py |
| GeoJSON / GeoTIFF | Input plot outlines + output predictions (GeoJSON); satellite imagery + boundary hints (GeoTIFF) |
| EPSG:4326 / EPSG:3857 | Coordinate systems for plots/output (lon, lat) and imagery (web-mercator metres) respectively |
Each village bundle contains:
| File | Contents | Role |
|---|---|---|
input.geojson |
Official plot outlines (EPSG:4326), plot_number, recorded areas, survey/holding breakdown |
Starting geometry — what gets transformed |
imagery.tif |
Satellite photo of the village (EPSG:3857) | Primary signal — ground truth reference |
boundaries.tif |
Auto-detected (rough) field edges | Secondary hint — strong on open fields, weak under tree cover/buildings |
example_truths.geojson |
A handful of hand-checked correct boundaries | Self-scoring only (not used to fit the model) |
Villages used:
| Village | District | Plots | Extent | Median plot | Image detail |
|---|---|---|---|---|---|
| Vadnerbhairav | Nashik | 2,457 | 54.2 km² | 7,753 m² | ~1.2 m/px |
| Malatavadi | Kolhapur | 2,508 | 5.8 km² | 872 m² | ~0.6 m/px |
The provided package handles all the geospatial plumbing so effort goes into the alignment logic itself:
load(village_dir)— loads plots + imagery + boundary hints + example truths into aVillage, with CRS handling already sorted (plots in EPSG:4326, imagery in EPSG:3857).patch_for_plot(src, geom)— crops the satellite image to the area around one plot.lonlat_to_pixel/pixel_to_lonlat— convert between map coordinates and image pixels.write_predictions(path, gdf)— writes a contract-validpredictions.geojson.score(preds, village)— runs the same accuracy / calibration / restraint checks used for grading, against the public example truths (a rough, directional self-check only).global_median_shift(village)— the naive baseline: estimates one translation from the example truths and applies it to every plot. Helps a lot of plots at once (most drift is a coherent per-village offset) but misses anything that drifted differently (rotation, local stretch, outliers) — that gap is the floor this solution tries to beat.
build_predictions(village) aligns every plot independently using the satellite imagery as the
primary signal and the rough boundary hints as a secondary nudge.
For both the imagery and the boundary-hint raster, a distance-to-edge map is built:
- Convert the imagery to grayscale and run a Sobel gradient to find strong edges (top ~16% of gradient magnitude).
- If
boundaries.tifis available, treat its nonzero pixels as a second edge layer. - For each layer, compute a Euclidean distance transform so every pixel knows how far it is (in metres) from the nearest detected edge.
Before touching individual plots, a coarse village-wide shift is estimated: a sample of ~240 plots has its boundary points tested against a grid of candidate translations (±30 m, 3 m steps), scored by how well each candidate's edges line up with the imagery/boundary distance maps. The offset that scores best on average becomes the prior — this captures the "single coherent drift" that the median-shift baseline also exploits, but derived purely from raster evidence (never from the example truths).
For each plot:
- Sample its boundary (exterior + interior rings) at ~2.5 m spacing.
- Search a local grid of translations centred on the village prior (default ±18 m radius, 3 m step).
- Score each candidate offset as a weighted blend of imagery-edge support (65%) and boundary-hint support (35%) — both expressed as both a "fraction of points within 3.6 m of an edge" (support) and a smooth Gaussian-decay score.
- Pick the best-scoring offset and measure:
- Improvement — how much better the best offset scores than no shift at all.
- Ambiguity margin — how much the best score beats the 90th-percentile score (a sharp winner vs. a flat, ambiguous landscape).
- Area reliability — how well the plot's drawn area matches its recorded area
(
recorded_area_sqm+pot_kharaba_ha), viaexp(-|log(drawn/recorded)|). This is the placement-vs-area signal from the problem write-up: plots whose shape disagrees with the record are penalised even if the imagery search finds some offset.
A plot is corrected only if all of:
- imagery support ≥ 0.10 (some real edge agreement, not noise)
- improvement ≥ 0.012 (the found offset is meaningfully better than doing nothing)
- shift magnitude ≤ 35 m (rules out runaway/implausible jumps)
- area reliability ≥ 0.45 (the shape roughly agrees with the recorded area — a placement problem, not an area problem)
Otherwise the plot is flagged and the original geometry is kept unchanged — including plots with too few boundary points to evaluate at all.
For corrected plots, confidence is a weighted, clipped combination of:
confidence = clip(
0.05
+ 0.45 * imagery_support
+ 0.20 * boundary_support
+ 1.8 * max(ambiguity_margin, 0)
+ 1.2 * max(improvement, 0)
+ 0.10 * area_reliability,
0, 0.95
)
The intent: confidence should be high only when the imagery edges strongly and unambiguously
agree with the chosen offset, the shift represents a real improvement, and the area record is
consistent with the shape. Flagged plots get confidence 0.0. This is what the calibration
metric (Spearman / AUC of confidence vs. IoU) checks — a flat or random confidence scores ~0.5
(useless); the goal is for high-confidence corrections to actually be the most accurate ones.
Every prediction carries a short note recording either the applied shift and the imagery/hint support that justified it, or the reason a plot was flagged (too few points, or weak/ambiguous/area-inconsistent evidence).
For each village attempted, predictions.geojson is written to
data/<village>/predictions.geojson — a GeoJSON FeatureCollection in EPSG:4326 with:
| Field | Required | Meaning |
|---|---|---|
plot_number |
always | Copied exactly from the input |
status |
always | "corrected" or "flagged" |
confidence |
if corrected | 0–1, calibrated against likely accuracy |
method_note |
optional | Brief explanation of the shift or the flag reason |
geometry |
always | New boundary if corrected, original if flagged |
uv sync
uv run quickstart.py data/34855_vadnerbhairav_chandavad_nashik # worked example, 1 plot + score
uv run main.py data/34855_vadnerbhairav_chandavad_nashik # full village run + self-scoreOptional tuning flags on main.py:
uv run main.py data/<village> --radius 18 --step 3--radius is the local search radius (metres) around the village prior shift; --step is the
translation grid step (metres).
score() reports median IoU (predicted vs. official, vs. the example truths), median centroid
error, the fraction of corrected plots that improved, and calibration (Spearman correlation and
AUC of confidence vs. accuracy). These numbers are computed over only a handful of public
example truths (6 for Vadnerbhairav, 3 for Malatavadi) and are a rough directional check
only — the real evaluation uses a larger hidden set, and the method was deliberately not
tuned against the public examples to avoid overfitting to them.
Known limitations / honest caveats:
- The boundary hints (
boundaries.tif) are noisy and largely unreliable under tree cover or near buildings; the imagery edge signal is weighted more heavily (65/35) for this reason. - Plots with very few boundary vertices (after simplification) can't be reliably searched and are flagged outright.
- The area-reliability check is a heuristic — it doesn't distinguish why a plot's drawn and recorded areas disagree (subdivision after the original survey, digitisation error, or a genuinely wrong shape); it simply treats large disagreement as a reason for restraint.
- The same single method (no per-village hand-tuning) is applied to both villages, in line with the "generalises" criterion in the rubric.
.
├── bhume/
│ ├── __init__.py # package exports
│ ├── io.py # load/write village bundles, predictions
│ ├── geo.py # CRS handling, imagery patches, pixel<->lonlat
│ ├── baseline.py # naive global median-shift baseline
│ ├── score.py # self-scoring against example truths
│ └── solution.py # this submission's alignment method
├── data/
│ └── <village>/
│ ├── input.geojson
│ ├── imagery.tif
│ ├── boundaries.tif
│ ├── example_truths.geojson
│ └── predictions.geojson # generated output
├── main.py # CLI entry point: load -> predict -> write -> score
├── quickstart.py # short worked example (load -> patch -> baseline -> score)
└── transcripts/ # AI chat transcripts used during this take-home
AI tools were used throughout this take-home — both to understand the problem (reviewing the
assignment write-up, glossary, and scoring rubric) and to develop and refine the alignment
method in bhume/solution.py. Full transcripts are included under /transcripts
(see transcripts/README.md for any web-chat share links).