← Back to portfolio

Machine Learning · Full-Stack

Shot Log & Shot Detection

An end-to-end target-scoring system — a computer-vision model that detects bullet impacts on paper targets, wired into a full-stack shooting-sports app that automatically scores shots.

  • PyTorch
  • ONNX
  • FastAPI
  • React Native
  • Terraform
  • DigitalOcean
  • Computer Vision
Try the app →
Shot Log & Shot Detection — cover image

Two repos, one product. Shot Detection is the ML brain that finds bullet holes and the target geometry in a photo. Shot Log is the product around it — clubs, events, series and scores. They are designed to be chained together: Shot Log sends a target-paper image to the Shot Detection model, gets back the detected impacts and target reference points, and turns that into an automatic score — no manual entry required.

The idea

Live target scoring with nothing more than a cheap camera and a phone.

The goal is to clip a low-cost camera to a target rig, capture the paper, and have computer vision do the rest: rectify the skewed photo into a flat, metric view, detect every bullet impact, map each one to the correct scoring ring, and log the full series into a cloud service that tracks progress over time. The same pipeline can run on-device for instant feedback (with a shot replay and audio cue) or in the cloud for batch scoring and analytics.

The work splits into three phases:

  1. Rectify the target paper — obtain a homography and a flat, undistorted view of the paper.
  2. Detect impacts — segment bullet holes with masks tight enough that scoring by edges is meaningful.
  3. Score — detect the scoring rings and assign each shot a value, with edge cases (a shot landing exactly on a ring boundary) handled precisely.

System architecture

The whole product spans an edge app, a cloud backend, an ML inference service and the supporting cloud infrastructure. The diagram shows the full request path — from a shooter capturing a target on their phone, through the React Native app and Caddy edge, into the FastAPI backend (where scoring, club management and witnessing live), out to the Shot Detection ONNX model, and back as an automatic score persisted in PostgreSQL — alongside the delivery pipeline and the external services (object storage, email, the postal/police licensing flow).

