Commercial RAW Photo Culling Engine (Shoot Intel)

Pillow + Claude 3.5 Sonnet 47/47 Tests · 2.31s ~900 Python LOC Production · Animoart

A 7-phase CLI pipeline that ingests commercial editorial shoots, filtering unusable RAW frames locally via Pillow pixel math before making multimodal vision API calls.


Manual Review of RAW Frames

Commercial editorial shoots generate thousands of frames per session. Photographers fire continuous high-speed bursts to capture a single fleeting expression, dynamic model turn, or product splash. When the memory cards dump onto studio storage, an assistant or lead photographer spends 2 to 4 hours manually flagging blurry shots, isolating blink frames, and separating candidates from discards.

Blindly dumping 1,200 45-megapixel RAW files (~42GB) into multimodal vision APIs like Claude 3.5 Sonnet costs over $12.00 in token fees per session, saturates studio bandwidth, and wastes API spend rating frames that were out-of-focus before they even left the camera sensor.


Filtering by Blur and Exposure

The core architectural insight: optical sharpness and exposure balance are deterministic physical properties. A 3x3 Laplacian edge convolution and a 256-bin luminance histogram can classify whether a frame is blurry, underexposed, or clipped in less than 4 milliseconds on local CPU.

No language model should ever be asked: “Is this photo in focus?” It costs money, introduces latency, and cannot match the mathematical precision of variance over pixel gradients.

Filter aggressively at the local edge with local operations. Only promote the top 2 sharpest candidates from each temporal burst cluster to multimodal vision evaluation.

By enforcing local rejection gates, 62% of raw frames are resolved on local disk with zero network requests. Studio API spend drops from ~$12.00 to ~$4.20 per shoot session, while photographer review time collapses from 3 hours to 20 minutes.


Local Pre-Flight Culling to Lightroom XMP

Shoot Intelligence executes a 7-phase CLI pipeline: it scans media cards for RAW files, extracts sub-second EXIF timestamps and embedded thumbnail previews, runs Laplacian blur variance (< 120.0 rejected locally) and 256-bin histogram checks, clusters remaining frames into bursts within a 2.0-second temporal window, routes the top candidates to Claude 3.5 Sonnet for expression scoring, and exports standard Adobe XMP sidecar files directly alongside the RAW binaries.

The original RAW files are treated as strictly read-only. When the photographer opens Lightroom Classic, star ratings (1 to 5) and color labels (Green for KEEP, Red for REJECT) appear instantly upon folder synchronization.

SAMPLE OUTPUT · cull_summary.json62% local rejection
{
  "session_dir": "/Volumes/EOS_R5/DCIM/100CANON",
  "total_frames": 1148,
  "local_rejected_blur": 524,
  "local_rejected_exposure": 188,
  "burst_clusters_formed": 84,
  "sent_to_vision_api": 252,
  "api_spend_usd": 4.18,
  "xmp_sidecars_written": 1148,
  "cull_duration_seconds": 118.4
}
04 — The Architecture

7-Phase Local Computer Vision & Multimodal Culling Pipeline

Each phase is isolated with defined inputs, outputs, and strict invariants. Local CPU filters gate cloud API calls so multimodal vision is only invoked on viable candidates. Click any phase card to inspect its implementation, input state, and output state.

RAW Ingest ($0.00) → Laplacian Blur ($0.00) → Exposure Screening ($0.00) → Burst Cluster ($0.00) → Pre-API Gate ($0.00) → Claude Vision (~$4.20) → XMP Export ($0.00)

SI
shoot_intelligence.pipeline:run_cull_pipeline
7 Sequential Phases•Pillow•Claude 3.5 Sonnet•XMP Writer
Swipe horizontally · Tap stage to inspect below ↓
Phase 01 · IngestRAW Files
Ingestion
phase_ingest()
Local I/O
Phase 02 · BlurLaplacian Variance
Blur Triage
phase_blur_variance()
Threshold < 120
Phase 03 · Exposure256-Bin Hist
Exposure Triage
phase_exposure()
Clipping Check
Phase 04 · Burst2.0s Window
Burst Cluster
phase_cluster_bursts()
Temporal Sort
Phase 05 · ScreenDeterministic Gate
Pre-API Screen
phase_filter_api()
Top 2 Frames
Phase 06 · VisionClaude 3.5 Sonnet
Vision Eval
claude_vision()
~$0.01 / Frame
Phase 07 · ExportLightroom Sidecar
XMP Export
phase_generate_xmp()
Read-Only RAW

Phase 01 — RAW Ingestion

shoot_intelligence.phases.ingest
Runtime: Python 3.12 (exifread)Compute: Local I/O ($0.00)
What This Stage Does

