<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Salaheldinaz</title><link>https://salaheldinaz.com/</link><description>OSINT · Intelligence · Cyber Security · Automation · Technologist · Investigator</description><language>en-US</language><lastBuildDate>Sat, 24 Oct 2026 08:04:50 +0000</lastBuildDate><atom:link href="https://salaheldinaz.com/tags/satellite/" rel="self" type="application/rss+xml"/><item><title>Finding dark vessels with satellite imagery</title><link>https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/</link><guid isPermaLink="true">https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/</guid><pubDate>Sat, 24 Oct 2026 00:00:00 +0000</pubDate><description>A ship that switches off its transponder disappears from the data you have. Satellite detection plus an AIS cross-check gives you a second look that does not depend on the ship's cooperation.</description><category>osint</category><category>geoint</category><category>satellite</category><category>maritime</category><category>automation</category><category>project</category><content:encoded><![CDATA[<span class="hero-label not-prose">The Analyst&#39;s Stack · Showcase</span>

<p>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.</p>
<h2 id="why-i-built-it">Why I built it</h2>
<p>Ships broadcast where they are. Most of the time.</p>
<p>AIS <em>(the transponder system vessels use to broadcast identity and position to anyone
listening; it can be switched off from the bridge)</em> 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.</p>
<p>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.</p>
<p>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.</p>
<h2 id="what-it-solves">What it solves</h2>
<p>It turns the afternoon into a list.</p>
<p>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 <em>no AIS coverage</em> when
there was no AIS data to check against.</p>
<p>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.</p>
<p>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,
<em>Remote Sensing Letters</em>, 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.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-sizeband_light.svg" width="900" height="240" alt="A length scale from 0 to 600 metres. Detections under 90 metres are dropped, small craft knowingly lost; detections over 500 metres are dropped as the wrong size for a ship; only the band from 90 to 500 metres is kept, highlighted. A note says anything within 150 metres of the shoreline is dropped as well, whatever its length." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-sizeband_dark.svg" width="900" height="240" alt="A length scale from 0 to 600 metres. Detections under 90 metres are dropped, small craft knowingly lost; detections over 500 metres are dropped as the wrong size for a ship; only the band from 90 to 500 metres is kept, highlighted. A note says anything within 150 metres of the shoreline is dropped as well, whatever its length." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The filter, to scale.</strong> Everything outside the highlighted band never reaches the cross-check, whatever it was.</figcaption>
</figure>

<p>Its synthetic demo shows the map, labels and detection cards before you hold any provider
credentials; running detection needs a fetched scene.</p>
<p><img src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-1-map_hu_87998d27d13913a8.webp" width="756" height="780" alt="A map of the southern Red Sea off Assab, Eritrea, with a square Sentinel-2 scene of 6 October 2026 laid over the basemap: the Eritrean coast and desert on the left, open water in the middle, the Yemeni shore at the top right. Coloured AIS tracks run down a shipping lane from the top of the scene to its south-east corner. Green AIS match rings sit on those tracks, purple Unmatched, AIS nearby rings lie beside them, and grey Unmatched rings dot the open water and cluster among the islands and shallows south-east of the coast. A magnified legend lists the ring colours: AIS match, Possible AIS match, Unmatched, AIS nearby, Unmatched, No AIS coverage, Pending, Analyst: false positive and Analyst confirmed, then the scene, S2C, 2026-10-06 07:46 UTC." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The view you actually work in.</strong> The labels are where the work starts, not where it
ends. One of my own projects, on a real Sentinel-2 scene of the southern Red Sea off Assab, Eritrea, 6 October 2026 at 07:46 UTC, checked against 465 AIS positions from 18 transmitters within 2 hours of it: 121 detections. The 4 green rings on the shipping lane are AIS matches, the purple ones are unmatched with AIS nearby, and the grey ones are plain unmatched, most of them among the islands and reefs. The map hides a few points in cloud or of low contrast; the legend is magnified. Contains modified Copernicus Sentinel data 2026; basemap © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>. The legend&rsquo;s 2 analyst rows are other marks: <em>dark candidate</em> is set from a row&rsquo;s menu and shows as a <em>Dark candidate (analyst)</em> chip on that row.</div>

