A command-line tool that analyses a macOS Photos library for stock photo suitability using a locally-running Ollama vision model. Photos are scored on technical quality and commercial appeal, producing a prioritised report of submission candidates.
Everything runs locally β no cloud services, no API costs, no data leaving the machine.
- Reads directly from the macOS Photos library (no export step needed)
- Analyses most recent photos first
- Skips already-analysed photos on subsequent runs (incremental)
- Filters by album, date range, or photo count
- Scores each photo on technical quality and commercial appeal (1β5 each)
- Recommends: submit, maybe, or skip
- Outputs JSON (default) or CSV; optional Markdown summary via
--markdown - Handles iCloud-only photos gracefully
- macOS (Photos.app and the Photos library are macOS-only)
- Homebrew β used to install all dependencies
- Python 3.10+
- Ollama and a vision model β installed automatically by
setup.sh
git clone https://github.com/ali5ter/photo-scout.git
cd photo-scout
./setup.sh
source .venv/bin/activatesetup.sh is idempotent β safe to re-run at any time. It installs via Homebrew:
pfb, Ollama, jq, and exiftool; starts the Ollama
service; pulls the vision model; and sets up the Python virtual environment.
To use a different model at setup time:
./setup.sh moondream # faster, smaller β good for testing
./setup.sh llava-phi3 # good middle ground
./setup.sh llava:7b # default, best quality (needs ~4 GB free RAM)git clone https://github.com/ali5ter/photo-scout.git
cd photo-scout
./setup.sh
source .venv/bin/activateBuild the CLIP reference set (takes ~5 min β downloads 200 Wikimedia featured photos):
python build_reference.py --download 200Optionally supplement with your own known-good stock photos to improve CLIP scoring for your subjects:
python build_reference.py --source ~/path/to/good-stock-photos --download 100Create an album called Stock Candidates (or any name) in Photos.app and drag in photos you think have potential. This pre-filters the analysis to photos worth scoring.
If those photos are iCloud-only (Photos shows a cloud icon), download them first:
osxphotos export /tmp/stock-dl --album "Stock Candidates" \
--download-missing --use-photokitThis forces Photos to pull the originals to disk. Run the analysis immediately after β iCloud will re-offload them automatically when storage is needed.
python photo_scout.py --album "Stock Candidates"Subsequent runs skip already-analysed photos. Use --force to re-score everything:
python photo_scout.py --album "Stock Candidates" --forceOpen the Markdown report for a human-readable summary (re-run with --markdown to generate it):
python photo_scout.py --album "Stock Candidates" --markdown
open photo-scout-report.mdOr tag photos directly in Photos.app for visual browsing:
./tag-photos.shThen search for photo-scout-submit in Photos.app, or create a Smart Album with that keyword.
See STOCK-PHOTO-RULES.md before submitting β covers model/property releases, copyright, and logo rules.
Copy recommended photos with IPTC/XMP metadata embedded:
./embed-metadata.sh # submit recommendations only
./embed-metadata.sh --filter maybe # submit + maybe
./embed-metadata.sh --organize # all photos in submit/ maybe/ skip/ subfoldersTagged copies land in ready-to-submit/. Upload directly to Adobe Stock, Shutterstock,
Alamy, or Pond5.
| Model | Size | Speed (Apple Silicon, 8 GB) | Quality |
|---|---|---|---|
moondream |
1.7 GB | Fast (~5β10 s/image) | Pipeline testing only β too small for real analysis |
llava-phi3 |
2.9 GB | Moderate (~15β30 s/image) | Good |
llava:7b |
4.1 GB | Slow (~30β60 s/image) | Best (recommended) |
On Apple Silicon with 8 GB unified memory, llava:7b (4 GB quantized) fits comfortably alongside
the OS. It is slow but produces genuinely useful analysis.
moondream warning: moondream is too small to follow structured prompts reliably. It tends to echo the JSON template rather than analyse the image, producing identical scores and placeholder keywords for every photo. Use it only to verify the pipeline runs end-to-end, then switch to
llava:7bfor real analysis.
To add or switch models, use setup.sh rather than ollama pull directly β it ensures the
full environment is consistent:
./setup.sh llava:7b# Analyse the 20 most recent locally-available photos
python photo_scout.py --model moondream --limit 20
# Filter to a specific album
python photo_scout.py --album Landscapes
# Only photos taken since a date
python photo_scout.py --since 2024-01-01
# Force re-analysis of everything (ignore existing report)
python photo_scout.py --force
# Use CLIP stock-likeness scoring (requires clip-reference-embeddings.npy)
python photo_scout.py --clip-reference clip-reference-embeddings.npy
# Also write a human-readable Markdown report alongside the JSON
python photo_scout.py --markdown
# Output as CSV instead (for sorting in Numbers/Excel)
python photo_scout.py --format csvFull option reference:
--library PATH Path to Photos library (default: system default)
--album NAME Filter to photos in this album (exact title match)
--model MODEL Ollama model (default: llava:7b)
--output FILE Output file written to current directory (default: photo-scout-report.json)
--format FORMAT json | csv (default: json)
--markdown Also write a Markdown report alongside the primary output
--limit N Max photos to process (selects most recent N)
--since YYYY-MM-DD Only photos taken on or after this date
--force Re-analyse all photos, ignoring any existing report
--clip-reference FILE Path to CLIP reference embeddings (from build_reference.py)
All output is written to the current working directory β wherever you run the script from.
Running from the project directory (photo-scout/) is recommended so the report is alongside
the other scripts.
| File | Created by | Description |
|---|---|---|
photo-scout-report.json |
photo_scout.py |
Analysis results (gitignored) |
ready-to-submit/ |
embed-metadata.sh |
Tagged copies of recommended photos (gitignored) |
ready-to-submit/submit/ |
embed-metadata.sh --organize |
Tagged submit photos in subfolder (gitignored) |
ready-to-submit/maybe/ |
embed-metadata.sh --organize |
Tagged maybe photos in subfolder (gitignored) |
ready-to-submit/skip/ |
embed-metadata.sh --organize |
Tagged skip photos in subfolder (gitignored) |
Each photo receives two scores on a 1β5 scale:
technical_score is computed deterministically from the image using PIL/NumPy β no model
involved. It combines sharpness (90th-percentile edge magnitude), exposure (clipping, brightness,
dynamic range), and noise (flat-region variance), weighted 50/30/20%.
commercial_score comes from the Ollama vision model, which assesses market demand, concept
clarity, and licensing suitability.
| Score | technical_score | commercial_score |
|---|---|---|
| 1 | Blurry, badly exposed, or unusable | Identifiable people without releases, copyrighted elements, or no commercial use |
| 2 | Noticeably soft, poorly exposed, or distracting noise | Personal snapshot, documentary, or tourist shot unlikely to sell |
| 3 | Acceptable but soft, slightly off-exposure, or minor noise β borderline | Niche or generic subject with limited buyers |
| 4 | Sharp and clean β meets stock site technical bar | Solid commercial appeal with an identifiable buyer market |
| 5 | Tack sharp, perfect exposure, no visible noise, professional composition | Strong sellable concept β business, lifestyle, nature, travel, technology |
overall_score is the mean of technical_score and the effective commercial signal (1.0β5.0).
When --clip-reference is active and clip_score β₯ 2.5, clip_score replaces commercial_score
as the commercial signal; otherwise commercial_score is used. The 2.5 floor prevents near-floor
CLIP scores (which reflect reference set gaps rather than real similarity) from overriding a strong
commercial_score.
recommendation is determined by strict score thresholds:
- submit β both
technical_scoreand the commercial signal are 4 or above - maybe β both scores are 3 or above, but not both 4+; review manually before deciding
- skip β either score is below 3, or the photo fails stock site standards
Thresholds are enforced in code, so a submit can never appear with scores below 4/4
regardless of what the model says.
The report file accumulates results across runs. On each run, photos already present in the report are skipped automatically. This means you can run in batches:
# First pass β 50 most recent photos
python photo_scout.py --limit 50
# Next pass β picks up the next 50 not yet in the report
python photo_scout.py --limit 50
# Re-analyse everything from scratch
python photo_scout.py --forcePhotos stored only in iCloud (offloaded to save local disk space) are skipped automatically, and the count is reported. To include them:
- Download everything: in Photos.app go to Settings β iCloud and select Download Originals to this Mac (requires sufficient disk space)
- Download selectively via osxphotos (works on macOS 26+ where right-click download is absent):
mkdir -p /tmp/photos-export
osxphotos export /tmp/photos-export \
--album "Album Name" \
--download-missing \
--use-photokit \
--limit 20Run photo_scout.py immediately after β iCloud re-offloads photos on its own schedule
if Optimise Mac Storage is enabled.
Photos.app stores originals under internal UUID-based filenames (e.g.
4A2F3D8E-1234-5678-ABCD-9012EFAB.HEIC) that are not searchable in Photos.app.
The report includes two fields to help you map entries back to actual images:
| Field | Example | How to use |
|---|---|---|
original_filename |
IMG_1234.HEIC |
Search by filename in Photos.app |
uuid |
4A2F3D8E-... |
Used by tag-photos.sh for precise Photos.app lookup |
Two workflows let you browse photos visually alongside the report:
embed-metadata.sh --organize copies every analysed photo into
ready-to-submit/submit/, ready-to-submit/maybe/, and ready-to-submit/skip/
subfolders. Open the folder in Finder alongside your Markdown report to see
photos and scores side by side.
./embed-metadata.sh --organizetag-photos.sh sets a Photos.app keyword on each photo matching its
recommendation: photo-scout-submit, photo-scout-maybe, or photo-scout-skip.
Once tagged, open Photos.app and search for a keyword to browse the photos
visually β or create a persistent Smart Album.
./tag-photos.shPhotos.app must be open when the script runs. Existing user keywords on photos
are preserved; re-running updates the keyword without duplicating it. Use
--clear to remove all photo-scout keywords.
--report FILE Path to photo-scout JSON report (default: photo-scout-report.json)
--prefix NAME Keyword prefix (default: photo-scout)
--clear Remove all photo-scout keywords without re-tagging
--help Show this help
To browse after tagging:
- Open Photos.app and type
photo-scout-submitin the search bar β all submit candidates appear visually. - For a permanent sidebar view: File β New Smart Album, set the filter to
Keyword contains
photo-scout-submit, and save. Repeat formaybeandskipif wanted.
Once you have a report you're happy with, embed-metadata.sh reads it and:
- Filters photos by recommendation level (
submit,maybe, orall) - Copies each qualifying original from the Photos library to an output directory
- Uses
exiftoolto embed the model's keywords and caption into the copy as IPTC and XMP tags
Originals in the Photos library are never modified β the script only works on copies.
./embed-metadata.sh # submit recommendations only (default)
./embed-metadata.sh --filter maybe # submit + maybe
./embed-metadata.sh --organize # all photos in submit/ maybe/ skip/ subfolders
./embed-metadata.sh --report my-report.json --output ~/Desktop/stock-uploadsFull option reference:
--report FILE Path to photo-scout JSON report (default: photo-scout-report.json)
--output DIR Output directory for tagged copies (default: ready-to-submit/)
--filter LEVEL submit | maybe | all (default: submit)
--organize Create submit/ maybe/ skip/ subfolders (implies --filter all)
--help Show this help
Filter levels:
| Filter | What's included |
|---|---|
submit |
Only photos the model recommends for submission |
maybe |
Submit and maybe recommendations |
all |
Every analysed photo, including skip |
The tagged copies in ready-to-submit/ (or your chosen output directory) are ready to upload
directly to stock platforms. Each file has IPTC keywords, an IPTC caption, and XMP equivalents
embedded β the standard metadata fields that Adobe Stock, Alamy, and Shutterstock read on import.
Run embed-metadata.sh from the project directory so it can find the default report and write
the output directory relative to your current location.
The Ollama vision model scores commercial appeal by reasoning about the image, but a small model
like llava:7b has limited commercial intuition. As an alternative, build_reference.py builds
a CLIP embedding set from photos that are known to meet stock photo standards β then
photo_scout.py --clip-reference scores each new photo by similarity to that reference set.
What CLIP scoring does:
- Compares your photo to a reference set of accepted stock-quality images using cosine similarity
- Produces a
clip_score(1β5) that reflects how closely the photo's subject and style match the reference set - When
--clip-referenceis provided,clip_scorereplaces the model'scommercial_scorefor recommendation thresholds andoverall_scoreβ the vision model still runs fortechnical_score,subject,keywords, andreason
What CLIP scoring cannot do:
- It measures subject/style similarity, not technical quality (that remains
technical_score) - A great photo of a rare subject may score low if the reference set has few similar examples
- It is not a replacement for human review of borderline cases
Download 200 featured photos from Wikimedia Commons and compute embeddings:
python build_reference.py --download 200Optionally add your own known-good stock photos:
python build_reference.py --source ~/my-reference-photos --download 100Full option reference:
--source DIR Local directory of reference photos to embed
--download N Download N featured photos from Wikimedia Commons (no API key needed)
--output FILE Output .npy file (default: clip-reference-embeddings.npy)
--download-dir DIR Cache for downloaded photos (default: clip-reference/)
Wikimedia Commons Featured Pictures are hand-curated by Wikipedia editors for technical quality β a useful proxy for stock photo standards, freely available with no authentication required.
Pass the embeddings file when running photo_scout.py:
python photo_scout.py --clip-reference clip-reference-embeddings.npyThe per-photo output line gains a clip: field:
[1/10] IMG_1234.HEIC β submit tech:4 comm:3 clip:4.2 78%
The JSON report gains a clip_score field alongside commercial_score. Use --force to re-score
photos already in the report with the new CLIP signal.
| Reference photos | Accuracy | Notes |
|---|---|---|
| 50β100 | Low | Useful for basic filtering |
| 200β500 | Good | Recommended starting point |
| 1000+ | Best | More diverse coverage of subjects |
Sites that pay per download (unlike Unsplash, which is free):
- Adobe Stock β large audience, fair royalties
- Shutterstock β high volume, lower per-download rate
- Alamy β accepts editorial/niche content, higher royalties
- Pond5 β strong for video, also accepts photos
- HOW-IT-WORKS.md β technical deep-dive with pipeline flow diagrams covering scoring architecture, CLIP theory, and post-processing workflows
- STOCK-PHOTO-RULES.md β practical reference for model/property releases, architectural copyright, logos/trademarks, and editorial vs commercial licences
MIT β see LICENSE.