This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 2, Side Hustle.
What I Built
BibLocal reads the bib numbers in a folder of race photos and writes them into each file as keywords. After that, a photographer can click a bib number in their photo manager and see every photo of that runner.
It runs on a laptop CPU with Wi-Fi off. Nothing is uploaded. There is no account, no API key and no GPU.
Who it's for
Race photographers. One 5K or trail race can mean thousands of photos, and every runner wants to find theirs. The usual way to make that possible is to open each photo, zoom in on the bib and type the number.
Photographers have complained about this for years. On the dgrin forum, a ski-race photographer shooting 2,000 to 3,000 images per race put it at "maybe 90 minutes to keyword a race." In 2024, someone on the Adobe Community asked whether any Lightroom Classic plugin could recognise race bib numbers. The Community Expert's answer was "No."
I didn't start with this idea. I started with filters. While researching ideas with Claude, I threw out the first batch: one needed a GPU the user wouldn't have, and another was something a free chatbot already does. The next round had to run on an ordinary CPU, beat a free chatbot, depend on open AI, and help people anywhere, not just in my city. When I read about BibLocal, I knew it was solving something real.
Who would pay for it
Photographers already pay for this. Manual tagging services charge about 2 cents to 5 cents per photo. For a 3,000-photo race, that is $60 to $150, every race. Cloud tools charge credits or a subscription, and the photos have to be uploaded first.
BibLocal is free and MIT licensed. [CHECK: the paid part. Suggestion: a signed one-click Windows build and setup help, sold per photographer instead of per photo.]
How it works
-
biblocal scanreads every JPEG in a folder and proposes bib numbers. It changes no photo. - Photos it isn't sure about go to a local review page. You confirm or type the bib, press Enter, and download
corrections.csv. -
biblocal applywith--write embedwrites the confirmed bibs into the JPEGs as IPTC and XMP keywords, using exiftool. Every original is kept as a backup.
Demo
About a minute, no audio. It runs on 29 real race photos from friends who run marathons; they agreed to me using them. The scan is shown at 5x speed. The real scan time was 51.42 seconds. I didn't get a clean recording of the apply step, so the video shows a text card with the result of the real run instead: 25 of 26 photos written.
Code
BibLocal
What it does
BibLocal reads runners' bib numbers in JPEG race photos on a CPU, offline Default scans preserve photos and write a CSV and local review page. Opt-in writes add bib keywords.
Quick start
Tested on Windows 11 with Python 3.13.7. Install Python 3.13 and this checkout on D:. Run in PowerShell from the checkout:
$env:PIP_CACHE_DIR = "D:\.cache\pip" New-Item -ItemType Directory -Force .tmp | Out-Null $env:TEMP = (Resolve-Path .tmp).Path $env:TMP = $env:TEMP py -3.13 -m venv .venv .venv/Scripts/python.exe -m pip install . .venv/Scripts/biblocal.exe scan "D:\Photos\Race" --start-list "D:\Photos\start.csv" Setup needs package access or prepared local wheels. It installs CPU ONNX Runtime and RapidOCR's bundled models No CUDA, GPU runtime, account or API key is needed. This is a CLI, with no…
How I Built It
The open AI part
The OCR is PaddleOCR's PP-OCRv6 small detection and recognition models (Apache-2.0), run through RapidOCR 3.10.0 on the CPU build of ONNX Runtime. The two model files are about 31 MB together and ship inside the RapidOCR package. BibLocal checks their SHA-256 hashes before using them and never downloads a model.
Each photo is decoded at reduced size with Pillow's JPEG draft mode, turned upright from its EXIF orientation, resized to a long side of 2,048 px and passed to the OCR.
The OCR proposes, plain code decides
I didn't want a model deciding what gets written into someone's files. So the OCR only produces candidates, and ordinary Python checks each one:
- Digits only, with a sensible length.
- Confidence of at least 0.90 to tag. Between 0.50 and 0.90, the photo goes to review.
- On the race's start list, when one is supplied.
- A text box at least 2% of the photo's height, so tiny numbers in the background don't count.
- Not a number that shows up across too many photos. Those are usually banners or signs, not bibs.
A photo that fails these checks goes to review or gets no tag. A suggestion is never written as a tag.
How the work was split
I used AI for most of the code, so here is exactly who did what. Claude (Opus 5.5) did the research, the design spec and the task plan, and later edited the demo video. OpenAI Codex, running GPT-6 Astra, wrote most of the code from that plan, test first, one task per commit. My part was choosing the problem, setting the rules, making the call when the numbers disagreed, checking labels and results, running it on real photos, verifying the tags in digiKam and recording the demo.
The rules were written down before any code, in the repo's CLAUDE.md. CPU only, even though my laptop has a GPU. No network calls at runtime. Never write to a photo unless asked. When in doubt, send it to review. Permissive licences only, and every model file recorded with its source, size, checksum and licence. The final suite has 134 tests, all on synthetic images, so no real person's photo is in the repo.
The hardest bug: IPTC
Writing a keyword into a JPEG sounds trivial. It wasn't.
Older photos often carry IPTC metadata encoded as Latin-1. Telling exiftool to treat it as UTF-8 reinterpreted those bytes and corrupted the text; a test with "café" and "El niño" reproduced it. Copying the old IPTC and editing keywords in one command duplicated keywords. Worst of all, exiftool can cut an IPTC keyword longer than 64 bytes and still exit with status 0, leaving only a warning.
The fix was two separate exiftool commands per photo, run on a staged copy, with any warning treated as a failure. If the write isn't clean, the original stays exactly as it was.
That rule fired on the real apply run. One photo triggered an exiftool warning about its HDR gain-map data [CHECK wording], so BibLocal skipped it and wrote the other 25.
The 10-out-of-30 moment
I tuned the rules on 100 photos from RBNR, a public research dataset of race photos with labelled bibs. Then I asked friends who run marathons for their race photos, and they sent me 30.
Before the scan, I asked myself: if it fails on real photos, has the project failed?
The first scan tagged 10 of them.
The cause was a rule tuned on the dataset. The minimum bib height was 4% of the photo, because that worked on RBNR. In my friends' photos, many bibs are smaller in the frame, and the rule threw them away. At 2%, the scan in the demo gave 23 tagged, 3 sent to review, 3 with no readable bib, and 0 errors. (That run uses 29 photos. I left one out of the video.)
That change has a cost, and the dataset shows it:
| Minimum bib height | Photos with a correct tag | Wrong tags, realistic start list |
|---|---|---|
| 4% | 80% | 7.76% (9 of 116) |
| 2% | 87% | 19.08% (29 of 152) |
"Realistic start list" here means every number between the lowest and highest bib at each event, which is close to what a real race list looks like. So at 2%, about one in five tags it accepts on that dataset is wrong. My target was under 3%. It misses that by a long way.
I kept 2% as the default because a tool that misses most bibs on real photos is no use to anyone, and a wrong tag is visible in the photo manager and can be removed. [CHECK: your reason] If you would rather miss bibs than tag wrong ones, --min-bib-height 0.04 brings back the old behaviour.
One more caveat about the demo: its start list contains only the 30 bibs that appear in those photos. That is the best case. A real race list can have thousands of numbers, which gives a misread more chances to match one.
What it doesn't do yet
- The wrong-tag rate above is the first thing to fix. The dataset numbers are also in-sample: 100 photos, no held-out set.
- I checked the keywords in digiKam 9.1.0. Lightroom Classic is untested.
- Speed comes from one laptop with an Intel Core i7-13650HX. On mains power, a 24 MP test image took about 1.5 seconds, roughly 25 minutes per 1,000 photos (an estimate, not a measured batch). On battery saver, it took 12 to 14 seconds per photo.
- It's a command-line tool, tested on Windows 11 only, and it reads JPEGs only.
- Blurred, covered or angled bibs get missed. Someone still has to review.
Why Does Open Innovation Matter?
Race photos are photos of the public, often including children. With a closed vision API, every one of them would be uploaded to someone else's server and paid for per image, and the tool would need internet at the finish line.
With an open model, the photos never leave the laptop. You can check that yourself by running the scan with Wi-Fi off; I did. Once it's installed, a photo costs nothing to process.
Open also let me measure it properly. The model files are fixed and checksummed, so the OCR gives the same output every time. The calibration script caches the OCR output for the 100 dataset photos and replays the rules under 18 combinations of settings, which is how the thresholds were chosen. With a closed API, the model can change under you, and every re-run is a bill.
If a better PP-OCR comes out, or someone fine-tunes one on bib fonts, swapping it in means changing a file and re-running the calibration.
Open did not make the model good at bibs. It gave me a model I could run as often as I liked and actually measure. That's how I know about the 19%, and I would rather publish that number than hide it.


