Skip to content
fullstack ▶ Demo Available

Event Photo Platform — OCR Bib Number Detection System

Full-stack monorepo with dual OCR providers, async BullMQ job processing, S3 storage, and real-time bib number search for race event photography platforms.

0 views
Next.js 15 Express.js 5 TypeScript Drizzle ORM PostgreSQL 16 Redis 7 MinIO (S3) BullMQ Tesseract.js Google Cloud Vision Sharp Docker Compose
Event Photo Platform — OCR Bib Number Detection System

The Problem

Race events — marathons, triathlons, cycling races — generate thousands of photos. A single marathon with 10,000 runners and 5 photographers produces roughly 15,000 images. Every participant wants to find photos of themselves quickly, and the fastest way is searching by bib number.

The problem? Someone has to tag every photo with the bib numbers visible in it. Manual tagging at 10 seconds per photo means 40+ hours of labor per event. Humans misread numbers, miss partial bibs, and get fatigued. For event photographers and organizers, this bottleneck means delayed photo delivery and frustrated runners.

The Solution

I built a complete Event Photo Platform — a full-stack monorepo that automates the entire workflow. Photographers upload photos through a dashboard, the system detects bib numbers using a dual OCR provider system (Google Cloud Vision for accuracy, Tesseract.js as a free fallback), and participants can search and download their photos instantly through a public-facing web app.

Try the client-side OCR demo — upload any race photo and watch bib detection happen entirely in your browser. For the full platform source, see the event-photo-platform monorepo.

For a deep technical walkthrough of the OCR pipeline, scoring algorithm, and database design, check out my blog post: Race Photo Platform with OCR Bib Detection: Full-Stack Build.

Architecture

The platform follows a monorepo architecture with clear separation between the API server, admin dashboard, and public-facing web app:

event-photo-platform/
├── apps/
│   ├── api/                # Express.js REST API (port 4001)
│   │   └── src/modules/    # Feature-based: auth, events, photos, ocr, search
│   ├── dashboard/          # Next.js admin/photographer portal (port 3002)
│   │   └── src/app/        # Admin pages, photographer upload, settings
│   └── web/                # Next.js public site (port 3000)
│       └── src/app/        # Event browsing, bib search, photo download
├── packages/
│   ├── database/           # Drizzle ORM + PostgreSQL schema + seed
│   ├── types/              # Shared TypeScript types
│   └── utils/              # Shared utilities
├── docker-compose.yml      # PostgreSQL, Redis, MinIO
└── package.json            # Bun workspace monorepo

Why Monorepo?

Shared types between API and both frontends eliminate duplicate interfaces. A single bun install covers all apps. Database schema changes propagate automatically to every consumer.

Why Dual OCR Providers?

Google Cloud Vision gives ~95% accuracy but costs money. Tesseract.js is free and runs locally but achieves ~50% accuracy. The platform lets admins switch between providers from the dashboard — no server restart needed. For the client-side demo, Tesseract.js runs entirely in the browser with no server required.

Why BullMQ + Redis?

OCR processing is CPU-intensive. Running it synchronously would block photo uploads. BullMQ handles queuing, concurrency control (5 workers), automatic retries, and failure logging — keeping the upload response fast while OCR runs in the background.

Key Features

OCR Pipeline

  • Dual provider system — Google Cloud Vision (high accuracy) or Tesseract.js (free, local)
  • Multi-pass Tesseract strategy — 4 passes per image: raw OCR, preprocessed, digit-only, and torso crop region
  • Confidence scoring — Occurrence-based boosting + digit length modifiers + Tesseract confidence blending
  • False positive filtering — Year numbers (2020-2027), too-short numbers (under 2 digits), too-long numbers (over 7 digits)
  • Async processing — OCR queued via BullMQ, doesn’t block photo uploads

Photo Management

  • Single and bulk upload — Up to 500 photos at once with drag-and-drop
  • S3-compatible storage — MinIO locally, AWS S3 in production — switch with env vars
  • Presigned URLs — Photos served directly from storage, never proxied through API
  • Automatic image processing — Sharp generates thumbnails and previews on upload
  • Soft delete — Photos marked as deleted, not removed from storage — recoverable

Search & Discovery

  • Bib number search — Indexed queries on (bib_number, photo_id) for fast lookups
  • Event-scoped search — Search within a specific event or across all events
  • Redis caching — Search results cached for 5 minutes
  • One-click download — Presigned download URLs with download count tracking

Role-Based Access

  • Admin — Full access: manage events, users, settings, all photos
  • Event Admin — Manage events, assign photographers, view reports
  • Photographer — Upload photos to assigned events, tag bibs, manage own photos
  • User — Browse events, search by bib, download photos

Client-Side Demo

  • Standalone HTML app — Zero backend, runs entirely in the browser
  • Tesseract.js powered — Same OCR engine as the full platform, client-side
  • Visual feedback — Highlights detected bib regions with confidence scores
  • No image upload — All processing happens locally, privacy-friendly

What I Learned

  • OCR is harder than it looks — Bib numbers are small, blurred, rotated, partially hidden. No single OCR configuration catches everything. The multi-pass approach was necessary because each pass catches different edge cases.
  • Job queues are essential for image processing — Synchronous OCR would have killed upload performance. BullMQ with Redis made the async pipeline reliable with retries and failure handling.
  • S3 presigned URLs are the right pattern — Never proxy large files through your API. Let the storage layer serve them directly. This keeps the API lightweight and scales much better.
  • Confidence scoring needs domain knowledge — Generic OCR confidence isn’t enough. Knowing that bibs are 4-5 digits, typically appear on the torso area, and that year numbers on banners are false positives dramatically improved accuracy.
  • Monorepos scale well for full-stack projects — Shared types and database schema across apps eliminated entire categories of bugs. Change a column type once, every app gets the update.

Interested in Something Similar?

Need a similar project built, want to collaborate, or have questions about the tech stack? Drop a comment below or reach out directly.

Discussion