| name | sar-locate |
| description | Locate a single image (a still from video, or any GPS-stripped image) by visually matching it against the known-GPS tile corpus of an ingested SAR incident. Use when the user asks to "locate this image against incident013", "find where this still was taken", "match this frame to our survey tiles", or similar. Pipeline is two-stage — a cheap dHash pre-ranker narrows thousands of tiles to a top-K batch, then Claude multimodal verification picks the matching tile (if any). The matched tile's EXIF GPS becomes the inferred location of the query. |
sar-locate
Match a query image against an incident's ingested tile corpus; inherit the matching
tile's GPS as the query's location.
Inputs
- Image path (required) — absolute path to the query image (PNG / JPG / etc).
- Incident id (required) — the ingested tile corpus to search against. That incident
MUST have been through
sar-ingest first.
- Optional region filter —
--region-lat/--region-lon/--region-radius-km to restrict
candidates to tiles whose GPS falls within the radius (useful if you know the subject
drifted into a sub-region of the incident footprint).
Assumptions
- The query image does NOT have reliable GPS. If EXIF GPS is present, it's surfaced as a
cross-check but is NOT treated as ground truth — the matched tile's GPS wins.
- The tile corpus is aerial (nadir or high-oblique drone imagery). If the query is
ground-perspective, matching is harder but still possible when distinctive features are
visible from both viewpoints.
What to do
Paths below are project-relative; run from the project root (or prefix with ${CLAUDE_PROJECT_DIR}/). Scripts resolve runs/ from $CLAUDE_PROJECT_DIR or their on-disk location by default; override with --output-root / --runs-root.
Step 1 — Extract context, build phash index, rank candidates
python3 .claude/skills/sar-locate/scripts/extract_context.py \
--image <ABSOLUTE_PATH_TO_IMAGE> \
--incident-id <INCIDENT_ID> \
--top-k 60
This:
- Builds (or reuses)
runs/<incident_id>/phash_index.json — a 64-bit dHash over every keep-tile in the incident manifest. One-time cost: ~3 min on 2000 tiles.
- Computes a dHash of the query image.
- Ranks all indexed tiles by Hamming distance, writes the top-K as
candidates_p0.json.
- Writes
runs/locate/<timestamp>_<image_basename>/context.json describing the run.
Prints the output directory on stdout — capture it.
Step 2 — Render the tile-match prompt
python3 .claude/skills/sar-locate/scripts/render_prompt.py \
--context <OUTPUT_DIR>/context.json \
> <OUTPUT_DIR>/prompt.md
Read <OUTPUT_DIR>/prompt.md — the criteria and JSON output schema for this batch.
Step 3 — View query + candidates
Use the Read tool on:
- The query image (
ctx.image)
- Every candidate tile listed in
<OUTPUT_DIR>/candidates_p0.json (60 tiles by default). For 60 images this is tractable in one session but can also be done in smaller sub-batches.
Step 4 — Produce a verdict
Write a single JSON object matching the prompt's schema to <OUTPUT_DIR>/verdict.json.
overall_outcome == "match" only if you have at least one candidate with match: "match" AND confidence ≥ 0.75.
matched_candidate_id must be one of the IDs in the current batch, or null.
per_candidate should have one entry per candidate you reviewed.
suggest_next_page: true if outcome is no_match or possible_match and you want more candidates.
Step 5 — Render the result
python3 .claude/skills/sar-locate/scripts/write_result.py \
--context <OUTPUT_DIR>/context.json \
--verdict <OUTPUT_DIR>/verdict.json
Writes <OUTPUT_DIR>/result.md and <OUTPUT_DIR>/result.geojson.
Step 6 — Optional next page
If suggest_next_page was true and the caller wants to keep searching:
python3 .claude/skills/sar-locate/scripts/rank_candidates.py \
--image <QUERY_IMAGE> \
--index runs/<INCIDENT_ID>/phash_index.json \
--offset 60 --top-k 60 \
> <OUTPUT_DIR>/candidates_p1.json
jq --arg cf "<OUTPUT_DIR>/candidates_p1.json" \
'.current_candidates_file = $cf | .current_page = 1' \
<OUTPUT_DIR>/context.json > <OUTPUT_DIR>/context.json.tmp && \
mv <OUTPUT_DIR>/context.json.tmp <OUTPUT_DIR>/context.json
Then re-render the prompt (step 2) and repeat. Recommended cap: 3 pages (180 tiles) before concluding "no match in the indexed corpus — the query may be outside the survey area or too cross-perspective to match."
Report back to the user
- Outcome (
match / possible_match / no_match) and confidence.
- If matched: inferred GPS with Google/Apple Maps links; the matched tile id and its frame; the dHash distance for context (low is encouraging, high is surprising).
- If EXIF GPS existed on the query, the delta between it and the inferred match — flag large discrepancies.
- Any caveats surfaced in the caveats section of
result.md.
- Absolute paths of
result.md and result.geojson.
Why this tactic
- No GPS on the query → OSINT-from-raw-reasoning is guessy and prior-dependent. Matching against a known-GPS corpus turns the problem into visual place recognition: the hard problem is identification of sameness, not inference of coordinates.
- dHash pre-ranker — O(72-byte grayscale downsample) per tile. 2000 tiles rank in seconds. Robust to JPEG, scale, mild color shift. Not robust to perspective — which is why Stage B is a full Claude visual comparison, not a hash threshold.
- Multimodal verification — the only way to distinguish "similar canopy" from "same canopy patch" is visual reasoning with both images in view. Hash distance is a ranker, not a verdict.
Caveats
- Ground→Aerial mismatches are structurally hard. A horizontal phone-still of forest looks nothing like a nadir aerial tile of the same ground, even to a model — unless a distinctive feature (clearing, rock outcrop, building) shows in both. Expect
no_match in most cross-perspective cases.
- The inferred GPS is the drone's capture coordinate. At high AGL with a tilted gimbal, the actual ground point under the matched pixel may be 50–150 m offset.
- False-match risk is significant. Forest canopies all look similar. Raise confidence only on specific observable features (a particular clearing shape, a road junction, a pond outline), not on "general vibe."
- Page cap. Reviewing past page 3 (180 candidates) rarely pays off. If no match by then, the query is likely outside the corpus or from a fundamentally different perspective.