<h2 id="how-it-works">How it works</h2>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-pipeline_light.svg" width="1160" height="190" alt="Six stages left to right: Search across optical and radar sources, Fetch one scene and tile it, Detect bright spots against their local background, Cross-check against AIS within 2 hours, Label as a match or unmatched, Map for a human to decide. Cross-check is highlighted." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-pipeline_dark.svg" width="1160" height="190" alt="Six stages left to right: Search across optical and radar sources, Fetch one scene and tile it, Detect bright spots against their local background, Cross-check against AIS within 2 hours, Label as a match or unmatched, Map for a human to decide. Cross-check is highlighted." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The spine, wearing a hat.</strong> Six boxes, but detect and cross-check are both <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">stage 4</a> — narrowing, not deciding. Cross-check is highlighted because detection on its own only tells you there is a ship.</figcaption>
</figure>

<p><strong>Search</strong> 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 <em>(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)</em> has neither
problem.</p>
<p><strong>Fetch</strong> 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.</p>
<p><strong>Detect</strong> runs the pipeline that suits the sensor — a water index and connected components
for optical, a speckle filter and then CFAR <em>(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)</em> 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.</p>
<p><strong>Cross-check</strong> 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 <em>unmatched</em>,
but only where AIS data was loaded; with no data at all the row reads <em>no AIS coverage</em>,
because an empty feed proves nothing.</p>
<p><strong>Label and map</strong> put it in front of a person, with a control that records an analyst&rsquo;s
judgement next to the machine&rsquo;s.</p>
<p><img src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-2-detection-card_hu_ebbe377074ef1205.webp" width="756" height="1141" alt="A detection card, magnified. The heading Broadcasting as HANG FENG, the line identity from AIS broadcast, not a confirmed hull, a green AIS MATCH chip and a medium chip, beside a small image chip of a bright hull on rippled water. Below: length × beam 158 m (138–178) × 47 m (measured), L/B 3.3, heading 342°, class Hull 120-180 m (rule), confidence 0.79 (detector rule), position 13.0883, 43.1473, image time 2026-10-06 07:46 UTC, S2C. The AIS section reads Broadcasting as HANG FENG (339301930), 55 m away, 26 s from scene time; source: MarineTraffic pull, then position interpolated between pings 6 min apart, slack 150 m, possible movement since its report up to 18 m, AIS length 156 m, and an Open in MarineTraffic link. Collapsed sections for MarineTraffic, AI check, Evidence &amp; checks (5 to review) and Notes &amp; history, and buttons for Confirm vessel, Not a vessel, Check with AI, Edit details, Reverse heading (180°), Copy brief and More." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>One row, expanded.</strong> A label that says <em>AIS match</em> beside a chip that says <em>sandbank</em> is a
disagreement worth seeing early. Here the two agree: the return measures 158 metres on the image, and the ship broadcasting as HANG FENG, 55 metres away and 26 seconds from the image, gives its own length as 156. The card still says <em>broadcasting as</em>, not <em>is</em>: the name comes from the transponder, not the hull. A real detection on the shipping lane east of Assab, from the same scene. Card magnified for legibility.</div>

<p><img src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-2b-detection-card_hu_2a29825ff2c31389.webp" width="756" height="1141" alt="A second detection card, magnified, in the same layout. The heading Broadcasting as COSCO SHIPPING PLANET, identity from AIS broadcast, not a confirmed hull, a green AIS MATCH chip and a medium chip, beside an image chip of a long bright hull. Length × beam 421 m (401–441) × 80 m (measured), L/B 5.2, heading 155°, class Hull over 300 m (rule), confidence 0.53 (detector rule), position 13.0832, 43.1069, image time 2026-10-06 07:46 UTC, S2C. The AIS section reads Broadcasting as COSCO SHIPPING PLANET (477345900), 92 m away, 7 s from scene time; source: MarineTraffic pull, then position interpolated between pings 3 min apart, slack 150 m, possible movement since its report up to 6 m, AIS length 400 m, and an Open in MarineTraffic link, above the same collapsed sections and buttons." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The long end of the band.</strong> A return measured at 421 metres, 401 to 441, against the 400 metres the ship broadcasting as COSCO SHIPPING PLANET gives for itself, 92 metres away and 7 seconds from the image. Even on an optical image it reads 21 metres long, and a 400-metre ceiling would have dropped one of the largest ships afloat. Same scene, a few kilometres west of HANG FENG. Card magnified for legibility.</div>