flowchart TD subgraph client["Client"] PHONE["Shooter phone
Expo / React Native
expo-camera + OpenCV warp"] CLUB["Club admin
web dashboard"] end subgraph edge["Edge"] CADDY["Caddy
auto-TLS · HTTP/3
reverse proxy"] end subgraph be["FastAPI backend · Droplet"] AUTH["Auth · OAuth2 JWT
roles: admin > moderator > member"] DOMAIN["Domain layer
clubs · events · series · shots
weapons · witnesses · awards · stats"] SCORE["Scoring flow
homography → ring projection"] RATE["SlowAPI rate limiting"] end subgraph ml["Shot Detection · inference"] ONNX["ONNX instance-seg model
SAHI sliced inference
shot · center_ring · target_paper"] WEB["onnxruntime-web (WASM)
runs in the phone browser"] end PG[("PostgreSQL 16
managed · private VPC")] S3[("Spaces / S3
target images")] MAIL["Resend / AWS SES
transactional email"] PHONE -->|"capture & rectify"| CADDY CLUB --> CADDY CADDY -->|"/api/* · /uploads/*"| be CADDY -->|"static SPA"| PHONE CADDY -->|"static SPA"| CLUB RATE --> be be --> AUTH AUTH --> DOMAIN DOMAIN -->|"POST /score-image
image"| ONNX ONNX -->|"JSON: masks + boxes
+ reference points"| DOMAIN DOMAIN --> SCORE SCORE --> PG DOMAIN --> PG DOMAIN -->|"presigned URLs"| S3 DOMAIN -->|"invite / witness / bounce"| MAIL PHONE -.->|"optional: on-device
inference (no round-trip)"| WEB PHONE -->|"witness gold-level series
(activity proof)"| CLUB CLUB -.->|"member activity record
→ police license recommendation"| POLICE["Police licensing authority"] subgraph cicd["CI/CD"] GH["GitHub · push to master"] GHA["GitHub Actions"] GHCR["ghcr.io
backend + frontend images"] end GH --> GHA GHA --> GHCR GHCR -->|"SSH · docker compose pull"| be

How the two components fit together

flowchart LR CAM(["Phone camera
target-paper photo"]) subgraph product["Shot Log — product"] direction TB RN["React Native app
Expo · Tamagui
expo-camera + OpenCV warp"] API["FastAPI backend
clubs · events · series · shots · stats"] RN -->|"capture & rectify"| API end subgraph model["Shot Detection — ML model"] ONNX["ONNX instance-seg model
Mask2Former / RF-DETR
SAHI sliced inference"] end DB[("PostgreSQL
series & statistics")] CAM --> RN API -->|"POST /score-image
image"| ONNX ONNX -->|"JSON: masks + boxes + scores
shot · center_ring · target_paper"| API API -->|"homography → ring projection
automatic score"| DB

Shot Log’s backend calls the Shot Detection inference service with the captured image. The model returns three classes of instance masks: target paper, center ring and shot (bullet impacts). The backend uses the target-paper and center-ring detections to compute the homography that maps the photo onto a canonical target layout, then projects each shot impact onto the scoring rings to produce a value per shot. That value flows straight into the series and statistics — fully automatic scoring.

The detection pipeline, visually

The images below show the progression through the pipeline — the same target going from a raw, perspective-skewed photo to a flat homography-corrected view, and finally to the model’s segmentation overlay with detected shot impacts, the target paper and the center circle.

Raw captured photo of a shot-up target paper
1 · Raw image from the camera
Target paper after homography rectification
2 · Homography — flat, rectified paper
Final instance segmentation overlay with detected shots, target paper and center ring
3 · Detection — impacts, paper & center ring

The dataset

This is a finely curated dataset I built myself. Every target paper was labelled manually using X-AnyLabeling, a tool that blends auto-suggested labels with hand correction — exactly what you want when the mask edges determine the score.

  • Sources: my own shot targets plus targets collected from other shooters, covering both .22 LR and air-pistol disciplines.
  • Annotations: COCO instance-segmentation polygons for three classes — shot (bullet impact), center_ring and target_paper.
  • Why it matters: scoring is all about what the mask edges touch, so the labels are drawn tight to the actual hole boundaries rather than loose blobs. A loose mask would silently corrupt every edge-of-ring decision.
  • Reference targets: official ISSF 10 m air-pistol and 25 m target SVGs are kept alongside the data so the canonical scoring-ring layout is available for the scoring phase.

Architecture by component

1. Inference pipeline (Shot Detection)

The ML side is built for accuracy first, deployment second: train a strong teacher model, then distil and quantize it down to something that runs fast enough on-device or in the browser.

flowchart LR A["raw image"] --> B["SAHI slicing
384×384 @ 0.4 overlap"] B --> C["per-tile inference
Mask2Former / RF-DETR"] C --> D["reassemble confidence maps
into full image"] D --> E["CombineStrategy
stitch overlapping tiles"] E --> F["post-process
instance segmentation"] F --> G["masks + boxes + scores
shot · center_ring · target_paper"]

Training. Models are trained as LightningModules on tiled COCO instance-segmentation data with Albumentations augmentation, AdamW + cosine warmup LR, mixed precision and gradient accumulation. Three model families were explored and compared in MLflow under a single shot-detection-model-comparison experiment:

  • Mask2Former (facebook/mask2former-swin-tiny-coco-instance) — the primary instance-segmentation model.
  • RF-DETR (Roboflow/rf-detr-seg-medium) — currently the checkpoint in use.
  • YOLO26 and MobileViT — earlier experiments, being phased out.

Distillation & quantization. A student model is distilled from the teacher using a dense pixel-level KL-divergence loss between the teacher’s and student’s per-pixel class distributions (computed via einsum("bqc,bqhw->bchw", class_probs, mask_probs)), combined with the standard Hungarian-matched task loss. The student is then exported to ONNX and dynamically quantized to INT8 — roughly halving the model size while keeping deployment friendly.

Export. Models are exported to ONNX via optimum with a torch.onnx fallback, including custom symbolics for the antialias upsample ops the legacy tracer can’t handle. The exported ONNX runs through onnxruntime (CUDA with CPU fallback) and is also served in the browser with onnxruntime-web (WASM) — the full sliced-inference + post-processing path is re-implemented in JavaScript so a phone can run inference without a server round-trip.

Homography / paper rectification. A separate OpenCV.js stage detects the target-paper quadrilateral (Canny → contours → approxPolyDPgetPerspectiveTransformwarpPerspective) and rectifies the photo to a flat, metric view before the impact model runs — which is exactly what step 2 of the visual pipeline above shows.

2. Backend (Shot Log)

A FastAPI service that owns the domain model: clubs, events, series, individual shots, weapons, witnesses, awards and statistics.

  • Auth: OAuth2 password flow with JWT (HS256), bcrypt password hashing and a role hierarchy (admin > moderator > member) enforced by a require_role(*roles) dependency.
  • Persistence: SQLAlchemy 2.0 ORM with Alembic migrations targeting PostgreSQL 16 (managed in the cloud; SQLite for local dev only).
  • Storage: a storage abstraction that serves local files in dev or S3-compatible object storage (presigned URLs) in production.
  • Email: a pluggable transport layer (email_providers/) that switches between AWS SES, Resend and a dev logger via a single EMAIL_PROVIDER setting — including SNS-based bounce/complaint suppression.
  • Rate limiting: SlowAPI middleware on auth and upload endpoints.
  • Shot-detection integration: the backend exposes the capture-and-score flow — it accepts the target image, forwards it to the Shot Detection inference service, and stores the resulting impacts and computed scores against the series. The image upload endpoint is rate-limited and stores both the original and processed image paths.

3. Frontend (Shot Log)

An Expo / React Native app targeting iOS, Android and Web from one TypeScript codebase with Expo Router file-based routing.

  • UI & state: Tamagui component system, React Query for server cache, Zustand for local/auth state, React Hook Form + Zod for forms.
  • Camera / scanner: expo-camera with a live quadrilateral overlay; an on-device OpenCV pipeline (react-native-fast-opencv on native, opencv.js WASM on web) does the paper detection and perspective warp in real time so the user sees the rectified target before capturing.
  • Internationalisation: a custom i18n store with 12 locales.
  • Native build: a custom Expo dev build is required (not Expo Go) because of the native OpenCV module.

The app serves three distinct user journeys: individual shooters scoring targets and tracking progress, club admins managing member activity, and a club-witnessing workflow that feeds the police firearm-licensing process.

Shot Log series detail screen showing an uploaded target and the automatic score returned by the system
Series detail — upload a target photo and get the automatic score back, logged against the series.
Shot Log statistics screen showing a shooter's progress over time
Statistics — a shooter tracks their progress over time across events and series.
Shot Log club activity screen showing member activity overview for club management
Club activity — admins overview member activity and witness gold-level series, feeding the police firearm-licence recommendation.

4. Cloud deployment

Two deployment targets are maintained; the active one is DigitalOcean, with an alternative AWS ECS Fargate stack.

DigitalOcean (active):

GitHub (push to master) ──► GitHub Actions
                            ├── build & push backend image  ──► ghcr.io
                            ├── build & push frontend image ──► ghcr.io
                            └── SSH into Droplet: pull + docker compose up -d

Internet → Droplet
            ├── Caddy (80/443, auto Let's Encrypt TLS, HTTP/3)
            │     ├── /api/*, /uploads/* → backend:8000
            │     └── /* → frontend:3000 (static SPA)
            ├── backend container (port 8000, image from ghcr.io)
            └── frontend container (port 3000, image from ghcr.io)

Backend → Managed PostgreSQL 16 (private VPC)
Backend → Spaces (S3-compatible file uploads)
Backend → Resend (transactional email)
  • Infrastructure as code: Terraform provisions the Droplet (Ubuntu 24.04, cloud-init installs Docker), a managed PostgreSQL 16 cluster, a Spaces bucket, firewall and VPC. The Droplet never needs source access — it only pulls pre-built images.
  • Reverse proxy / TLS: Caddy terminates TLS with automatic Let’s Encrypt certificates and routes /api/* and /uploads/* to the backend and everything else to the static frontend.
  • CI/CD: GitHub Actions builds both Docker images, pushes them to the GitHub Container Registry, then SCPs the compose file and SSHes in to docker compose pull && up -d. Every push to master ships to production.

AWS (alternative): ECS Fargate (nginx + backend sidecars), an ALB, RDS PostgreSQL, S3, SES/SNS for email deliverability, and secrets injected from SSM Parameter Store — all Terraform-managed.

Technologies used

Inference pipeline

  • PyTorch + PyTorch Lightning
  • HuggingFace Transformers (Mask2Former, RF-DETR, MobileViT)
  • Ultralytics (YOLO26)
  • ONNX Runtime (CUDA / WASM)
  • optimum, torchao (QAT/PTQ int4-int8)
  • OpenCV & OpenCV.js (homography)
  • Albumentations, pycocotools
  • MLflow, TensorBoard
  • X-AnyLabeling (annotation)

Backend

  • FastAPI + Uvicorn
  • SQLAlchemy 2.0 + Alembic
  • PostgreSQL 16
  • Pydantic v2 / pydantic-settings
  • python-jose (JWT), bcrypt
  • boto3 (S3/SES/SNS), Resend SDK
  • SlowAPI (rate limiting)
  • uv (package management)

Frontend

  • Expo SDK 55 / React Native 0.83
  • React 19 + TypeScript 5.9
  • Expo Router (file-based)
  • Tamagui UI
  • TanStack React Query, Zustand
  • React Hook Form + Zod
  • react-native-fast-opencv / opencv.js
  • expo-camera, expo-secure-store

Cloud deployment

  • Terraform (DigitalOcean & AWS)
  • Docker Compose
  • Caddy (reverse proxy + auto-TLS)
  • GitHub Actions + GHCR
  • DigitalOcean Droplet, Managed PG, Spaces
  • AWS ECS Fargate, RDS, S3, ALB, SES/SNS
  • SSM Parameter Store (secrets)

Status: the Shot Detection model trains and infers end-to-end (ONNX export, quantization and a browser webapp are all working), and Shot Log is a deployed, multi-tenant product. The final API wiring that returns automatic scores from the model into Shot Log's series is the active integration work — the architecture above describes how the two are designed to chain together.

Open the Shot Log app →