Wildlife Photo Review Workflow

Wildlife Photo Review Workflow

How wildlife photos are triaged, curated, and added to the /wildlife/ gallery. This is the repeatable process to run whenever a new folder of raw photos arrives.

Canonical data model

  • <Photos>\Wildlife — curated originals for the gallery, from every camera, named by photo ID. <Photos> is the photo-library root (currently D:\Photos); the repo never stores it, so the library can move between machines and drives. Originals never enter the git repo.
    • Photo ID = capture time from EXIF DateTimeOriginal, YYYYMMDD-HHMMSS (e.g. 20261017-162524); add -2, -3 for different frames in the same second. It is camera-independent and doesn’t collide when counters reset or a new body is added. python scripts/build_wildlife_images.py id <photo> prints it. <Photos>\rename_map.csv maps the IDs from the 2026-10 migration back to the original camera filenames.
    • Invariant: every photo in the catalog (_data/wildlife.yml) must have <Photos>\Wildlife\<file>.JPG. The build reads originals only from there, so put originals there before building, and verify that no catalog entry lacks its original.
  • _data/wildlife.yml — single source of truth for the catalog:
    • highlights: — ordered list of photo IDs shown at the top of the page.
    • photos: — one entry per photo: file (photo ID), date, species, scientific, group (bird | mammal | reptile | amphibian | fish | insect | arachnid | crustacean), location ("" until filled), camera ("Nikon Coolpix P950" or "Nikon Z5II + NIKKOR Z 180-600mm"), notes. Kept reverse-chronological.
    • locations: — key / lat / lng rows that photo locations resolve to.
    • species_zh: — English common name → Chinese name (display only). Add a row for every new species.
  • images/wildlife/{web,thumb}/<file>.jpg — derivatives (1600 px / 400 px), generated by python scripts/build_wildlife_images.py build <Photos>\Wildlife (or set WILDLIFE_PHOTOS_DIR and omit the argument). The build only processes files listed in the YAML.
  • After any change, verify: catalog entries == web derivatives == thumb derivatives, with no orphans and nothing missing.

Photo folder layout

Under <Photos>:

  • One folder per camera with the raw files, untouched and keeping camera filenames: Nikon P950\ sorted into YYYY-MM month folders (EXIF DateTimeOriginal; file mtime for videos), and Nikon Z5-II 180-600mm\All\ with every frame (JPG + NEF).
  • Three curated folders, shared by all cameras, holding copies named by photo ID (the camera folders keep everything):
    • Wildlife\ — gallery originals; the build reads from here.
    • People\ and Scenary\ — good people and scenery shots picked in the side triage (step 2d). Not published; no catalog.

The review pipeline (run this for each new folder)

1. Generate stubs, excluding already-reviewed photos

For every *.JPG in the new folder, make a 400 px stub — but skip any frame whose photo ID is already in Wildlife, People or Scenary (those were reviewed in a prior round). Also capture EXIF DateTimeOriginal into a manifest.

Then group frames into bursts: sort by capture time and start a new burst whenever the gap to the previous frame exceeds ~30 s. Record a burst ID per file in the manifest. Bursts are the unit for the near-duplicate decisions below.

# 400px stub generator (run once per new folder). Note: on Windows the
# filesystem is case-insensitive, so glob EITHER "*.JPG" OR "*.jpg" — not both,
# or every file is processed twice.
from pathlib import Path
from PIL import Image
ROOT = Path(r"D:\Photos")  # photo-library root on this machine
SRC = ROOT / "<CAMERA>" / "<NEW_FOLDER>"; OUT = Path(r"<scratch>\stubs")
def pid(p):  # photo ID (without -n suffix) from EXIF capture time
    d = Image.open(p).getexif().get_ifd(0x8769).get(36867).strip("\x00 ")
    return d.replace(":", "").replace(" ", "-")
REVIEWED = {p.stem[:15] for d in ("Wildlife", "People", "Scenary")
            for p in (ROOT / d).glob("*.JPG")}
OUT.mkdir(parents=True, exist_ok=True)
for p in sorted(SRC.glob("*.JPG")):
    if pid(p) in REVIEWED: continue
    with Image.open(p) as im:
        im.load(); s = 400/max(im.size)
        (im if s>=1 else im.resize((round(im.width*s),round(im.height*s)), Image.LANCZOS)
         ).convert("RGB").save(OUT/f"{p.stem}.jpg","JPEG",quality=82)

2. Triage with Sonnet sub-agents

Split the stub list into batches of ~31 and dispatch one Sonnet sub-agent per batch (they run in parallel). Never split a burst across batches — let a batch run over or under ~31 to keep each burst whole, and give the agents the burst IDs. Each agent reads its batch list + stubs and, for every file, decides:

  • Is it wildlife?
    • INCLUDE free-ranging wild animals of any kind, including free-living introduced/invasive species (e.g., peacocks, iguanas, Egyptian geese, Brown Anoles in Miami).
    • EXCLUDE captive zoo/aquarium/safari-park animals (enclosures, tanks, signage, exotic non-native captives), pets, and non-animal photos (scenery, people, food, buildings, plants).
  • If wildlife: identify species, report ID confidence (high / med / low), rate quality 1–5, note behavior, verdict KEEP / DROP. Relax the quality bar for rare species or rare/interesting behavior.
  • If not wildlife: tag a category — people (people are the subject), scenery (landscape, cityscape, nature, architecture as a view), or other (food, documents, signs, captive animals, snapshots) — and for people / scenery rate quality 1–5 with a KEEP/DROP verdict for step 2d.
  • Judge bursts as a whole: give one KEEP/DROP verdict per burst (per subject, if a burst has more than one animal). Do not drop frames within a kept burst — 400 px is too small to judge sharpness; the frame is picked in step 2c.
  • Give the agents the current collection’s species list so they can flag (common in collection) for dedup.

