| name | sar-ingest |
| description | Prepare a search-and-rescue drone-image incident for visual triage. Use when the user asks to "ingest", "prepare", or "set up" an SAR incident directory of drone imagery — e.g. "get incident013 ready for search", "ingest the new footage", "prep this folder for triage". Enumerates high-resolution RGB JPGs in the directory, extracts GPS/timestamp from EXIF, tiles each frame into a 4x3 overlapping grid, runs a cheap ImageMagick Stage-1 filter to drop uniform/sky/overexposed tiles while flagging blaze-orange clusters, copies the incident profile into the run, and writes runs/<incident_id>/manifest.json. |
sar-ingest
Prepare one SAR incident directory of drone imagery for visual triage.
Inputs
Two values, both required. Ask the user if missing.
- Incident directory — absolute path to the folder of drone frames, e.g.
${CLAUDE_PROJECT_DIR}/assets/incident013
- Incident id — short slug, typically the directory name:
incident013
An incident profile at assets/<incident-id>/incident.json (not <incident-dir>/... — the canonical location is keyed by incident-id so sar-triage and sar-report find it automatically) describes the subject, terrain, region, and visual cues. The profile drives the triage prompt and the report's Known Limitations section — without it, downstream skills are generic and much less useful.
What to do
Step 0 — Ensure the incident profile exists
Before running the ingest script, check whether assets/<incident-id>/incident.json exists.
If it does: proceed to Step 1.
If it does not: interview the user to author one — do NOT skip this step, and do NOT fall back to the default profile without explicit permission. Ask for:
- Subject — one-sentence description, known clothing, equipment, concealment (e.g. "possibly wearing a ghillie suit"), any behavior notes (e.g. "likely injured and in cover").
- Terrain — short description, named area (e.g. "Mt Shavano, Chaffee County, CO"), approximate altitude range in meters, vegetation.
- Region prior — lat/lon of a likely center and a search radius in km. If the user doesn't know, pull the center from a representative frame's EXIF GPS.
- Visual cues — 3–8 things you'd look for in a tile, each with a weight (
decisive / strong / medium / weak). Decisive cues (e.g. blaze_orange) alone justify high confidence; strong cues need one clear instance; medium/weak compound. Include mission-specific cues the user mentioned (e.g. shotgun_barrel, ballcap_brim, ghillie_texture_break).
- Modality warnings — things RGB might miss (e.g. "a stationary ghillie-suited subject may defeat RGB; thermal is the better modality if available").
Write the completed profile to assets/<incident-id>/incident.json using the schema in _shared/incident_profile.py (DEFAULT_PROFILE is the canonical reference). Then proceed to Step 1.
Only if the user explicitly says "use the default / generic profile, I don't have mission details" may you pass --use-default-profile to the ingest script to skip this step.
Step 1 — Run the ingest script
Invoke the orchestrator once. It enumerates, extracts GPS, tiles, Stage-1 filters, copies the profile into the run as a frozen snapshot, and writes manifest.json. Paths below are project-relative; run from the project root (or use ${CLAUDE_PROJECT_DIR}/...):
python3 .claude/skills/sar-ingest/scripts/build_manifest.py \
--incident-dir <ABSOLUTE_PATH_TO_INCIDENT_DIR> \
--incident-id <INCIDENT_ID>
If the profile is missing and --use-default-profile was not passed, the script writes a TODO template to assets/<incident-id>/incident.json and exits with status 3. That's the signal that Step 0 was skipped — go fill in the template (or go back and interview the user) and re-run.
The script writes into <project_root>/runs/ by default. Override with --output-root or set the CLAUDE_PROJECT_DIR env var (Claude Code sets this automatically).
Expected runtime on a 250-frame incident: 5–20 min depending on core count. Progress prints to stderr. The manifest path prints to stdout.
Outputs
runs/<incident_id>/manifest.json — canonical manifest (project-relative). Schema version 1.
runs/<incident_id>/profile.json — copy of the incident profile used for this run.
runs/<incident_id>/tiles/<FRAME_ID>/tile_NN.jpg — 12 tiles per frame.
Report back to the user
- Path of the manifest.
- Total frames and total tiles.
- Stage-1 keep/skip counts with reason breakdown.
- Blaze-orange flagged tiles — list each
(frame_id, tile_index, pixel_count). A blaze-orange cluster in wilderness is nearly always a human.
- Next step: invoke
sar-triage.
Assumptions
- Frames are DJI high-resolution RGB (default 4056×3040). Other sensors require retuning the tile grid in
tile_frame.sh / tile_frame.py.
- EXIF GPS is populated on captures. Without GPS, the skill still completes;
gps carries an error marker and the report shows "no coordinates" for those frames.
If something goes wrong
- 0 frames found — check the frame resolution with
identify -format "%wx%h\n" <file>. The enumerator currently matches 4056×3040 exactly.
- Pillow missing — auto-fallback to the shell tiler. Slower but identical output.
Stage-1 filter reference
Any single rule matching a tile → skip:
| Reason | Rule (0–1 ImageMagick fx values) |
|---|
uniform_gray | stddev < 0.04 AND mean_sat < 0.15 |
sky_blue | mean_hue ∈ [0.55, 0.68] AND mean_sat ∈ [0.10, 0.55] AND mean > 0.55 AND stddev < 0.10 |
overexposed | mean > 0.93 AND stddev < 0.06 |
Blaze-orange detection runs independently on a 400×400 downsample of each tile. Count is stored in stage1.blaze_orange_pixels and does not trigger skip.
Manifest schema (key paths)
frames[].frame_id e.g. "DJI_0401"
frames[].source_path absolute path
frames[].parent_w / parent_h 4056, 3040
frames[].gps.lat/lon/alt_m floats
frames[].gps.timestamp_utc ISO8601 Zulu
frames[].tiles[].tile_index 0..11
frames[].tiles[].tile_path absolute path to tile JPG
frames[].tiles[].crop_box {x, y, w, h} in parent-frame pixels
frames[].tiles[].stage1.verdict "keep" | "skip"
frames[].tiles[].stage1.reason "texture_ok" | "uniform_gray" | "sky_blue" | "overexposed" | "stage1_error"
frames[].tiles[].stage1.blaze_orange_pixels int
frames[].tiles[].stage1.stats {mean, stddev, mean_hue, mean_sat}