The labelling trick
The obvious blocker for a segmentation model is training masks, and hand-drawing rooftop masks for thousands of tiles is exactly the work we were trying to avoid.
But Okra already had years of operational data: villages where field teams had tagged every household by hand, stored as map pins. So rather than labelling anything new, I pulled each of those areas twice, once with the tags rendered on the map and once without, then derived the mask from the difference between the two images. An existing operational dataset became a labelled segmentation corpus for free.
The masks that come out of this are not precise roof outlines. A tag sits at whatever point on the roof the person happened to click. That imprecision shows up later as the reason the probability threshold has to be tuned low.


The model
U-Net with a ResNet-34 encoder, transfer-learned from ImageNet in fastai. Binary segmentation, two classes. Trained in the standard two-stage shape: freeze the encoder and fit the decoder with one-cycle, then unfreeze and fine-tune the whole network with discriminative learning rates across the layer groups.
Augmentation mattered more than usual, because satellite tiles have no canonical orientation and lighting varies enormously between captures. Vertical flips as well as horizontal, zoom to 1.5×, warp and lighting jitter, applied aggressively.
From mask to map pins
A probability mask is not an answer. The pipeline that turns it into something a field team can use:
- Threshold the probability map at
p, low (around 0.15–0.30) because the training tags were single imprecise points rather than full roof outlines. - Run connected-component labelling over the thresholded mask to separate it into discrete blobs, one per candidate rooftop.
- Filter blobs by pixel area, with both a floor and a ceiling. The floor drops isolated speckle; the ceiling drops the failure mode where several adjacent roofs merge into one region, or a road gets picked up.
- Take each surviving blob's centroid and convert pixel coordinates back to lat/long through the Mercator projection for that zoom level.
- Emit KML, chunked so no single file is too large for the tools downstream.
Coverage comes from tiling: give the script a bounding box and it walks it in 600×600 tiles with a small overlap factor, so nothing falls through a seam.
Tuning it honestly
Two parameters, probability threshold and minimum blob area, control the whole precision/recall trade-off, and eyeballing overlays is not a way to choose them. So the notebook sweeps a grid of both across the validation set and scores each combination with a symmetric nearest-neighbour distance: for every true point, the distance to the closest predicted point, and for every predicted point, the distance to the closest true one, via a KD-tree.
Both directions are needed, and that is the whole point. Score only predicted-to-true and a model that finds three houses in a village of two hundred looks perfect. Score only true-to-predicted and one that carpets the tile in predictions looks perfect. Summing both, plus the squared variants to punish badly-placed points harder than slightly-off ones, gives a single number per parameter pair that neither failure mode can game. The sweep dumps to CSV and the production settings were read off it.
What it was actually for
Not replacing the humans. The workflow was: run the script over a bounding box, get KMLs, send those to contractors for QC. The model does the pass that costs the most and is the most boring, and a person checks the output before it reaches a network design.
That framing, where the model drafts, a named human verifies and the expensive judgement stays with the person, is the same shape as the agent work I build now, six years later. It was the right answer then for the same reason it is now: the cost of a wrong rooftop is not zero, so the system has to make it cheap to catch.
Where it fell down
Training data came overwhelmingly from Cambodia and the Philippines, which is where the historical tagging existed. Performance degraded visibly on Nigerian villages: different roofing materials, different building density, different colour response in the imagery. The model had learned "roof in Southeast Asia", not "roof". The planned fix was contracted tagging in the under-represented regions to broaden the corpus, which is the unglamorous answer and also the correct one.
Built and trained on Google Maps satellite tiles, pulled through the Static Maps API at zoom 19–20. Imagery © Google. Built at Okra Solar in 2020 on fastai v1.
I work with startups on applied LLM systems, ops automation, and getting whole teams comfortable with AI. Reach me at msmart840@gmail.com.