Recursively scans camera media cards for RAW files (.CR3, .ARW, .NEF, .DNG). Extracts EXIF capture timestamps with sub-second precision, shutter speeds, ISO, lens focal length, and embedded thumbnail previews.

🛡️Invariant
Zero RAW File Mutation: The original camera RAW binary is treated as strictly read-only. Embeds zero data into the original files; all tags are written to external .xmp sidecars.
Code · Input State · Output State

            

Technical Architecture Decisions

Decision 01

Laplacian Variance as the Local Sharpness Gate

Convolving an image thumbnail with a 3x3 Laplacian kernel computes the second spatial derivative of pixel intensity. In focus, high-contrast edge gradients produce massive variance across the image matrix. Out of focus, edge blur smooths the gradients and variance collapses toward zero.

Shoot Intelligence computes ImageStat.Stat(thumb.filter(LAPLACIAN_KERNEL)).var[0] in pure Pillow. If the variance is below 120.0, the frame is tagged REJECT_BLUR locally. Zero cloud API calls. Under 4 milliseconds per frame.

blur.py · Laplacian Convolution GateLocal CPU
from PIL import Image, ImageFilter, ImageStat

LAPLACIAN_3x3 = ImageFilter.Kernel((3, 3), [0, 1, 0, 1, -4, 1, 0, 1, 0], scale=1, offset=0)
BLUR_THRESHOLD = 120.0

def phase_blur_variance(thumb: Image.Image) -> float:
    # Pure local convolution — zero OpenCV binary dependency required
    gray = thumb.convert("L")
    edges = gray.filter(LAPLACIAN_3x3)
    stat = ImageStat.Stat(edges)
    return stat.var[0]  # Variance across high-frequency edge gradients
Blur detection runs locally using Laplacian variance. Out-of-focus frames are filtered before any API call.
Decision 02

Temporal Burst Clustering with a 2.0-Second Sliding Window

Commercial photographers shoot in high-speed bursts: 6 to 12 frames in 2 seconds of the exact same setup. Sending every frame in a burst to a vision model wastes money evaluating negligible micro-variations.

The clustering algorithm groups consecutive frames whose EXIF capture timestamps differ by ≤ 2.0 seconds. When a gap exceeding 2.0 seconds is encountered, the current cluster closes and a new one begins. Within each cluster, frames are ranked by sharpness, and only the top 2 candidates advance to Claude Vision.

burst.py · Sliding Window ClusteringLocal algorithm
def phase_cluster_bursts(assets: list[RawAsset], delta_sec: float = 2.0) -> list[BurstCluster]:
    if not assets:
        return []
    clusters = []
    current = [assets[0]]
    for next_asset in assets[1:]:
        gap = (next_asset.timestamp - current[-1].timestamp).total_seconds()
        if gap <= delta_sec:
            current.append(next_asset)
        else:
            clusters.append(BurstCluster(members=current))
            current = [next_asset]
    clusters.append(BurstCluster(members=current))
    return clusters
Burst clustering groups continuous takes and reduces redundant frames before API dispatch.
Decision 03

Lightroom-Compatible XMP Sidecars (Zero RAW File Mutation)

In commercial studio environments, touching or modifying original RAW binaries (.CR3, .ARW, .DNG) violates archival contracts and risks file corruption. Camera media must remain bit-for-bit identical to the sensor dump.

Shoot Intelligence generates standard Adobe XML sidecars (DSC_4825.xmp) alongside each RAW frame. It writes standard xmp:Rating (1 to 5) and photoshop:ColorLabel (Green, Yellow, Red) tags. When the photographer imports the folder into Lightroom Classic, all cull flags appear instantly with zero import plugins needed.

export.py · Adobe XMP Sidecar WriterLocal I/O
def phase_generate_xmp(asset: RawAsset) -> Path:
    xmp_path = asset.path.with_suffix(".xmp")
    xmp_content = render_xmp_template(asset.final_star_rating, asset.color_label)
    xmp_path.write_text(xmp_content, encoding="utf-8")
    return xmp_path
Original files are never modified. Ratings are written to XMP sidecars, and duplicate writes are idempotent.

Run the Full Suite Locally in Under 3 Seconds

TERMINAL · LOCAL REPRODUCTION 47/47 Tests Passing (2.31s)
# 1. Clone and install git clone https://github.com/Eaterofsouls/shoot-intelligence.git cd shoot-intelligence && pip install -e ".[dev]" # 2. Run the test suite offline — zero API keys required pytest -v tests/ ✓ 47 passed in 2.31s # 3. Run a session cull with local pre-flight screening shoot-intel cull --session /Volumes/EOS_R5_CARD/DCIM/100CANON --threshold 0.72

The full test suite runs offline against synthesized mock RAW EXIF records. Zero API keys, zero network calls, zero token spend required to verify the implementation.