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 (currentlyD:\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,-3for 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.csvmaps 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.
- Photo ID = capture time from EXIF
_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/lngrows that photolocations 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 bypython scripts/build_wildlife_images.py build <Photos>\Wildlife(or setWILDLIFE_PHOTOS_DIRand 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 intoYYYY-MMmonth folders (EXIFDateTimeOriginal; file mtime for videos), andNikon 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\andScenary\— 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), orother(food, documents, signs, captive animals, snapshots) — and forpeople/sceneryrate 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:
- Focus on the eye/head — critically sharp where it matters.
- No motion blur on the subject.
- Unobstructed subject — no branches, grass, or other animals across it.
- Pose and behavior — head turned toward camera, wings spread, feeding, etc.; prefer the more interesting moment.
- Framing — subject not clipped, reasonable size in frame.
- 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.
- Take the
people/sceneryKEEPs (quality ≥ 4) from step 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.
- Copy the picks’ originals into
<Photos>\Peopleor<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
- Required: copy every selected original from the new folder into
<Photos>\Wildlife, renamed to its photo ID, first. - Add entries to
_data/wildlife.yml(reverse-chronological;location: ""unless known;camerafrom the EXIF model), plus aspecies_zhrow for each new species and alocationsrow for any new location. python scripts/build_wildlife_images.py build <Photos>\Wildlife- Verify counts/consistency (catalog == web == thumb, no orphans/missing) and that every catalog
filehas its original in<Photos>\Wildlife. - 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
locationresolves to a lat/lng in thelocations:table. - No Ruby/Jekyll on this machine — verify the GitHub Pages build after pushing rather than rendering locally.
