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_mask

Ship 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 inside

Technique 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 anomalies

Technique 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 transfers

Complete 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 report

Real-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

  1. Download a Sentinel-1 GRD scene covering a busy shipping area (English Channel, Strait of Malacca, or Baltic Sea)
  2. Apply speckle filtering
  3. Run the CFAR ship detection
  4. How many ships? Estimate their sizes
  5. Convert pixel coordinates to geographic coordinates using the raster transform

Exercise 2: AIS Correlation

  1. For the same area/date, obtain AIS data (MarineTraffic screenshot, Global Fishing Watch, or research dataset)
  2. Compare your SAR detections with AIS positions
  3. How many detections have matching AIS? How many are “dark”?
  4. For dark ships: are they in areas of concern?

Exercise 3: Port Activity Time Series

  1. Download 6+ Sentinel-1 scenes of a major port over 2-3 months
  2. Run ship detection on each scene
  3. Plot ship count over time
  4. Identify any anomalous dates (unusually many or few ships)
  5. Cross-reference anomalies with news events

Self-Test Questions

  1. Why is SAR better than optical for ship detection in most maritime scenarios?
  2. A ship appears on SAR but not on AIS. List 5 possible explanations (not all are malicious).
  3. What is the approximate minimum ship size detectable by Sentinel-1 at 10m resolution?
  4. Why are ship-to-ship transfers a key indicator of sanctions evasion?
  5. 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