Detecting Suspicious Damage Patterns in Shipping Containers Using Spatial Analysis
Detecting Suspicious Damage Patterns in Shipping Containers Using Spatial Analysis
The Problem
Shipping containers get damaged during transport. That’s expected. What’s not expected? Impossible damage patterns that suggest fraud or reporting errors.
When a container arrives at port with damage, someone needs to file a claim. But here’s the thing: not all damage patterns make physical sense. If a container has damage on the top-front-left corner AND the bottom-back-right corner, but nothing in between—that’s weird. That probably didn’t happen from normal handling.
Manual inspection catches some of this, but humans are inconsistent and slow. What if you could automatically flag suspicious patterns using spatial analysis?
That’s what we’re building here: a system that analyzes damage reports and detects physically improbable patterns using geometric reasoning.
The Big Picture
A shipping container is basically a box with 6 sides. We can divide it into zones:
Top View:
┌─────────────────┐
│ front-left │ front-right │
│─────────────────│
│ back-left │ back-right │
└─────────────────┘
Side View:
┌─────────────────┐
│ top-front │
│ middle │
│ bottom-front│
└─────────────────┘
Each damage report lists damaged sections (door, left panel, roof, etc.) with their locations. Our job is to look at the spatial distribution and ask: “Does this pattern make physical sense?”
We check for several suspicious patterns:
- Symmetric damage - Both left AND right sides damaged (very rare)
- Diagonal damage - Front-left AND back-right (physically unlikely)
- Impossible gaps - Front and back damaged, but middle sections untouched
Let’s build it.
Part 1: Grouping Damage by Location
First, we need to organize damage reports by their spatial location. Each container section has location tags like [:front, :left] or [:top, :middle].
defmodule ContainerDamage.PatternChecker do
@moduledoc """
Analyzes damage patterns in shipping containers to detect suspicious
or physically improbable damage configurations.
"""
def check_damage_plausibility(damage_reports) do
# Filter to only damaged sections that have location data
sections_with_locations =
Enum.filter(damage_reports, fn report ->
report.damaged? and not is_nil(report.locations)
end)
if Enum.empty?(sections_with_locations) do
# No damage or no location data, nothing to check
{:ok, :no_suspicious_patterns}
else
# Run all our pattern checks
checks = [
check_symmetric_pattern(sections_with_locations),
check_diagonal_pattern(sections_with_locations),
check_gap_pattern(sections_with_locations)
]
suspicious = Enum.filter(checks, fn {score, _} -> score > 0.0 end)
if Enum.empty?(suspicious) do
{:ok, :no_suspicious_patterns}
else
{:suspicious, suspicious}
end
end
end
# Helper to filter sections by location tags
defp filter_by_locations(sections, required_locations, excluded_locations \\ []) do
Enum.filter(sections, fn section ->
locations = section.locations || []
has_required = Enum.all?(required_locations, &(&1 in locations))
has_excluded = Enum.any?(excluded_locations, &(&1 in locations))
has_required and not has_excluded
end)
end
end
The filter_by_locations/3 function is the workhorse here. It lets us ask questions like:
- “Give me all sections that are both
:frontAND:left” - “Give me sections that are
:leftbut NOT:frontor:back” (left-middle sections)
This is more flexible than hard-coding zone names because container designs vary. Some have 3 vertical sections, others have 2. The location tags adapt to any layout.
Part 2: Detecting Symmetric Patterns
Symmetric damage (both left AND right sides) is extremely rare in shipping. Containers usually get damaged on one side from a forklift, crane, or stacking issue. Both sides at once? Suspicious.
defp check_symmetric_pattern(sections) do
# Group sections by quadrant
by_location = %{
front_left: filter_by_locations(sections, [:front, :left]),
front_right: filter_by_locations(sections, [:front, :right]),
back_left: filter_by_locations(sections, [:back, :left]),
back_right: filter_by_locations(sections, [:back, :right]),
# Sections that are just left or right (not front/back specific)
left_only: filter_by_locations(sections, [:left], [:front, :back]),
right_only: filter_by_locations(sections, [:right], [:front, :back])
}
patterns = detect_left_right_patterns(by_location)
# Find all sections involved in suspicious patterns
suspicious_sections =
patterns
|> Enum.filter(fn {_pattern_name, {is_suspicious?, _sections}} -> is_suspicious? end)
|> Enum.flat_map(fn {_pattern_name, {_is_suspicious?, sections}} -> sections end)
if Enum.empty?(suspicious_sections) do
{0.0, []}
else
section_ids = Enum.map(suspicious_sections, & &1.id) |> Enum.uniq()
{1.0, section_ids} # Score of 1.0 = definitely suspicious
end
end
defp detect_left_right_patterns(by_location) do
%{
# Pattern: Damage on BOTH front corners
front_symmetric: {
not Enum.empty?(by_location.front_left) and not Enum.empty?(by_location.front_right),
by_location.front_left ++ by_location.front_right
},
# Pattern: Damage on BOTH back corners
back_symmetric: {
not Enum.empty?(by_location.back_left) and not Enum.empty?(by_location.back_right),
by_location.back_left ++ by_location.back_right
},
# Pattern: Damage on both entire sides
full_symmetric: {
not Enum.empty?(by_location.left_only) and not Enum.empty?(by_location.right_only),
by_location.left_only ++ by_location.right_only
}
}
end
The logic is straightforward: if we have damage on the left side AND the right side, flag it. We check three variations:
- Both front corners
- Both back corners
- Both entire sides
Why three checks instead of one? Because the severity differs. Both front corners might happen if the container was dropped. Both entire sides? Almost impossible.
In a real system, you’d probably weight these differently. But for simplicity, we’re treating any symmetric pattern as equally suspicious.
Part 3: Detecting Diagonal Patterns
Diagonal damage is even weirder. Front-left AND back-right? How does that happen physically? The container would need to tumble in a very specific way, or be hit by two separate incidents.
defp check_diagonal_pattern(sections) do
front_left = filter_by_locations(sections, [:front, :left])
back_right = filter_by_locations(sections, [:back, :right])
front_right = filter_by_locations(sections, [:front, :right])
back_left = filter_by_locations(sections, [:back, :left])
# Check both possible diagonals
diagonal_1 = not Enum.empty?(front_left) and not Enum.empty?(back_right)
diagonal_2 = not Enum.empty?(front_right) and not Enum.empty?(back_left)
cond do
diagonal_1 and diagonal_2 ->
# Both diagonals damaged? VERY suspicious
all_sections = front_left ++ back_right ++ front_right ++ back_left
section_ids = Enum.map(all_sections, & &1.id) |> Enum.uniq()
{1.0, section_ids}
diagonal_1 ->
section_ids = Enum.map(front_left ++ back_right, & &1.id) |> Enum.uniq()
{1.0, section_ids}
diagonal_2 ->
section_ids = Enum.map(front_right ++ back_left, & &1.id) |> Enum.uniq()
{1.0, section_ids}
true ->
{0.0, []}
end
end
This one’s simple: if opposite corners are damaged, flag it. There’s not much nuance here—diagonal damage is pretty much always suspicious unless there’s a good explanation (like the container was in a serious accident, which would be documented).
Part 4: Detecting Gaps Using Graph Theory
This is where it gets interesting. Sometimes damage patterns have gaps that don’t make physical sense.
Imagine a container with damage on the front door AND the back panel, but the middle sections are completely fine. How did the force skip over the middle?
To detect this, we model the container as a graph:
- Nodes = container sections (door, panel, roof, etc.)
- Edges = physical adjacency (door connects to panel, panel connects to roof)
Then we calculate the “hop distance” between damaged sections. If two damaged sections are far apart (many hops) but all the sections in between are undamaged, that’s a gap.
defp check_gap_pattern(sections) do
# Build a graph of container topology
# (In production, this would come from a database of container types)
topology = build_container_topology()
# Find all pairs of damaged sections
damaged_ids = Enum.map(sections, & &1.id)
gap_pairs =
for id1 <- damaged_ids,
id2 <- damaged_ids,
id1 < id2 do # Avoid checking same pair twice
# Calculate shortest path between these sections
hop_distance = shortest_path_length(topology, id1, id2)
# Get all sections along the shortest path
path_sections = get_path_sections(topology, id1, id2)
# Check if any intermediate sections are also damaged
intermediate_damaged? =
path_sections
|> Enum.drop(1) # Skip first (id1)
|> Enum.drop(-1) # Skip last (id2)
|> Enum.any?(&(&1 in damaged_ids))
# A gap exists if:
# 1. Sections are far apart (hop_distance > 1)
# 2. No intermediate sections are damaged
if hop_distance > 1 and not intermediate_damaged? do
{:gap, id1, id2, hop_distance}
else
nil
end
end
|> Enum.reject(&is_nil/1)
if Enum.empty?(gap_pairs) do
{0.0, []}
else
# Return all sections involved in gaps
involved_ids =
gap_pairs
|> Enum.flat_map(fn {:gap, id1, id2, _} -> [id1, id2] end)
|> Enum.uniq()
{1.0, involved_ids}
end
end
The graph approach is powerful because it handles any container layout. Add new section types? Just update the topology. Different container sizes? Same code works.
Building the Topology Graph
Here’s how you’d build the topology for a standard shipping container:
defp build_container_topology do
# Define which sections connect to which
# Format: {section_id, [connected_section_ids]}
edges = [
{"front_door", ["front_left_panel", "front_right_panel", "roof_front"]},
{"front_left_panel", ["front_door", "middle_left_panel", "floor_front"]},
{"front_right_panel", ["front_door", "middle_right_panel", "floor_front"]},
{"middle_left_panel", ["front_left_panel", "back_left_panel"]},
{"middle_right_panel", ["front_right_panel", "back_right_panel"]},
{"back_left_panel", ["middle_left_panel", "back_door"]},
{"back_right_panel", ["middle_right_panel", "back_door"]},
{"back_door", ["back_left_panel", "back_right_panel", "roof_back"]},
{"roof_front", ["front_door", "roof_middle"]},
{"roof_middle", ["roof_front", "roof_back"]},
{"roof_back", ["roof_middle", "back_door"]},
{"floor_front", ["front_left_panel", "front_right_panel", "floor_middle"]},
{"floor_middle", ["floor_front", "floor_back"]},
{"floor_back", ["floor_middle", "back_door"]}
]
# Convert to a graph structure (using :digraph or a library like libgraph)
graph = Graph.new()
Enum.reduce(edges, graph, fn {section, connections}, g ->
g = Graph.add_vertex(g, section)
Enum.reduce(connections, g, fn connected, g2 ->
g2
|> Graph.add_vertex(connected)
|> Graph.add_edge(section, connected)
end)
end)
end
defp shortest_path_length(graph, from_id, to_id) do
case Graph.dijkstra(graph, from_id, to_id) do
nil -> :infinity # No path exists
path -> length(path) - 1 # Number of edges (hops)
end
end
defp get_path_sections(graph, from_id, to_id) do
Graph.dijkstra(graph, from_id, to_id) || []
end
For a standard 20-foot container, you might have 15-20 sections. A 40-foot container? Maybe 30. The graph scales well.
Part 5: Confidence Scoring
So far we’ve been returning 1.0 (suspicious) or 0.0 (fine). In reality, you probably want nuance.
Some patterns are more suspicious than others:
- Both sides damaged → 70% confidence
- Diagonal damage → 90% confidence
- Gap with 3+ hops → 95% confidence
Here’s how to add confidence scoring:
defmodule ContainerDamage.ConfidenceScorer do
def calculate_confidence(check_type, details) do
case check_type do
:symmetric ->
# More sections = higher confidence it's suspicious
num_sections = length(details.involved_sections)
min(0.5 + (num_sections * 0.1), 1.0)
:diagonal ->
# Diagonals are almost always suspicious
0.9
:gaps ->
# Longer gaps = higher confidence
max_hop_distance = Enum.max(Enum.map(details.gaps, & &1.hop_distance))
case max_hop_distance do
1 -> 0.3 # Adjacent sections, might be ok
2 -> 0.6 # One section gap, suspicious
3 -> 0.85 # Two section gap, very suspicious
_ -> 0.95 # Multiple sections skipped, almost certainly wrong
end
end
end
end
You could also use machine learning to tune these thresholds based on historical fraud data. But starting with rule-based scoring is fine.
Part 6: Handling Edge Cases
Real-world data is messy. Here are gotchas I’ve encountered:
Missing location data
Not all damage reports have location data. Someone might report “door damaged” without specifying which door or which side.
defp filter_sections_with_locations(sections) do
Enum.filter(sections, fn section ->
section.locations && !Enum.empty?(section.locations)
end)
end
Just skip these. You can’t do spatial analysis without spatial data.
Ambiguous locations
Some sections might be tagged [:left] without :front or :back. That’s fine—our location filter handles it with the exclusion parameter.
Multiple damage types
A section might have both a dent AND a scratch. For pattern detection, we don’t care about damage severity—only location. Just check if damaged? == true.
False positives
Sometimes legitimate damage looks suspicious. Container was in a major accident? Might have symmetric or diagonal damage.
Solution: treat these checks as flags for human review, not automatic rejections. An adjuster sees the flag and investigates.
Part 7: Putting It All Together
Here’s the complete flow:
defmodule ContainerDamage do
alias ContainerDamage.{PatternChecker, ConfidenceScorer}
def analyze_damage_report(report_id) do
# Load damage report from database
report = Repo.get!(DamageReport, report_id)
sections = load_damaged_sections(report)
# Run pattern checks
case PatternChecker.check_damage_plausibility(sections) do
{:ok, :no_suspicious_patterns} ->
{:ok, %{suspicious?: false, patterns: []}}
{:suspicious, patterns} ->
# Add confidence scores
scored_patterns =
Enum.map(patterns, fn {_score, involved_ids} = pattern ->
check_type = determine_check_type(pattern)
confidence = ConfidenceScorer.calculate_confidence(check_type, pattern)
%{
check_type: check_type,
confidence: confidence,
involved_section_ids: involved_ids
}
end)
# Sort by confidence (most suspicious first)
sorted = Enum.sort_by(scored_patterns, & &1.confidence, :desc)
{:suspicious, %{suspicious?: true, patterns: sorted}}
end
end
defp load_damaged_sections(report) do
from(s in ContainerSection,
where: s.report_id == ^report.id,
where: s.damaged == true,
preload: [:section_type]
)
|> Repo.all()
end
end
Example output:
{:suspicious, %{
suspicious?: true,
patterns: [
%{
check_type: :diagonal,
confidence: 0.9,
involved_section_ids: ["front_left_panel", "back_right_panel"]
},
%{
check_type: :gaps,
confidence: 0.85,
involved_section_ids: ["front_door", "back_door"]
}
]
}}
This gives adjusters a clear view: “This report has suspicious patterns. Review the highlighted sections.”
Testing Spatial Logic
Testing geometric algorithms can be tricky. Here’s a pattern that works well:
defmodule ContainerDamage.PatternCheckerTest do
use ExUnit.Case
alias ContainerDamage.PatternChecker
describe "symmetric pattern detection" do
test "flags damage on both left and right sides" do
sections = [
%{id: "left_1", locations: [:left, :front], damaged?: true},
%{id: "right_1", locations: [:right, :front], damaged?: true}
]
{score, involved_ids} = PatternChecker.check_symmetric_pattern(sections)
assert score == 1.0
assert "left_1" in involved_ids
assert "right_1" in involved_ids
end
test "ignores damage on only one side" do
sections = [
%{id: "left_1", locations: [:left, :front], damaged?: true},
%{id: "left_2", locations: [:left, :back], damaged?: true}
]
{score, _ids} = PatternChecker.check_symmetric_pattern(sections)
assert score == 0.0
end
end
describe "gap detection" do
test "flags damage with undamaged sections in between" do
sections = [
%{id: "front_door", locations: [:front], damaged?: true},
%{id: "middle_panel", locations: [:middle], damaged?: false},
%{id: "back_door", locations: [:back], damaged?: true}
]
{score, involved_ids} = PatternChecker.check_gap_pattern(sections)
assert score == 1.0
assert "front_door" in involved_ids
assert "back_door" in involved_ids
refute "middle_panel" in involved_ids # Not flagged because it's undamaged
end
end
end
Test with visual diagrams in comments:
test "complex gap scenario" do
# Container layout:
# [DAMAGED] - [ OK ] - [ OK ] - [DAMAGED]
# ^ ^
# |------ 3 hop gap --------------|
sections = [...]
{score, _ids} = PatternChecker.check_gap_pattern(sections)
assert score > 0.8 # High confidence for 3-hop gap
end
Wrapping Up
Spatial pattern detection is about encoding physical intuition into algorithms. Humans naturally think “that damage pattern doesn’t make sense”—we’ve just formalized that intuition.
Key takeaways:
- Location-based filtering - Use flexible tags instead of hard-coded zones
- Multiple checks - Look for different suspicious patterns (symmetric, diagonal, gaps)
- Graph theory - Model physical adjacency to detect impossible gaps
- Confidence scores - Not all suspicious patterns are equally suspicious
- Handle edge cases - Real data is messy, filter and validate
This pattern works for any domain with spatial data:
- Vehicle damage (the original use case this is based on)
- Building inspections
- Equipment damage assessments
- Agricultural crop damage
- Archaeological site analysis
The algorithms stay the same—just change the domain-specific topology.