A one-time report counting and listing which active players across the four major US pro sports leagues (NFL, MLB, NBA, NHL) have a full beard in their current official headshot, using a vision model — Claude by default, or a local/remote alternative (Ollama, Open WebUI, OpenRouter).
This is a fun, one-off project — not a persistent pipeline, and not meant to be 100% accurate. See beard-classifier-brief.md for the original scope.
-
fetch_rosters.pypulls active rosters + headshot URLs from each league's public stats API/CDN:- NFL / NBA: ESPN's site API (
site.api.espn.com) - NHL:
api-web.nhle.com - MLB:
statsapi.mlb.com+ themidfield.mlbstatic.comheadshot CDN
Known placeholder headshots (e.g. ESPN's generic college-football silhouette for players without a real NFL photo yet) and players with no headshot at all are flagged/logged rather than silently classified. Output:
players.csv. - NFL / NBA: ESPN's site API (
-
classify.pysends each real headshot to a vision model with the project's full-beard rubric, run concurrently across a thread pool. Output: a CSV withclassification(full_beard/not_full_beard/unclear),confidence, and per-photoreasoning.Four backends, picked with
--backend:claude(default) — Anthropic API, Haiku 4.5 by default. NeedsANTHROPIC_API_KEY. Uses native JSON-schema-enforced structured output.ollama— a local model via a running Ollama server (ollama serve, defaulthttp://localhost:11434, override withOLLAMA_HOST). No API key or per-call network cost; only the photo fetch still goes out to the league's CDN. JSON shape is enforced via Ollama'sformat: "json"plus an in-prompt schema description — a weaker guarantee than Claude's native schema enforcement, so malformed output is retried rather than impossible.openwebui— any remote OpenAI-compatible chat completions endpoint (built for Open WebUI, but works against LM Studio, vLLM's OpenAI server, LiteLLM, etc. too). NeedsOPENWEBUI_BASE_URL(+ optionalOPENWEBUI_API_KEYif the endpoint requires auth) and an explicit--model— there's no universal default model for an arbitrary remote endpoint.openrouter— OpenRouter's hosted OpenAI-compatible API. NeedsOPENROUTER_API_KEY. Defaults--modeltodeepseek/deepseek-v4-flash-0731, but that specific model is text-only — OpenRouter returns a clean404 No endpoints found that support image inputif you actually try to classify a photo with it, so pass a vision-capable model explicitly (see note below).--modelaccepts any OpenRouter model slug; checkGET https://openrouter.ai/api/v1/modelsand filter onarchitecture.input_modalitiescontaining"image"before picking one.
openwebuiandopenroutershare the same request logic (an OpenAI-compatible chat completions call withresponse_format: {"type": "json_object"}— enforced-valid-JSON but not schema-guaranteed, so malformed output is retried rather than impossible) and the same fail-fast behavior on 401/403/402 instead of burning through retries on an error that won't fix itself.
No images are downloaded to disk or rehosted. The Claude backend streams the photo URL directly to the API; the other three fetch the bytes in-memory, base64-encode them for the call, and discard them.
pip install requests
pip install anthropic # only needed for the claude backend
# .env file (gitignored) -- only set what the backend(s) you use need:
cat >> .env <<'EOF'
ANTHROPIC_API_KEY=sk-ant-...
OPENWEBUI_BASE_URL=https://your-openwebui-host.example.com
OPENWEBUI_API_KEY=your-api-key
OPENROUTER_API_KEY=sk-or-...
EOF
python fetch_rosters.py # all 4 leagues -> players.csv
python fetch_rosters.py nfl # just one league
# hosted (Claude, default)
python classify.py players.csv results.csv --workers 8
python classify.py players.csv results.csv --limit 20 # quick sample run
# local (Ollama) -- pull a vision-capable model first
ollama pull llava
python classify.py players.csv results.csv --backend ollama --workers 2
python classify.py players.csv results.csv --backend ollama --model llava:13b
# remote OpenAI-compatible endpoint (Open WebUI, etc.) -- --model is required
python classify.py players.csv results.csv --backend openwebui --model llava:13b
# OpenRouter -- pick a vision-capable model, the default is text-only
python classify.py players.csv results.csv --backend openrouter --model "google/gemma-4-26b-a4b-it:free"Progress prints as it goes: done/total (%) | elapsed | rate/s | eta, throttled to about once every 3 seconds, plus a final line with total elapsed time.
Six backend/model combinations were actually tested against real headshots before settling on one for the full run. Summary, most to least viable:
| Backend / model | Speed | Reliability | Quality | Verdict |
|---|---|---|---|---|
claude / Haiku 4.5 |
Fast (full ~4,650-photo batch: minutes) | 0 errors across every test, including a full production run | Best — specific, correctly-grounded reasoning; consistently applied the goatee/stubble exclusions | Use this. |
openwebui / Gemma 4 26B A4B (self-hosted, VPN) |
~16-20s/photo | 0 errors | Good — agreed with Claude on every shared test case | Viable if you have the hardware and hours; ~20-26hr for the full batch |
openrouter / google/gemma-4-26b-a4b-it:free |
~3s/photo when working | Unusable at batch scale — see below | Good, matched Claude when it responded | Fine for a handful of requests, not a batch job |
ollama / llava (7b, default) |
Fast | Reliable | Weak — repetitive boilerplate reasoning, at least one flipped call vs. Claude | Zero-dependency fallback only |
openwebui / Gemma 4 E4B (self-hosted) |
Slower than the 26B model (likely a cold-start/model-swap cost) | Reliable | Worse than the 26B model — missed a goatee case it should have caught | Skip — smaller isn't better here |
ollama / gemma4:26b (pulled locally) |
~41s/photo — slowest tested | 25% hard failures in an 8-photo sample (malformed JSON, timeout) | Worst tested — 0 of 8 photos came back clean; empty reasoning on half, a leaked <channel|> chat-template token on one, a @@@,@@@,@@@... repetition-loop degeneration on another |
Skip — Ollama's default quantization of this model is meaningfully worse than the same model served properly |
openrouter / deepseek/deepseek-v4-flash-0731 (the README's original default) |
N/A | N/A | N/A | Hard blocker, not a tuning issue — this specific model is text-only; OpenRouter returns a clean 404 No endpoints found that support image input for any vision request against it |
The free Gemma model looked like the best option after early testing (free, fast, accurate) and was used for a real attempt at the full 4,648-photo batch. It failed almost completely: 4,594 of 4,648 requests errored (98.8%), all but 6 of them 429 Too Many Requests. Root cause, confirmed from the response headers/body on a deliberately-triggered 429:
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 0
"Rate limit exceeded: free-models-per-day. Add 5 credits to unlock 1000 free model requests per day"
This is a daily quota (50 requests/day on the free tier, not a per-minute rate limit), and no amount of retry/backoff logic can work around it — the run's 54 successes line up almost exactly with the 50-request cap. Small smoke tests (5-15 photos) never revealed this because they never got close to the daily limit. Adding $5 in credits raises the cap to 1,000/day, which still isn't enough to classify ~4,650 photos in one sitting — it'd take roughly 5 days of quota resets. Conclusion: OpenRouter's free tier is fine for occasional/interactive use, not for a batch job this size, regardless of which free model is used.
Pulled directly via ollama pull gemma4:26b (18GB, official Google release, MoE with 3.8B active params — the same architecture as the remote Gemma 4 26B A4B that performed well elsewhere) to see if running it locally avoided the OpenRouter quota problem. It did avoid the quota problem, but introduced worse ones: on an 8-photo sample, 2 hard-failed (malformed JSON from the model, a 120s read timeout) and of the remaining 6 "successful" classifications, 4 had empty reasoning strings despite the prompt explicitly requiring grounded reasoning, 1 leaked an internal chat-template artifact (<channel|>) into the output, and 1 degenerated into a token repetition loop (@@@,@@@,@@@,...) instead of real reasoning. Zero of the 8 came back exactly as specified. This looks like a quantization/serving-quality issue specific to Ollama's default pull of this model, not a capability limit of the model itself — the same model served by Open WebUI or OpenRouter never showed this behavior.
Counts as a full beard:
- Continuous hair growth connecting cheeks, jawline, and chin
- Visibly grown-in (not stubble) — has actual length/density, not just shadow
Does NOT count:
- Mustache only, goatee only, "neck beard," 5 o'clock shadow/stubble, or sideburns-only with a bare chin/cheeks
Ambiguous cases are classified unclear with a confidence score and short reasoning rather than forced into a binary bucket.
Concluded as a pipeline/backend experiment — no full classification run was completed, and none is currently planned. fetch_rosters.py was validated for all 4 leagues (5,040 active players, 392 NFL placeholder headshots correctly flagged and excluded, leaving 4,648 real photos), and classify.py was validated end-to-end on claude (78 photos, 0 errors, the only backend confirmed reliable at any real volume).
The interesting part of this project turned out to be the backend/model comparison, not the beard count itself: four free/local alternatives to Claude were built and tested in an attempt to avoid paying for the full batch (~$12-18 on Haiku 4.5), and every one of them hit a real, only-visible-at-scale problem — see Backend testing notes above. Paying for the Claude run was judged not worth it for a one-off joke project, so the full report was never generated. The code is left in a working, documented state if anyone wants to actually run it later: python classify.py players.csv results.csv (default claude backend) is the whole remaining step.