<h2 id="how-i-built-it">How I built it</h2>
<p>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.</p>
<p>PostGIS was the decision that kept paying. The cross-check is a spatial-and-temporal question
— <em>what was near here, around then</em> — and once the database answers that natively, the join
is a query instead of a pile of loops.</p>
<p>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.</p>
<p>Credentials were the other tax. A public archive turned out to use 2 authentication systems
that do not accept each other&rsquo;s tokens, failing a long way from the cause. That cost a day.</p>
<p>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.</p>
<p><img src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-3-unmatched_hu_f5f65ef5e4efcd01.webp" width="756" height="1229" alt="The detections table filtered by a LABEL: UNMATCHED badge, the Label filter showing (1), and the count 113/121 detections. Each row has a small image chip, most of them a bright strip of shoreline or land beside blue water, an UNMATCHED label with a grey rejected chip and, on some rows, an Under 90 m chip, a size class such as Hull 60-120 m (rule), and a length such as about 73 m plus or minus 20 m." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The output that justifies the build.</strong> Everything upstream exists to make this list
readable. The same Red Sea scene filtered to <em>Unmatched</em>: 113 of 121 detections. These first rows come from the islands and reefs, and their chips show shoreline, not hulls; <em>rejected</em> is the print rating, meaning not to be published. Rows flagged <em>Under 90 m</em> are here because this project&rsquo;s minimum length was lowered to 49 metres. Even <em>unmatched</em> is a lead, never an accusation against any ship. The optional AI step sends each chip with the scene date, sensor and measured length, never the coordinates, to a model you pick, and adds a shape verdict without changing the label. Scene, position, AI and action columns hidden for width; table magnified.</div>

<figure class="post-video not-prose">
  <video class="post-video-el" controls muted loop playsinline preload="none"
         width="756" height="459" poster="/blog/finding-dark-vessels-with-satellite-imagery/video/d1-clip-poster.png" aria-label="A silent screen recording, about 54 seconds, sped up in places. The map zooms from the Red Sea down to the Sentinel-2 scene off Assab and onto the shipping lane; the green ring on a long hull opens its card, broadcasting as COSCO SHIPPING PLANET, and the card scrolls through its measured size, AIS line and MarineTraffic details, including a third-party photo of the ship with its photographer&#39;s credit. A second match, HANG FENG, opens the same way; then the Detections table, a label filter, and one image chip opened full size.">
    <source src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/video/d1-clip.mp4" type="video/mp4">
    <p class="post-video-fallback">
      Your browser cannot play this clip.
      <a href="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/video/d1-clip.mp4" download>Download the MP4</a> — A silent screen recording, about 54 seconds, sped up in places. The map zooms from the Red Sea down to the Sentinel-2 scene off Assab and onto the shipping lane; the green ring on a long hull opens its card, broadcasting as COSCO SHIPPING PLANET, and the card scrolls through its measured size, AIS line and MarineTraffic details, including a third-party photo of the ship with its photographer&#39;s credit. A second match, HANG FENG, opens the same way; then the Detections table, a label filter, and one image chip opened full size.
    </p>
  </video>
  <figcaption class="post-caption"><strong>Working the list.</strong> From the whole scene to one ship&rsquo;s broadcast identity, then back to the table. Recorded by me in one of my own projects, real data; sped up 1.5 to 3 times between the key moments. The photo inside the card comes from MarineTraffic, credited on screen to its photographer.</figcaption>
</figure>

