Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pro Sports Beard Census

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.

How it works

  1. fetch_rosters.py pulls 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 + the midfield.mlbstatic.com headshot 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.

  2. classify.py sends each real headshot to a vision model with the project's full-beard rubric, run concurrently across a thread pool. Output: a CSV with classification (full_beard / not_full_beard / unclear), confidence, and per-photo reasoning.

    Four backends, picked with --backend:

    • claude (default) — Anthropic API, Haiku 4.5 by default. Needs ANTHROPIC_API_KEY. Uses native JSON-schema-enforced structured output.
    • ollama — a local model via a running Ollama server (ollama serve, default http://localhost:11434, override with OLLAMA_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's format: "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). Needs OPENWEBUI_BASE_URL (+ optional OPENWEBUI_API_KEY if the endpoint requires auth) and an explicit --model — there's no universal default model for an arbitrary remote endpoint.
    • openrouterOpenRouter's hosted OpenAI-compatible API. Needs OPENROUTER_API_KEY. Defaults --model to deepseek/deepseek-v4-flash-0731, but that specific model is text-only — OpenRouter returns a clean 404 No endpoints found that support image input if you actually try to classify a photo with it, so pass a vision-capable model explicitly (see note below). --model accepts any OpenRouter model slug; check GET https://openrouter.ai/api/v1/models and filter on architecture.input_modalities containing "image" before picking one.

    openwebui and openrouter share the same request logic (an OpenAI-compatible chat completions call with response_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.

Usage

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.

Backend testing notes

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 OpenRouter free-tier collapse (the actual full-run attempt)

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.

The local gemma4:26b quality regression

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.

Classification rubric

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.

Status

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.

About

Pro Sports Beard Census — one-time AI-vision classification of NFL/NBA/NHL/MLB headshots against a full-beard rubric

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages