
The Analyst's Stack Part 8 of 22
Finding dark vessels with satellite imagery
A tool that reads satellite imagery for ship-shaped objects, then asks the AIS record which of them were transmitting — and flags the ones that were not.
Why I built it
Ships broadcast where they are. Most of the time.
AIS (the transponder system vessels use to broadcast identity and position to anyone listening; it can be switched off from the bridge) is how you know what is where at sea. It works right up to the moment someone would rather you did not know, and then it stops silently. A switched-off transponder leaves no gap and no error. Just a ship that is no longer in the feed.
So the thing you want to find is defined by its absence from the only dataset you have. You need a second look that does not depend on the vessel agreeing to be seen, and a satellite is that look.
By hand it is miserable: pull a scene, stare until bright specks resolve into something ship-shaped, note coordinates, switch to an AIS tool, type each position in, line up timestamps, repeat. One scene is an afternoon. A month of scenes is not a task anybody finishes.
What it solves
It turns the afternoon into a list.
You draw an area, pick a scene, and the machine does the staring. It finds the bright spots, drops the ones that are land or the wrong size for a ship, cuts a small image chip of each survivor, and asks the AIS record whether anything was transmitting from that position at that moment. Every row gets a verdict on the AIS record, never on the ship: AIS match, possible AIS match, unmatched with AIS nearby, plain unmatched, or no AIS coverage when there was no AIS data to check against.
The filters are blunt on purpose. Anything working out shorter than 90 metres or longer than 500 metres is dropped, as is anything within 150 metres of the shoreline — a coastline is violently bright to radar and will otherwise fill your list with rocks. Losing small craft is a real cost, paid knowingly.
The ceiling sits above the longest ships afloat, about 400 metres, because radar reads a hull long. A study of 127 Sentinel-1 ships checked against AIS (Stasolla and Greidanus, Remote Sensing Letters, 2016) found lengths about 15% out on average, around 30 metres, mostly on the long side, so a 400-metre hull can read about 460 and a 400-metre cutoff would lose the largest real ships.
Its synthetic demo shows the map, labels and detection cards before you hold any provider credentials; running detection needs a fetched scene.

How it works
Search queries several imagery archives at once against your area and dates. Optical imagery is a photograph: easy to read, useless under cloud or after dark. SAR (radar imaging, where the satellite sends its own pulse and measures what comes back, so it sees through cloud and at night, at the cost of looking nothing like a photograph) has neither problem.
Fetch downloads one scene and cuts the raster into 4096×4096 pixel tiles with a 64-pixel overlap, so a vessel on a tile boundary is not quietly sliced in half.
Detect runs the pipeline that suits the sensor — a water index and connected components for optical, a speckle filter and then CFAR (a detection rule that sets its threshold from the background immediately around each pixel, so a bright spot is judged against its own neighbourhood rather than one fixed brightness for the whole scene) for radar. Sea clutter varies enormously across a single scene, so a fixed threshold goes blind in the rough part and drowns in false alarms in the calm part.
Cross-check looks for AIS positions within 8 kilometres and 2 hours of each surviving detection, projects each nearby track to the moment of the image, and scores the best fit; the score decays with distance and with time. Nothing nearby earns unmatched, but only where AIS data was loaded; with no data at all the row reads no AIS coverage, because an empty feed proves nothing.
Label and map put it in front of a person, with a control that records an analyst’s judgement next to the machine’s.


How I built it
The back end is Python 3.12 on FastAPI with Postgres and PostGIS underneath, the front end React with MapLibre, all of it under Docker Compose.
PostGIS was the decision that kept paying. The cross-check is a spatial-and-temporal question — what was near here, around then — and once the database answers that natively, the join is a query instead of a pile of loops.
The hard part was not the detection maths. A real scene is large and the download is slow, so the work has to survive being long-running, and the front end needed a stall detector before it behaved: a job that has stopped looks identical to a job being thorough.
Credentials were the other tax. A public archive turned out to use 2 authentication systems that do not accept each other’s tokens, failing a long way from the cause. That cost a day.
What I would do differently: the detection constants are compiled in. Threshold multiplier, ring sizes, chip dimensions — tuned once against the scenes I had. That was right for getting it working; it is now the main thing between this tool and a sea state it has not met.

A look at the code
The detection rule, rewritten to stand alone. It flags a pixel far brighter than the ring of background around it.
import numpy as np
from scipy.signal import fftconvolve
def ca_cfar(image, k=4.75, guard=4, training=16):
"""Flag pixels much brighter than their own local background.
guard and training are radii in pixels. A (2*guard+1) box around each
pixel is excluded from the statistics; the ring outside it, out to
guard+training, is the background estimate. With the defaults that is
a 9x9 guard box inside a 41x41 window.
"""
outer = 2 * (guard + training) + 1
inner = 2 * guard + 1
ring = np.ones((outer, outer), dtype=np.float32)
pad = (outer - inner) // 2
ring[pad:pad + inner, pad:pad + inner] = 0.0
n = float(ring.sum())
mean = fftconvolve(image, ring, mode="same") / n
mean_sq = fftconvolve(image ** 2, ring, mode="same") / n
std = np.sqrt(np.clip(mean_sq - mean ** 2, 0.0, None))
return image > mean + k * std
The cross-check is smaller than people expect. A score that decays with distance and with elapsed time, and 5 labels cut out of it. The first 150 metres are free, because positions wobble (the real tool allows more on radar, which shifts a moving ship).
import math
def match_score(metres_off, seconds_apart):
"""1.0 is perfect. The first 150 m are free; then it falls with metres and seconds."""
beyond_slack = max(0.0, metres_off - 150)
return math.exp(-beyond_slack / 500) * math.exp(-abs(seconds_apart) / 1800)
def label(score, ais_nearby, ais_loaded):
if score > 0.7:
return "AIS match"
if score >= 0.3:
return "Possible AIS match"
if ais_nearby:
return "Unmatched (AIS nearby)"
if not ais_loaded:
return "No AIS coverage"
return "Unmatched"
# 400 m and 5 minutes from where the track puts the ship: close, not conclusive.
print(round(match_score(400, 300), 2)) # 0.51
print(label(match_score(400, 300), True, True)) # Possible AIS match
print(label(0.0, ais_nearby=False, ais_loaded=True)) # Unmatched
print(label(0.0, ais_nearby=False, ais_loaded=False)) # No AIS coverage
Those thresholds are a judgement, not a measurement. Move 0.3 and you move the boundary between a human looks at this and nobody does.
What it can’t do
A detection is a lead, not a finding
“Vessel in the imagery, nothing in AIS” has several dull explanations before it has an interesting one. Reception is not uniform — a satellite receiver has gaps, a terrestrial one has range. The scene and the nearest ping can be minutes apart, and minutes at sea are hundreds of metres. Plenty of small craft are not required to carry a transponder at all. And the detector is fallible: a breaking wave, a fixed platform, a sandbank in shallow water.
So the tool never calls anything dark. Dark candidate is a mark only you can set, only on a plain unmatched detection where AIS data was loaded: hull-shaped, not a fixture or a radar ghost (a false echo beside a bright target), and big enough that it should have been transmitting. Even then it means worth an hour of your attention, not that a ship was hiding, and nothing that leaves this tool is publishable on its own.
Fixed where it should be tunable, and narrow in what it accepts

Where this sits in the series
This is stage 4 of the 6-stage spine doing its whole job. Collection is a satellite archive, storage is a spatial database, and all the interesting work is in the narrowing.
It is also the cleanest example I have of the argument in where AI actually fits in OSINT. Machine detection here does one thing: it turns a pile you cannot look at into a pile you can. It does not conclude that a ship was running dark. It hands you a chip, a coordinate, a timestamp and an absence, then gets out of the way.
Detection is a scoring step, and a scoring step you have not measured is a scoring step you are guessing about. Does your AI step actually work? is the discipline that turns “it seems to find them” into a number, including what to do when tightening the threshold makes things worse rather than better.