Building an Event Photo Platform with OCR Bib Detection
How I architected a full-stack monorepo platform for event photographers to upload, auto-tag bib numbers via OCR, and let runners search and download their photos.
// table of contents (37 sections)
Try it live — The OCR demo is hosted on GitHub Pages and the source code is on GitHub.
Live Demo
Try the OCR bib detection right here — upload an event photo and see detected bib numbers. Everything runs in your browser, no images are uploaded to any server:
Race events, marathons, and sporting competitions generate thousands of photos. Every participant wants to find their photos quickly — and the fastest way is searching by bib number. But manually tagging 15,000+ photos with bib numbers is slow, expensive, and error-prone.
So I built a complete Event Photo Platform — a full-stack monorepo where photographers upload photos, the system automatically detects bib numbers using dual OCR providers, and participants can search and download their photos instantly.
In this post, I’ll walk through the entire architecture, tech stack, database design, OCR pipeline, and the decisions I made along the way.
The Problem
Here’s the workflow that event photographers and organizers deal with:
- A marathon has 10,000 runners and 5 photographers
- Each photographer captures ~2,000–3,000 photos
- That’s roughly 15,000 images that need bib number tagging
- Runners want to search by their bib number and download their photos
Manual tagging at 10 seconds per photo = 40+ hours of labor. And humans misread numbers, miss partial bibs, and get fatigued.
The solution: a platform that automates bib detection, manages the upload pipeline, and provides a clean search experience for participants.
Architecture Overview
I chose a monorepo architecture with clear separation between the API, admin dashboard, and public-facing web app:
event-photo-platform/
├── apps/
│ ├── api/ # Express.js REST API (port 4001)
│ │ └── src/
│ │ ├── index.ts
│ │ ├── config/ # S3, env, Redis config
│ │ ├── middleware/ # Auth, upload, error handling
│ │ └── modules/ # Feature-based API modules
│ │ ├── auth/
│ │ ├── events/
│ │ ├── photos/
│ │ ├── ocr/
│ │ ├── search/
│ │ ├── downloads/
│ │ ├── settings/
│ │ └── users/
│ ├── dashboard/ # Next.js admin/photographer portal (port 3002)
│ │ └── src/app/
│ │ ├── admin/ # Admin pages
│ │ ├── photographer/ # Photographer pages
│ │ └── login/
│ └── web/ # Next.js public site (port 3000)
│ └── src/app/ # Public event browsing + search
├── packages/
│ ├── database/ # Drizzle ORM + PostgreSQL schema
│ │ ├── src/
│ │ │ ├── schema.ts
│ │ │ └── connection.ts
│ │ └── seed.ts
│ ├── types/ # Shared TypeScript types
│ └── utils/ # Shared utilities
├── docker-compose.yml
├── .env.example
└── README.md
Why Monorepo?
- Shared types between API and both frontends — no duplicate interfaces
- Single
bun installfor all apps - Atomic changes — update a type in
packages/types, and both frontends + API get it - Shared database package — schema defined once, used everywhere
Tech Stack
Here’s every technology I used and why:
| Layer | Technology | Why |
|---|---|---|
| Runtime | Bun | Fastest JS runtime, native TypeScript, drop-in Node replacement |
| API | Express.js 5 + TypeScript | Mature, well-documented, massive middleware ecosystem |
| Frontend | Next.js 15 + React 19 | App Router, SSR for public SEO, fast dashboard |
| Styling | Tailwind CSS 4 | Utility-first, rapid UI development |
| Database | PostgreSQL 16 | Reliable, JSONB support, full-text search potential |
| ORM | Drizzle ORM | Type-safe, lightweight, SQL-like syntax, great migrations |
| Storage | MinIO (S3-compatible) | Self-hosted S3, presigned URLs, cost-effective |
| Cache | Redis 7 | Search result caching, BullMQ job queue backend |
| Job Queue | BullMQ | OCR processing is async — queue it, retry on failure |
| OCR Primary | Google Cloud Vision API | ~95% accuracy on bib detection |
| OCR Fallback | Tesseract.js | Free, local, no API needed — ~50% accuracy |
| Auth | JWT (access + refresh tokens) | Stateless auth, works across microservices |
| Image Processing | Sharp | Resize, crop, grayscale, sharpen for OCR preprocessing |
| Containerization | Docker Compose | PostgreSQL, Redis, MinIO — one command to start |
Infrastructure with Docker Compose
Everything you need to run locally in a single docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: event_photo
ports: ["5432:5432"]
volumes: [postgres_data:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: [redis_data:/data]
minio:
image: minio/minio:latest
ports: ["9000:9000", "9001:9001"]
volumes: [minio_data:/data]
command: server /data --console-address ":9001"
One command: docker compose up -d — and you have PostgreSQL, Redis, and S3-compatible storage running.
Database Schema
I designed the schema with Drizzle ORM. Here’s the complete schema:
Users Table
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
fullName: varchar('full_name', { length: 255 }).notNull(),
phone: varchar('phone', { length: 50 }),
role: varchar('role', { length: 20 }).default('user').notNull(),
avatarUrl: text('avatar_url'),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
Roles: admin, event_admin, photographer, user — each with different access levels.
Events Table
export const events = pgTable('events', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 500 }).notNull(),
slug: varchar('slug', { length: 500 }).notNull().unique(),
description: text('description'),
eventDate: date('event_date').notNull(),
location: varchar('location', { length: 500 }),
city: varchar('city', { length: 255 }),
coverImageUrl: text('cover_image_url'),
status: varchar('status', { length: 20 }).default('upcoming').notNull(),
createdBy: uuid('created_by').references(() => users.id),
}, (table) => [
index('idx_event_slug').on(table.slug),
index('idx_event_status').on(table.status, table.eventDate),
]);
Status flow: upcoming → active → completed → archived
Photos Table
export const photos = pgTable('photos', {
id: uuid('id').defaultRandom().primaryKey(),
eventId: uuid('event_id').references(() => events.id, { onDelete: 'cascade' }).notNull(),
photographerId: uuid('photographer_id').references(() => users.id).notNull(),
originalUrl: text('original_url').notNull(),
previewUrl: text('preview_url').notNull(),
thumbnailUrl: text('thumbnail_url'),
fileSize: bigint('file_size', { mode: 'number' }),
width: integer('width'),
height: integer('height'),
downloadCount: integer('download_count').default(0).notNull(),
status: varchar('status', { length: 20 }).default('active').notNull(),
}, (table) => [
index('idx_photo_event').on(table.eventId, table.status),
]);
Photos are stored as S3 keys (not URLs). Presigned URLs are generated on-the-fly for secure access.
Bib Tag Table
export const photoBibTags = pgTable('photo_bib_tags', {
id: uuid('id').defaultRandom().primaryKey(),
photoId: uuid('photo_id').references(() => photos.id, { onDelete: 'cascade' }).notNull(),
bibNumber: varchar('bib_number', { length: 50 }).notNull(),
taggedBy: varchar('tagged_by', { length: 20 }).default('manual').notNull(),
confidenceScore: decimal('confidence_score', { precision: 5, scale: 4 }),
}, (table) => [
index('idx_bib_search').on(table.bibNumber, table.photoId),
]);
taggedBy values:
manual— photographer tagged it by handocr_auto— OCR detected it automaticallyocr_verified— OCR detected + human confirmed
Supporting Tables
event_photographers— many-to-many: which photographers are assigned to which eventsdownload_logs— tracks every download with IP and user agentplatform_settings— key-value store for global config (likeocr_provider)
API Design
Express.js with a modular route structure. Each feature is its own module with routes, controller logic, and middleware:
Auth Module
POST /api/auth/register → Register new user
POST /api/auth/login → Login, get access + refresh JWT tokens
POST /api/auth/refresh → Refresh expired access token
GET /api/auth/me → Get current authenticated user
Events Module
GET /api/events → List all events (with photo counts)
GET /api/events/:slug → Get event by slug
POST /api/events → Create event (admin only)
PUT /api/events/:id → Update event (admin only)
POST /api/events/:id/assign → Assign photographer to event (admin only)
Photos Module
POST /api/photos/upload → Upload single photo (photographer+)
POST /api/photos/bulk-upload → Upload up to 500 photos at once
GET /api/photos/event/:eventId → List photos with presigned URLs + bib tags
DELETE /api/photos/:id → Soft-delete photo
POST /api/photos/:id/tag-bib → Manual bib number tagging
Search & Download
GET /api/search?event=slug&bib=123 → Search photos by bib number
GET /api/photos/:id/download → Get presigned download URL
Photo Upload Flow
Here’s what happens when a photographer uploads a photo:
router.post('/upload', authMiddleware, requireRole('photographer'), upload.single('photo'), async (req, res) => {
const { eventId } = req.body;
const file = req.file;
// 1. Generate unique S3 key
const key = `events/${eventId}/originals/${Date.now()}-${randomHex()}.${ext}`;
// 2. Upload to MinIO/S3
await uploadToS3(key, file.buffer, file.mimetype);
// 3. Save metadata to database
const [photo] = await db.insert(photos).values({
eventId,
photographerId: req.user.userId,
originalUrl: key,
fileSize: file.size,
}).returning();
// 4. Enqueue OCR job (async — doesn't block response)
await enqueueOCR(photo.id, key, file.buffer);
res.status(201).json({ success: true, data: photo });
});
The OCR processing happens asynchronously via BullMQ — the photographer gets an instant response, and bib detection runs in the background.
The OCR Pipeline (Most Interesting Part)
This is the heart of the platform. I built a dual-provider OCR system with sophisticated preprocessing and confidence scoring.
Architecture
Photo Upload
│
▼
BullMQ Queue (async)
│
▼
┌──────────────────────┐
│ OCR Provider │
│ (from settings) │
├──────────────────────┤
│ Google Vision API │ ← ~95% accuracy, $1.50/1000 images
│ OR │
│ Tesseract.js │ ← ~50% accuracy, free, local
└──────────────────────┘
│
▼
Confidence Scoring
│
├── ≥ 75% → Auto-tag immediately
├── ≥ 40% → Tag for review
└── < 40% → Skip (noise)
Tesseract Provider (Free — Default)
The Tesseract provider is the most complex part. It uses a multi-pass strategy to maximize detection:
Pass 1: Raw full image OCR
- Resize large images (>2000px width) for performance
- Run Tesseract with 3 different PSM (Page Segmentation Mode) modes: 11, 6, 3
- Extract all digit sequences from raw text output
Pass 2: Preprocessed full image
- Convert to grayscale
- Normalize contrast
- Apply sharpening
- Run multi-PSM OCR again
Pass 3: Digit-only OCR
- Force Tesseract to only look for digits (0-9 whitelist)
- PSM 6 mode — treats image as a uniform block of text
- Better at finding numbers in noisy backgrounds
Pass 4: Torso crop region
- Crop to the upper-center 60% width × middle 40% height — where bibs typically appear on runners
- Preprocess (grayscale + sharpen)
- Run both multi-PSM and digit-only OCR
- Boost confidence scores for crop matches (they’re in the bib zone)
Scoring algorithm:
// Each candidate gets a confidence score based on:
// 1. Occurrence count (found in multiple passes = higher confidence)
// 2. Digit length (5 digits = standard bib format = big boost)
// 3. Tesseract's own confidence score
const occurrenceBoost = occurrences >= 4 ? 0.82
: occurrences >= 3 ? 0.72
: occurrences >= 2 ? 0.60
: 0.45;
// Digit length modifier
if (digitLength === 5) occurrenceBoost += 0.20; // Standard bib
else if (digitLength >= 4 && <= 6) occurrenceBoost += 0.10;
else if (digitLength <= 3) occurrenceBoost *= 0.6; // Likely noise
// Final: weighted blend
const confidence = (occurrenceBoost * 0.7) + (tesseractConfidence * 0.3);
False positive filtering:
- Years (2020-2027) are filtered out — they’re everywhere on banners
- Numbers under 2 digits are ignored
- Numbers over 7 digits are ignored
Google Vision Provider (Paid — Higher Accuracy)
The Google Vision provider is simpler but more accurate:
const [result] = await client.textDetection(imageBuffer);
const detections = result.textAnnotations;
// Extract bib patterns: 4-5 digit numbers, optionally prefixed with letters
const bibPatterns = [/\b(\d{4,5})\b/g, /\b([A-Z]{1,3}\d{4,5})\b/g];
Google Vision handles the hard parts — it’s robust to angles, lighting, and partial occlusion. At ~95% accuracy, it’s reliable enough for auto-tagging.
Provider Switching
The platform stores the active OCR provider in the platform_settings table. Admin can switch between providers from the dashboard without restarting the server:
const [setting] = await db.select().from(platformSettings)
.where(eq(platformSettings.key, 'ocr_provider'));
const providerName = setting?.value || 'tesseract';
const provider = getProvider(providerName);
Search Functionality
Participants search by bib number. Here’s how it works:
- User enters bib number on the public web app
- API queries the
photo_bib_tagstable indexed on(bib_number, photo_id) - Join with photos to get image metadata
- Generate presigned URLs for thumbnails and previews
- Cache results in Redis for 5 minutes (same bib = same results)
The index on bib_number makes lookups fast even with millions of tag rows.
Frontend Architecture
Public Web App (Port 3000)
- Browse all events with cover images
- View event details and photo gallery
- Search by bib number across events or within a specific event
- Download photos with one click
Dashboard (Port 3002)
Photographer views:
- My Events — assigned events with photo counts
- Upload Photos — single or bulk upload with drag-and-drop
- My Photos — grid view with bib tagging and soft-delete
Admin views:
- Events — create, edit, change status
- Photographers — register new photographers, assign to events
- Settings — OCR provider, platform configuration
Role-Based Access Control
| Role | Access |
|---|---|
admin | Everything — manage events, users, settings, all photos |
event_admin | Manage events, assign photographers, view reports |
photographer | Upload photos to assigned events, tag bibs, delete own photos |
user | Browse events, search by bib, download photos |
Middleware enforcement:
router.post('/upload', authMiddleware, requireRole('photographer', 'admin'), handler);
Key Design Decisions
1. Presigned URLs Instead of Serving Files
Photos are never proxied through the API. Instead, I generate S3 presigned URLs that expire after 1 hour. This keeps the API lightweight and lets MinIO/S3 handle the heavy lifting of serving images.
2. Soft Delete for Photos
When a photographer “deletes” a photo, I just set status = 'deleted' instead of removing it from S3. This prevents accidental data loss and allows recovery.
3. Async OCR with BullMQ
OCR processing (especially Tesseract) is CPU-intensive. Running it synchronously would block the upload response. BullMQ handles queuing, concurrency (5 workers), retries, and failure logging.
4. Monorepo with Shared Packages
The packages/types and packages/database packages are shared across all three apps. Change a database column type, and the API + both frontends get the update automatically.
5. S3-Compatible Storage
Using MinIO locally means the same code that works in development can switch to AWS S3 in production by changing environment variables. No code changes needed.
Getting Started
# 1. Install dependencies
bun install
# 2. Start infrastructure
docker compose up -d
# 3. Run database migrations
cd packages/database && bun run db:push
# 4. Seed sample data (optional)
bun run db:seed
# 5. Start all apps
bun run --filter './apps/*' dev
The platform will be running at:
- Public site: http://localhost:3000
- Dashboard: http://localhost:3002
- API: http://localhost:4001
What I Learned
Building this platform taught me several things:
-
OCR is harder than it looks. Bib numbers are small, often blurred, sometimes rotated. The multi-pass Tesseract approach was necessary because no single OCR configuration catches everything.
-
Job queues are essential for image processing. Synchronous OCR would have killed upload performance. BullMQ with Redis made the async pipeline reliable.
-
S3 presigned URLs are the right pattern. Never proxy large files through your API. Let the storage layer serve them directly.
-
Monorepos scale well for full-stack projects. Shared types and database schema across apps eliminated entire categories of bugs.
-
Confidence scoring needs domain knowledge. Generic OCR confidence isn’t enough. Knowing that bibs are 4-5 digits, typically on the torso, and that year numbers are false positives dramatically improved accuracy.
What’s Next
Some features I’m considering for v2:
- Face recognition — match runners by face when bib isn’t visible
- Real-time processing — WebSocket notifications when OCR completes
- Watermarking — automatic watermarked previews for unpaid downloads
- Payment integration — let photographers sell high-res downloads
- Mobile app — Flutter companion app for on-site photo uploads
The full source code is available on GitHub — it’s open source!