Output: a table File | Date | Wildlife? | Category | Species | Confidence | Quality | Verdict | Reason plus a tally. Persist each batch’s result to a scratch file (long task — guards against context loss).

2b. Opus re-ID of non-high-confidence wildlife

For every photo marked wildlife whose ID confidence is not high (whatever its KEEP/DROP verdict), generate a 1000 px version from the original and have an Opus-level model (an Opus sub-agent, or the lead model if it is Opus) identify the species from it. The Opus ID replaces the Sonnet one, and the photo is then re-evaluated for KEEP/DROP and dedup in step 3. This catches new species that Sonnet misidentified as a common one (and would otherwise drop as a duplicate) or dismissed as too small to identify at 400 px.

2c. Pick the best frame per burst

For every kept burst, generate 1000–1200 px versions of all its frames and compare them directly (lead model, not the triage agents). Rank by, in order:

  1. Focus on the eye/head — critically sharp where it matters.
  2. No motion blur on the subject.
  3. Unobstructed subject — no branches, grass, or other animals across it.
  4. Pose and behavior — head turned toward camera, wings spread, feeding, etc.; prefer the more interesting moment.
  5. Framing — subject not clipped, reasonable size in frame.
  6. Exposure — no blown highlights on white plumage, shadows not crushed.

Carry the top frame forward. If the top 2–3 are close (different trade-offs, e.g. sharper vs. better pose), keep them as runners-up for step 5.

2d. Side triage: people and scenery

A light pass — no catalog, no derivatives, no human review needed.

  1. Take the people / scenery KEEPs (quality ≥ 4) from step 2.
  2. Per burst, view the frames at ~1000 px and pick one (two if they are genuinely different compositions). Criteria:
    • People: subject in focus, eyes open, natural expression, not cut off; prefer intentional portraits and group shots over passing snapshots.
    • Scenery: level horizon, clean composition, good light/exposure (no blown sky), sharp; prefer the strongest of near-identical views.
  3. Copy the picks’ originals into <Photos>\People or <Photos>\Scenary, renamed to their photo ID (copy, never move — skip any ID already there), and list what was copied in the round’s report.

3. Aggregate and dedup against the existing collection

Avoid near-duplicates, but don’t be stingy. Keep:

  • New species not yet in the catalog;
  • Up to ~3 photos per species in a round when each shows something different (behaviour, age/sex, pose, setting);
  • A species already in the catalog when the shot is good (quality ≥ 4) and different from what’s published, or from a new location.

Never keep two frames that differ only slightly.

4. Verify uncertain cases on the original yourself

Do not trust sub-agent IDs for anything going public. Generate ~1200 px versions of the shortlisted candidates and review them directly to confirm the species, choose between burst winners when one species has several bursts, and catch misidentifications. Sub-agents misidentify regularly (e.g. a cormorant called a “Bald Eagle”, a female Anhinga called a “Great Blue Heron”, a chameleon called a “katydid”) — make every reptile/amphibian ID yourself on the originals.

5. Raise borderline photos for human review (required)

Do not silently decide borderline cases. Before adding anything, present a short list to the user for a decision on every photo that is any of:

  • an uncertain species ID (including ones where you overrode a sub-agent),
  • a rare species/behavior kept at low quality (the relaxed-bar cases),
  • a possible duplicate of a species already in the collection,
  • a borderline quality call (obscured, distant, soft) that is otherwise a new species or notable shot,
  • a close best-frame call from step 2c — show the runners-up side by side with a one-line trade-off for each.

Give each a one-line reason and a recommended pick, and let the user confirm, swap the frame, or drop it. Only the clearly-good, clearly-new-species photos may be added without asking. The user often swaps in a better frame or keeps a record the pipeline set aside.

6. Add to the collection

  1. Required: copy every selected original from the new folder into <Photos>\Wildlife, renamed to its photo ID, first.
  2. Add entries to _data/wildlife.yml (reverse-chronological; location: "" unless known; camera from the EXIF model), plus a species_zh row for each new species and a locations row for any new location.
  3. python scripts/build_wildlife_images.py build <Photos>\Wildlife
  4. Verify counts/consistency (catalog == web == thumb, no orphans/missing) and that every catalog file has its original in <Photos>\Wildlife.
  5. Commit (originals stay out of git; only YAML + derivatives are committed).

Conventions & reminders

  • Quality bar: well-shot, subject prominent, in focus — except relax for rare species and rare behavior (say so in the notes when you do).
  • Invasive vs captive: free-ranging invasives are in; captive animals are out.
  • Locations are added manually (photos have no GPS EXIF); the map shows only photos whose location resolves to a lat/lng in the locations: table.
  • No Ruby/Jekyll on this machine — verify the GitHub Pages build after pushing rather than rendering locally.