OilTrace
U-Net oil-slick detection on Sentinel-1 radar, Smart India Hackathon 2026

Measured
| Metric | Value | Window | Source |
|---|---|---|---|
| Dice, vs 0.06 for the classical dark-spot threshold, 6× better | 0.35 | validation, traps included | ML-service/runs/E6_focal_iter/history.json |
| Object precision, up from 0.28 after the focal-loss fine-tune; raw was 0.045 | 0.47 | validation | ML-service/docs/judge_card.html |
| Object recall, slicks of 10 ha or more, max-IoU matching | 67% | validation | ML-service/docs/judge_card.html |
| Centroid error, about 8 px at 10 m resolution | 85 m | validation | ML-service/docs/judge_card.html |
| Inference, full Sentinel-1 scene on CPU | 10 s | ML-service/service/app.py, measured on the demo scene |
Winner, internal round, Smart India Hackathon 2026 (problem SIH26143); national round pending
Problem
Problem statement SIH26143, set by NTRO, asks for oil-spill detection and vessel attribution from satellite data. The detection half sounds easy: oil damps the small waves that scatter radar back to the satellite, so a slick shows up as a dark patch on a Sentinel-1 scene. The trouble is that the sea is full of dark things that are not oil: calm wind, algae blooms, rain cells, ship wakes, the lee of an island. A detector that flags every dark patch is useless to an investigator, because the failure mode of a coastguard tool is not missing obvious oil; it is crying wolf on an algae bloom. The exam that matters is rejecting the look-alikes.
My module is the detector and everything downstream of its output that a human sees: the FastAPI service, the six-stage investigation dashboard, and the technical report. Ved built the backward hindcast, Parth the backend orchestration.
From the repository
ML-service holds the training code (train.py, model.py, a sharded NPZ patch_dataset.py), five evaluation scripts (threshold sweep, confidence sweep, test-time augmentation, object-level matching, the classical baseline), eight recorded runs under runs/ each with a manifest and a per-epoch history, the FastAPI service, and the judge card that fixes how every number may be quoted. The backend has 23 tests.
Pipeline
A scene arrives as a two-channel GeoTIFF (VV and VH polarisation, sigma-nought in decibels). The service tiles it into 512-pixel patches, runs the U-Net on each with test-time augmentation, stitches the probability map back together, thresholds it, drops components below the minimum area or confidence, vectorises what is left, and returns WGS84 GeoJSON with a polygon, centroid, area and confidence per slick. The hindcast takes that polygon backward in time through OpenDrift to a probable source region; AIS vessels in that region and window are ranked; a forward counterfactual run per candidate checks whether that vessel could have produced the observed slick.
Training the detector
Data. About 2,500 real Sentinel-1 scenes, of which 685 are deliberate trap scenes with no oil. Scenes were split at scene level, never at patch level, so no patch of a validation scene ever appears in training. They were sharded into 16,926 training and 4,335 validation patches of 512 pixels, with an inventory and an error log per shard, because the first pipeline silently dropped scenes the reader could not open. A test set was sealed before the first experiment and has still not been evaluated.
Model. A plain U-Net, base width 32, batch normalisation, batch 6 with gradient accumulation of 4, Adam at 1e-4 for fine-tunes and 1e-3 from scratch. About eleven minutes per epoch on the one GPU we had.
Loss, and the runs that failed. The first runs used binary cross-entropy plus Dice. On a dataset that is 96% background, that combination has a stable attractor: predict nothing. E0_partial reported a Dice of 0.70 after its first epoch and then collapsed to 0.00, with precision 1.00 and recall 0.00, which is what “an empty mask is always safe” looks like in numbers. Fine-tuning from the checkpoint before the collapse gave a model that drew too much: recall 0.80, precision 0.17. Object-level error analysis showed why. The false positives were confidently wrong: look-alike regions predicted as oil at 80% confidence, which cross-entropy barely penalises once averaged over millions of pixels. Focal loss amplifies the gradient on exactly those pixels. The E5_focal_v2 fine-tune raised object precision from 0.28 to 0.47 while slick recall stayed within three points. A longer focal run, E6_focal_iter, collapsed the other way, precision 0.60 with recall 0.03, and was abandoned. All of it is in the git history, next to the run that worked.
Reporting. Scored the way most papers score, on oil-only patches, the model reaches about 0.75 Dice. We report 0.35 because that is the number with the traps in the validation set, and it is the one that predicts deployed behaviour. Every operating parameter (threshold, minimum area, confidence floor) was chosen on validation only.
Try it
Every recorded segmentation run, plotted from its own history file. The collapses are left in.
Pixel metrics on the held-out validation split with look-alike traps included, one point per epoch, exactly as logged by train.py. The headline Dice of 0.35 and object precision of 0.47 come from the evaluation sweeps on the E5-focal checkpoint (threshold_sweep.py, confidence_sweep.py, tta_eval.py, object_eval.py), which choose an operating point on validation only; the test set stays sealed. Hover a point for its full row.
Timeline
- E0_partial starts at 14:16: BCE plus Dice Dice 0.70 after epoch 1, then collapse to empty masks over the next nine.
- 00:21 E0, 01:26 E0_finetune fine-tune from the pre-collapse checkpoint; stable at Dice 0.28 to 0.30, recall high, precision low.
- 12:28 E5_focal, 19:13 E5_focal_v2: focal loss object precision 0.28 to 0.47, recall within three points.
- 21:17 E6_focal_iter seventeen epochs, collapsed to precision 0.60 and recall 0.03; abandoned.
- Classical baseline measured at Dice 0.06; judge card written every number fixed with its baseline and its caveat.
- Repository pushed: ML service, backend, dashboard, report FastAPI on Modal, React and Leaflet investigation UI.
- Dashboard replaced with the final investigation UI
Before and after
Service and dashboard
The detector runs behind FastAPI: POST /detect takes a GeoTIFF, tiles it, stitches the probability map, applies the threshold and minimum-area filters, and returns GeoJSON; GET / reports the checkpoint, epoch and full validation metrics so any result can be traced to a model version; GET /detect/demo serves a precomputed run so the dashboard works without a GPU. It was deployed on Modal for the hackathon and runs locally now.
The investigation dashboard, React and Leaflet, walks the six stages with map-synced particle clouds and a timeline driven only by backend timestamps, so the animation can never claim a time the model did not compute. The interface never says “culprit”; it says “probable source region”, because that is what the evidence supports. On the demo scene the measured slick area was 266.9 km² against 267.9 km² ground truth, and the centroid error across validation is a median of 85 metres, about eight pixels, which is enough to seed the hindcast.
One error class remains and the demo panel shows it rather than hiding it: of ten demo scenes, nine are clean or correct, and one very dark, structured look-alike still produces confident false positives. Curriculum training on that class is the first thing on the roadmap.
What I would do next
Evaluate the sealed test set exactly once when the design freezes, and publish that number here next to the validation one. Add a per-scene report so a judge can see which traps were rejected and why. Calibrate the confidence scores, so that “0.8” can mean something. Wire the detector back onto Modal with a warm-up so the demo does not cold start.
Learned: Put the look-alikes in the validation set or the number is a lie.