Case Study — Maritime Domain Awareness
Maritime Domain Awareness (MDA) is tracking, identifying, and assessing vessels and maritime activity. It combines SAR ship detection, optical imagery, AIS transponder data, and OSINT to build a comprehensive picture of what’s happening at sea. This is one of the highest-value GEOINT applications — detecting sanctions evasion, illegal fishing, smuggling, and naval force movements.
The Problem: What Happens at Sea
The ocean covers 71% of Earth’s surface. Most of it is unmonitored. Ships carry AIS transponders that broadcast position, but AIS can be turned off, spoofed, or disabled. When a ship goes “dark” — disappears from AIS — it may be engaged in activities it wants to hide:
- Sanctions evasion: Iran, North Korea, Russia — ships transfer cargo at sea, turn off AIS, or falsify identity
- Illegal, Unreported, Unregulated (IUU) fishing: fishing vessels turn off AIS to fish in restricted waters
- Smuggling: drugs, weapons, people — ships operating without AIS in transit areas
- Military operations: naval vessels do not always broadcast AIS, or may broadcast false data
Satellite SAR detects ships regardless of AIS status. A metal hull on dark water is one of the highest-contrast targets in radar imagery. By comparing SAR detections with AIS records, you can identify “dark ships” — vessels physically present but electronically invisible.
Technique 1: SAR Ship Detection
See Ship Detection in SAR for the full algorithm. Here we build the complete maritime pipeline.
import numpy as np
from scipy.ndimage import label, binary_dilation, binary_erosion, uniform_filter
def maritime_sar_detection(sar_vv, pixel_size_m=10, min_ship_length_m=30):
"""
Maritime ship detection pipeline for Sentinel-1 GRD.
Returns list of detected vessels with estimated sizes.
"""
sar = np.maximum(sar_vv, 1e-10).astype(np.float64)
sar_db = 10 * np.log10(sar)
# Step 1: Water mask (open ocean + coastal water)
# Use low-backscatter threshold — water is typically < -18 dB
water_pct = np.percentile(sar_db, 40)
water_threshold = min(water_pct, -15)
water_mask = sar_db < water_threshold
# Expand water mask slightly (catch near-shore ships)
water_mask = binary_dilation(water_mask, iterations=3)
# Step 2: CFAR detection on water
guard = max(3, int(min_ship_length_m / pixel_size_m))
window = guard * 3
local_mean = uniform_filter(sar, window)
local_sqr_mean = uniform_filter(sar ** 2, window)
local_std = np.sqrt(np.maximum(local_sqr_mean - local_mean ** 2, 0))
# Threshold: pixel significantly brighter than local background
cfar_threshold = local_mean + 5 * local_std
detections = (sar > cfar_threshold) & water_mask
# Step 3: Cluster into ship objects
detections = binary_erosion(detections, iterations=1) # remove isolated pixels
detections = binary_dilation(detections, iterations=1)
labeled, n_objects = label(detections)
min_pixels = max(2, int(min_ship_length_m / pixel_size_m))
ships = []
for i in range(1, n_objects + 1):
obj_pixels = np.argwhere(labeled == i)
area = len(obj_pixels)
if area < min_pixels:
continue
# Estimate dimensions
row_min, col_min = obj_pixels.min(axis=0)
row_max, col_max = obj_pixels.max(axis=0)
length_pixels = max(row_max - row_min, col_max - col_min) + 1
width_pixels = min(row_max - row_min, col_max - col_min) + 1
centroid = obj_pixels.mean(axis=0)
max_backscatter = sar_db[labeled == i].max()
ships.append({
"id": i,
"centroid_row": centroid[0],
"centroid_col": centroid[1],
"length_m": length_pixels * pixel_size_m,
"width_m": width_pixels * pixel_size_m,
"area_pixels": area,
"max_backscatter_db": max_backscatter,
})
return ships, detections, water_maskShip Size Classification
def classify_ship_by_length(length_m):
"""
Rough classification based on estimated length.
Sentinel-1 at 10m resolution: length estimate has ~20m uncertainty.
"""
if length_m < 30:
return "small_vessel" # fishing boat, yacht
elif length_m < 100:
return "medium_vessel" # coaster, ferry, small tanker
elif length_m < 200:
return "large_vessel" # container ship, bulk carrier, frigate
elif length_m < 300:
return "very_large_vessel" # large container, VLCC tanker, aircraft carrier
else:
return "ultra_large" # ULCC, largest container ships (400m)Technique 2: AIS Correlation
AIS (Automatic Identification System) is mandatory for ships over 300 GT on international voyages. It broadcasts ship identity, position, speed, heading, and cargo type via VHF radio. AIS data is collected by coastal stations and satellites.
AIS Data Sources (Free/Open)
- MarineTraffic (marinetraffic.com): free browse, API access paid
- VesselFinder (vesselfinder.com): similar
- Global Fishing Watch (globalfishingwatch.org): free for research, tracks fishing vessels
- AIS data archives: various research datasets available
Cross-Reference Pipeline
import numpy as np
from datetime import datetime, timedelta
def correlate_sar_with_ais(sar_ships, ais_records, sar_datetime,
max_time_diff_hours=1, max_distance_km=5):
"""
Correlate SAR ship detections with AIS records.
sar_ships: list of dicts from maritime_sar_detection (with lat/lon added)
ais_records: list of dicts with keys: mmsi, lat, lon, timestamp, name, ship_type
sar_datetime: datetime of SAR acquisition
Returns:
- matched: SAR detection + matching AIS record
- sar_only: SAR detections with no AIS match (= potential dark ships)
- ais_only: AIS records with no SAR match (= potential AIS spoofing)
"""
matched = []
sar_unmatched = []
ais_used = set()
for ship in sar_ships:
best_match = None
best_distance = max_distance_km
for j, ais in enumerate(ais_records):
# Time filter
ais_time = ais["timestamp"]
time_diff = abs((sar_datetime - ais_time).total_seconds() / 3600)
if time_diff > max_time_diff_hours:
continue
# Distance filter (approximate, Haversine)
dist = haversine_km(ship["lat"], ship["lon"],
ais["lat"], ais["lon"])
if dist < best_distance:
best_distance = dist
best_match = j
if best_match is not None:
matched.append({
"sar": ship,
"ais": ais_records[best_match],
"distance_km": best_distance,
})
ais_used.add(best_match)
else:
sar_unmatched.append(ship)
# AIS records with no SAR match
ais_unmatched = [ais_records[j] for j in range(len(ais_records))
if j not in ais_used]
return {
"matched": matched,
"dark_ships": sar_unmatched, # visible on SAR, no AIS = suspicious
"ais_only": ais_unmatched, # AIS present but no SAR detection
"match_rate": len(matched) / max(len(sar_ships), 1),
}
def haversine_km(lat1, lon1, lat2, lon2):
"""Great-circle distance between two points."""
R = 6371 # Earth radius km
dlat = np.radians(lat2 - lat1)
dlon = np.radians(lon2 - lon1)
a = (np.sin(dlat/2)**2 +
np.cos(np.radians(lat1)) * np.cos(np.radians(lat2)) *
np.sin(dlon/2)**2)
return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))Dark Ship Analysis
def analyze_dark_ships(dark_ships, known_fishing_zones=None, sanctions_areas=None):
"""
Assess dark ship detections for potential intelligence value.
"""
results = []
for ship in dark_ships:
assessment = {
"position": (ship["lat"], ship["lon"]),
"estimated_length_m": ship["length_m"],
"classification": classify_ship_by_length(ship["length_m"]),
"risk_factors": [],
}
# Check against known areas of concern
if sanctions_areas:
for area in sanctions_areas:
if point_in_polygon(ship["lat"], ship["lon"], area["polygon"]):
assessment["risk_factors"].append(
f"In sanctions-relevant area: {area['name']}"
)
if known_fishing_zones:
for zone in known_fishing_zones:
if point_in_polygon(ship["lat"], ship["lon"], zone["polygon"]):
if ship["length_m"] < 80:
assessment["risk_factors"].append(
f"Potential IUU fishing in {zone['name']}"
)
# Size-based assessment
if ship["length_m"] > 150:
assessment["risk_factors"].append(
"Large vessel with no AIS — high priority for investigation"
)
results.append(assessment)
return results
def point_in_polygon(lat, lon, polygon):
"""Simple point-in-polygon check (ray casting)."""
n = len(polygon)
inside = False
j = n - 1
for i in range(n):
if ((polygon[i][1] > lon) != (polygon[j][1] > lon)) and \
(lat < (polygon[j][0] - polygon[i][0]) *
(lon - polygon[i][1]) / (polygon[j][1] - polygon[i][1]) +
polygon[i][0]):
inside = not inside
j = i
return insideTechnique 3: Port Activity Monitoring
Track what’s happening at ports using regular satellite imagery.
import numpy as np
def port_monitoring_metrics(ship_detections_timeseries, dates):
"""
Compute port activity metrics over time.
ship_detections_timeseries: list of ship detection lists, one per date
"""
metrics = []
for date, ships in zip(dates, ship_detections_timeseries):
n_ships = len(ships)
large_ships = sum(1 for s in ships if s.get("length_m", 0) > 150)
total_area = sum(s.get("area_pixels", 0) for s in ships)
metrics.append({
"date": date,
"total_ships": n_ships,
"large_ships": large_ships,
"total_ship_area": total_area,
})
return metrics
def detect_anomalous_port_activity(metrics, baseline_window=10):
"""
Flag dates where port activity deviates from baseline.
"""
ship_counts = np.array([m["total_ships"] for m in metrics])
# Rolling baseline
anomalies = []
for i in range(baseline_window, len(ship_counts)):
window = ship_counts[i - baseline_window:i]
mean = np.mean(window)
std = np.std(window) + 1
z_score = (ship_counts[i] - mean) / std
if abs(z_score) > 2:
anomalies.append({
"date": metrics[i]["date"],
"ship_count": int(ship_counts[i]),
"baseline_mean": float(mean),
"z_score": float(z_score),
"direction": "increase" if z_score > 0 else "decrease",
})
return anomaliesTechnique 4: Wake Detection
A moving ship creates a visible wake pattern. Wake presence indicates the ship is underway (not anchored). Wake length and angle can estimate speed.
def detect_wake(sar_image, ship_centroid, search_radius_pixels=100):
"""
Simple wake detection behind a detected ship.
Wakes appear as linear bright features extending behind the vessel
in SAR imagery (Kelvin wake pattern at ~19.5 degree half-angle).
"""
row, col = int(ship_centroid[0]), int(ship_centroid[1])
# Extract region behind ship (search in all directions)
r1 = max(0, row - search_radius_pixels)
r2 = min(sar_image.shape[0], row + search_radius_pixels)
c1 = max(0, col - search_radius_pixels)
c2 = min(sar_image.shape[1], col + search_radius_pixels)
region = sar_image[r1:r2, c1:c2]
region_db = 10 * np.log10(np.maximum(region, 1e-10))
# Wake pixels: brighter than water background but dimmer than ship
water_mean = np.percentile(region_db, 30)
ship_peak = np.max(region_db)
wake_mask = (region_db > water_mean + 3) & (region_db < ship_peak - 5)
wake_pixels = np.sum(wake_mask)
has_wake = wake_pixels > 20 # minimum wake size
return {
"has_wake": has_wake,
"wake_pixels": wake_pixels,
"moving": has_wake,
}Technique 5: Ship-to-Ship Transfer Detection
A key indicator of sanctions evasion: two ships meeting at sea to transfer cargo.
def detect_sts_transfer(sar_ships, min_proximity_m=200, min_combined_length=200):
"""
Detect potential Ship-to-Ship (STS) transfers.
STS indicators: two large ships very close together in open water.
"""
transfers = []
for i, ship_a in enumerate(sar_ships):
for j, ship_b in enumerate(sar_ships):
if j <= i:
continue
# Distance between centroids
dr = abs(ship_a["centroid_row"] - ship_b["centroid_row"])
dc = abs(ship_a["centroid_col"] - ship_b["centroid_col"])
dist_pixels = np.sqrt(dr**2 + dc**2)
dist_m = dist_pixels * 10 # assume 10m pixel size
if dist_m < min_proximity_m:
combined_length = ship_a.get("length_m", 0) + ship_b.get("length_m", 0)
if combined_length > min_combined_length:
transfers.append({
"ship_a": ship_a,
"ship_b": ship_b,
"proximity_m": dist_m,
"combined_length_m": combined_length,
"confidence": "medium" if dist_m < 100 else "low",
})
return transfersComplete Maritime Intelligence Report
def generate_maritime_report(sar_results, ais_correlation, date, area_name):
"""Generate a structured maritime intelligence report."""
report = f"""
MARITIME DOMAIN AWARENESS REPORT
=================================
Date: {date}
Area: {area_name}
Sensor: Sentinel-1 SAR (VV polarization)
1. DETECTION SUMMARY
Total SAR detections: {len(sar_results)}
Matched with AIS: {len(ais_correlation['matched'])}
Dark ships (SAR only, no AIS): {len(ais_correlation['dark_ships'])}
AIS only (no SAR detection): {len(ais_correlation['ais_only'])}
Match rate: {ais_correlation['match_rate']:.0%}
2. MATCHED VESSELS
"""
for m in ais_correlation["matched"][:10]:
ais = m["ais"]
report += (
f" - {ais.get('name', 'Unknown')} "
f"(MMSI: {ais.get('mmsi', 'N/A')}, "
f"Type: {ais.get('ship_type', 'N/A')}) "
f"— distance to AIS: {m['distance_km']:.1f} km\n"
)
report += f"""
3. DARK SHIPS (PRIORITY FOR INVESTIGATION)
Count: {len(ais_correlation['dark_ships'])}
"""
for ds in ais_correlation["dark_ships"]:
cls = classify_ship_by_length(ds.get("length_m", 0))
report += (
f" - Position: ({ds.get('lat', 0):.3f}, {ds.get('lon', 0):.3f}), "
f"Est. length: {ds.get('length_m', 0):.0f}m, "
f"Class: {cls}\n"
)
report += f"""
4. ANOMALIES
[List any ship-to-ship transfers, unusual clustering, etc.]
5. ASSESSMENT
[Analyst interpretation of findings]
6. RECOMMENDED FOLLOW-UP
- Task optical imagery for dark ship identification
- Cross-reference with sanctions vessel lists
- Check AIS historical data for vessels that recently went dark
"""
return reportReal-World Examples
Iran Sanctions Evasion
Iranian oil tankers routinely turn off AIS in the Persian Gulf/Indian Ocean to evade US sanctions. Detected by SAR satellites showing vessels at sea with no AIS signal. Ship-to-ship transfers documented by commercial satellite imagery (TankerTrackers.com methodology).
IUU Fishing (Global Fishing Watch)
Global Fishing Watch uses AIS data + SAR + machine learning to identify fishing vessels worldwide. Key findings: many fishing vessels go dark in exclusive economic zones where they’re not authorized to fish. SAR detects them; AIS absence confirms suspicious behavior.
Russian Naval Activity
Open-source analysts track Russian naval vessels using SAR imagery of ports (Severomorsk, Sevastopol, Tartus). Submarine presence/absence in port is a key indicator. OSINT (AIS, social media) provides identification; SAR provides all-weather confirmation.
Exercises
Exercise 1: Detect Ships in Sentinel-1 SAR
- Download a Sentinel-1 GRD scene covering a busy shipping area (English Channel, Strait of Malacca, or Baltic Sea)
- Apply speckle filtering
- Run the CFAR ship detection
- How many ships? Estimate their sizes
- Convert pixel coordinates to geographic coordinates using the raster transform
Exercise 2: AIS Correlation
- For the same area/date, obtain AIS data (MarineTraffic screenshot, Global Fishing Watch, or research dataset)
- Compare your SAR detections with AIS positions
- How many detections have matching AIS? How many are “dark”?
- For dark ships: are they in areas of concern?
Exercise 3: Port Activity Time Series
- Download 6+ Sentinel-1 scenes of a major port over 2-3 months
- Run ship detection on each scene
- Plot ship count over time
- Identify any anomalous dates (unusually many or few ships)
- Cross-reference anomalies with news events
Self-Test Questions
- Why is SAR better than optical for ship detection in most maritime scenarios?
- A ship appears on SAR but not on AIS. List 5 possible explanations (not all are malicious).
- What is the approximate minimum ship size detectable by Sentinel-1 at 10m resolution?
- Why are ship-to-ship transfers a key indicator of sanctions evasion?
- How does Global Fishing Watch use the combination of AIS + SAR + ML?
See also: SAR Fundamentals and Analysis | Multi-Source Intelligence Fusion | Case Study - Monitoring Military Installations Next: GEOINT Capstone