<h2 id="a-look-at-the-code">A look at the code</h2>
<p>The detection rule, rewritten to stand alone. It flags a pixel far brighter than the ring of
background around it.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="nn">np</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">scipy.signal</span> <span class="kn">import</span> <span class="n">fftconvolve</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">ca_cfar</span><span class="p">(</span><span class="n">image</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mf">4.75</span><span class="p">,</span> <span class="n">guard</span><span class="o">=</span><span class="mi">4</span><span class="p">,</span> <span class="n">training</span><span class="o">=</span><span class="mi">16</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Flag pixels much brighter than their own local background.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">    guard and training are radii in pixels. A (2*guard+1) box around each
</span></span></span><span class="line"><span class="cl"><span class="s2">    pixel is excluded from the statistics; the ring outside it, out to
</span></span></span><span class="line"><span class="cl"><span class="s2">    guard+training, is the background estimate. With the defaults that is
</span></span></span><span class="line"><span class="cl"><span class="s2">    a 9x9 guard box inside a 41x41 window.
</span></span></span><span class="line"><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">outer</span> <span class="o">=</span> <span class="mi">2</span> <span class="o">*</span> <span class="p">(</span><span class="n">guard</span> <span class="o">+</span> <span class="n">training</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="n">inner</span> <span class="o">=</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">guard</span> <span class="o">+</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">ring</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">ones</span><span class="p">((</span><span class="n">outer</span><span class="p">,</span> <span class="n">outer</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">pad</span> <span class="o">=</span> <span class="p">(</span><span class="n">outer</span> <span class="o">-</span> <span class="n">inner</span><span class="p">)</span> <span class="o">//</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">    <span class="n">ring</span><span class="p">[</span><span class="n">pad</span><span class="p">:</span><span class="n">pad</span> <span class="o">+</span> <span class="n">inner</span><span class="p">,</span> <span class="n">pad</span><span class="p">:</span><span class="n">pad</span> <span class="o">+</span> <span class="n">inner</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.0</span>
</span></span><span class="line"><span class="cl">    <span class="n">n</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">ring</span><span class="o">.</span><span class="n">sum</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">mean</span> <span class="o">=</span> <span class="n">fftconvolve</span><span class="p">(</span><span class="n">image</span><span class="p">,</span> <span class="n">ring</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="s2">&#34;same&#34;</span><span class="p">)</span> <span class="o">/</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">    <span class="n">mean_sq</span> <span class="o">=</span> <span class="n">fftconvolve</span><span class="p">(</span><span class="n">image</span> <span class="o">**</span> <span class="mi">2</span><span class="p">,</span> <span class="n">ring</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="s2">&#34;same&#34;</span><span class="p">)</span> <span class="o">/</span> <span class="n">n</span>
</span></span><span class="line"><span class="cl">    <span class="n">std</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sqrt</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">clip</span><span class="p">(</span><span class="n">mean_sq</span> <span class="o">-</span> <span class="n">mean</span> <span class="o">**</span> <span class="mi">2</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">,</span> <span class="kc">None</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">image</span> <span class="o">&gt;</span> <span class="n">mean</span> <span class="o">+</span> <span class="n">k</span> <span class="o">*</span> <span class="n">std</span>
</span></span></code></pre></div><p>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).</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">math</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">match_score</span><span class="p">(</span><span class="n">metres_off</span><span class="p">,</span> <span class="n">seconds_apart</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;1.0 is perfect. The first 150 m are free; then it falls with metres and seconds.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">beyond_slack</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="n">metres_off</span> <span class="o">-</span> <span class="mi">150</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">math</span><span class="o">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="n">beyond_slack</span> <span class="o">/</span> <span class="mi">500</span><span class="p">)</span> <span class="o">*</span> <span class="n">math</span><span class="o">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="nb">abs</span><span class="p">(</span><span class="n">seconds_apart</span><span class="p">)</span> <span class="o">/</span> <span class="mi">1800</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">label</span><span class="p">(</span><span class="n">score</span><span class="p">,</span> <span class="n">ais_nearby</span><span class="p">,</span> <span class="n">ais_loaded</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">score</span> <span class="o">&gt;</span> <span class="mf">0.7</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;AIS match&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">score</span> <span class="o">&gt;=</span> <span class="mf">0.3</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;Possible AIS match&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">ais_nearby</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;Unmatched (AIS nearby)&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">ais_loaded</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s2">&#34;No AIS coverage&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;Unmatched&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 400 m and 5 minutes from where the track puts the ship: close, not conclusive.</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="nb">round</span><span class="p">(</span><span class="n">match_score</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="mi">300</span><span class="p">),</span> <span class="mi">2</span><span class="p">))</span>                 <span class="c1"># 0.51</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">label</span><span class="p">(</span><span class="n">match_score</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="mi">300</span><span class="p">),</span> <span class="kc">True</span><span class="p">,</span> <span class="kc">True</span><span class="p">))</span>        <span class="c1"># Possible AIS match</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">label</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="n">ais_nearby</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ais_loaded</span><span class="o">=</span><span class="kc">True</span><span class="p">))</span>   <span class="c1"># Unmatched</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">label</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="n">ais_nearby</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ais_loaded</span><span class="o">=</span><span class="kc">False</span><span class="p">))</span>  <span class="c1"># No AIS coverage</span>
</span></span></code></pre></div><p>Those thresholds are a judgement, not a measurement. Move 0.3 and you move the boundary
between <em>a human looks at this</em> and <em>nobody does</em>.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-score_light.svg" width="960" height="400" alt="A chart of match score against metres from where the AIS track puts the ship at the moment of the image, 0 to 1,000 metres, computed from the match_score function above. A solid curve for a ping at the same moment stays at 1.0 for the first 150 metres, then falls; a dashed curve for a ping 5 minutes apart sits below it. Dashed lines at 0.7 and 0.3 split the chart into AIS match above 0.7, Possible AIS match from 0.3 to 0.7, and Unmatched below 0.3, with or without AIS nearby. The example point, 400 metres and 5 minutes, is marked at 0.51." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-score_dark.svg" width="960" height="400" alt="A chart of match score against metres from where the AIS track puts the ship at the moment of the image, 0 to 1,000 metres, computed from the match_score function above. A solid curve for a ping at the same moment stays at 1.0 for the first 150 metres, then falls; a dashed curve for a ping 5 minutes apart sits below it. Dashed lines at 0.7 and 0.3 split the chart into AIS match above 0.7, Possible AIS match from 0.3 to 0.7, and Unmatched below 0.3, with or without AIS nearby. The example point, 400 metres and 5 minutes, is marked at 0.51." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Where the labels fall.</strong> On the same-moment curve the score holds at 1.0 for 150 m, leaves <em>AIS match</em> at about 330 m and drops below 0.3 at about 750 m; every gap in time pulls the whole curve down. <em>No AIS coverage</em> is not on the chart: with no data there is nothing to score.</figcaption>
</figure>

<h2 id="what-it-cant-do">What it can&rsquo;t do</h2>
<div class="limitation not-prose"><p class="limitation-title">A detection is a lead, not a finding</p><div class="limitation-body"><p>&ldquo;Vessel in the imagery, nothing in AIS&rdquo; 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.</p>
<p>So the tool never calls anything dark. <em>Dark candidate</em> is a mark only you can set, only on a
plain <em>unmatched</em> detection where AIS data was loaded: hull-shaped, not a fixture or a radar
ghost <em>(a false echo beside a bright target)</em>, and big enough that it should have been
transmitting. Even then it means <em>worth an hour of your attention</em>, not
that a ship was hiding, and nothing that leaves this tool is publishable on its own.</p>
</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">Fixed where it should be tunable, and narrow in what it accepts</p><div class="limitation-body">The CFAR constants cannot be changed from the interface, and neither can the image chip, a
fixed 64-pixel square around each detection (about 640 metres at 10-metre resolution).
Sensitivity is one scalar for the
whole scene, so calm water in one corner and a stiff wind in the other are served badly at
both ends. Optical search takes atmospherically corrected Sentinel-2 scenes only,
ungeoreferenced imagery is rejected, and one commercial provider can be held to 2 downloads
an hour.</div>
</div>

<p><img class="img-narrow" src="https://salaheldinaz.com/blog/finding-dark-vessels-with-satellite-imagery/images/d1-4-parameters_hu_6b468fbf5704866a.webp" width="592" height="1249" alt="Detection settings, magnified: a sensitivity slider at 50 between Fewer (strict) and More (aggressive), unticked boxes for Also find dark and white hulls (experimental) and Include vessels under 90 m, a ticked Check with AI box with its model pickers, and a Run detection on linked scenes button. Then Detection parameters: shoreline buffer 150 m, min area 5 px, max area 500 px, min vessel length 49 m against a default of 90, max vessel length 500 m with a note that the ceiling is 500 m because the longest ships are about 400 m and radar reads a moving hull about 15% long, NDWI threshold -0.1 and NIR cutoff 4000, each with its default beneath it, and a greyed-out Save button." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The dials that exist, and the ones that don&rsquo;t.</strong> The sensitivity slider changes what gets detected; the size and shoreline settings below it decide what is kept, and the last 2 belong to the optical water test. From the same Red Sea project, at the defaults except one: the minimum length was lowered to 49 metres, which is why rows under 90 metres reach its table.</div>

<h2 id="where-this-sits-in-the-series">Where this sits in the series</h2>
<p>This is stage 4 of <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">the 6-stage spine</a>
doing its whole job. Collection is a satellite archive, storage is a spatial database, and all
the interesting work is in the narrowing.</p>
<p>It is also the cleanest example I have of the argument in
<a href="https://salaheldinaz.com/blog/where-ai-actually-fits-in-osint/">where AI actually fits in OSINT</a>. 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.</p>
<p>Detection is a scoring step, and a scoring step you have not measured is a scoring step
you are guessing about. <span class="link-pending" title="Coming soon in this series">Does your AI step actually work?</span> is the discipline that
turns &ldquo;it seems to find them&rdquo; into a number, including what to do when tightening the
threshold makes things worse rather than better.</p>
]]></content:encoded></item></channel></rss>