01 Overview
A dental sensor saves a single-channel JPEG with no view metadata β nothing on the image says whether it is a bitewing or a periapical. But every downstream pipeline needs to know: tooth-numbering, landmark placement, and view-specific models all branch on it. This is the small model that answers that one question. Give it a radiograph; it returns {label, confidence, processing_ms}.
Its engineering value is not the accuracy β the task is visually near-trivial for a CNN β but that it ships as a clean, dependency-light, reusable inference artifact. The same exported ONNX is consumed three ways, from one file:
home-server microservice
A containerized FastAPI service on :8237, up and healthy today β the production surface, called over the LAN and Tailscale.
Pathology stack
The byte-identical ONNX is copied into the shipped roboflow pathology pipeline and driven in-process to route bitewing vs periapical models β no HTTP hop.
Annotation pipeline
The DenPAR view-detector POSTs to the service and accepts the answer above a confidence gate, deferring borderline cases to an older heuristic.
02 Why I built it
It started as a subcomponent of an earlier segmentation research effort (codename DenPAR). That pipeline read the view from a channel of its training PNGs, but real office sensor images are single-channel β so every one of them defaulted to "periapical," giving wrong tooth-numbering and wrong dual-arch logic for the bitewings.
There was already a hand-built heuristic view classifier β dark-midband detection, content-aware aspect ratio, orientation correction on top of landmark counts. It looked fine internally (~98%) but scored only 94.2% on 550 external images, a 6.7% false-positive rate on periapicals. So I trained a learned model to replace it as primary, on ground-truth-only labels (folder membership is the label; the source datasets know the view by construction β no heuristic labels in the loop). I deliberately made it a self-contained service / CLI / library rather than a function buried inside DenPAR, so other projects could adopt it wholesale β which is exactly what later happened.
03 What I built & how it works
One trained model, exported once, consumed three ways.
Fig. 1 β the “reusable artifact” thesis in one figure: a single exported ONNX feeds a live service, an in-process library, and a confidence-gated cascade.
- Train β EfficientNet-B0 pretrained on ImageNet, head replaced with a
Linear(1280, 2), all layers fine-tuned; class imbalance handled twice over (inverse-frequency weighted loss and a weighted sampler) on a stratified, fixed-seed split. - Export β best checkpoint to ONNX (opset 17, dynamic batch) on a rented GPU, then downloaded; the container ships the ONNX, never PyTorch.
- Preprocess β decode any PIL-readable image to RGB (the single grayscale channel repeated to three), resize to 224Β², scale, ImageNet-normalize β in pure PIL + numpy, so the serving image needs no torch.
- Infer β a single pre-warmed ONNX Runtime session (CPU) returns two logits; softmax β argmax β
{label, confidence, processing_ms}.
04 π Skills & tech used
05 Notable challenges & decisions
A visually easy task, but a few honest engineering lessons around it.
INT8 collapsed to identical logits β so it ships FP32
Static INT8 quantization produced the same output (~52/48) for every image, regardless of input. Rather than hide it, the service ships the full-precision 16 MB FP32 ONNX and the failure is documented. The root cause was never fully diagnosed β a likely-bad calibration step is the leading suspect β and the broken 4.3 MB artifact is kept for traceability. (The parent project's own quantization worked; this collapse was specific to this model.)
Keeping PyTorch out of the serving image
Preprocessing originally pulled in a torchvision transform, which dragged torch into a container that had none. The fix was to inline the whole preprocess in pure PIL + numpy with hardcoded ImageNet constants, so the serve image is just ONNX Runtime, FastAPI, and Pillow β a lean, single-stage python:3.11-slim build with no PyTorch at all.
Replace the heuristic β but keep it as a fallback
On the identical 550-image external set the learned model scored 99.6% against the heuristic's 94.2%, so it became primary. But instead of deleting the old classifier, downstream callers accept the CNN above a 0.55 confidence gate and defer to the heuristic below it β a two-model cascade rather than trusting either alone.
In-distribution accuracy isn’t accuracy
Validation hit 100% at the first epoch β the task is visually clear-cut, and dual class-balancing held up against a real ~5.7:1 periapical majority. But the honest headline is the 99.6% on 550 fully unseen images from independent public datasets, including smartphone photos of X-rays. Confidence dropped on that domain shift (to ~0.67β0.79); accuracy held.
The standalone decision paid off. Building it as a self-contained service / CLI / library β rather than a function inside the pipeline that spawned it β is why a second project could adopt the exact same ONNX byte-for-byte, and why this one component outlived the research effort it came from.
06 Results
The two bitewing misses (of 100) were near coin-flip β predicted periapical at confidence 0.505 and 0.547 β i.e. the model correctly flagged its own uncertainty rather than being confidently wrong. Source: task-summary and an in-code record of the external eval; see the honest note below.
07 Companion artifact β verified tooth masks
The same abandoned effort yielded a second reusable artifact, credited alongside the classifier. From the public DenPAR dataset's 864 auto-matched per-tooth periapical masks, I built a small Gradio tooth-picker to human-verify the FDI tooth identity behind each one: 686 accepted (707 reviewed, 21 rejected). That verified set became the ground truth the shipped SAM tooth-segmentation fine-tunes were both trained and measured against β the benchmark behind a val Dice improvement of 0.9505 β 0.9657 (a decoder-only fine-tune reached 0.9569; a LoRA variant, 0.9657). It is a supporting artifact; the classifier is the primary subject here.
per-tooth mask Β· FDI tooth-picker grid Β· accept / correct
08 Screenshots
No production screenshots are published β the inputs are patient radiographs. Placeholders below stand in for the figures that matter.
Overall 99.6% Β· PA 100% (450) Β· BW 98% (100)
two BW misses annotated at conf 0.505 / 0.547

POST /classify with a bitewing β returns {"label":"BW","confidence":0.73,...} in ~40 ms.99.6% vs 94.2% on the identical 550-image set
09 Honest status
The classifier is live today as a small microservice on :8237 and is shipped inside the pathology stack; it is the one component of that abandoned research arc still running in production. It is an internal tool β reachable over the LAN and Tailscale, with no authentication and no public URL by design, so there is no live demo link here. A three-tier runnable clone exists via the project's reproduce recipe.
Two honest caveats. First, the 99.6% figure is reproducible from the 550 images on disk but not yet serialized to a metrics file β today it survives in a task-summary and an in-code record, not a saved JSON; treat it as reproducible, not freshly re-run. Second, the FP32 pivot ships from the working tree rather than a committed build, so a naΓ―ve rebuild from history would regress to the broken INT8 image β noted, not hidden. (One naming caution for the record: the credited artifact is the learned EfficientNet at 99.6%, not the older 94.2% heuristic that confusingly shares the same name.)
BW/PA Radiograph View Classifier β Build Recipe
Take a bare PC to a running copy of the classifier that labels a dental radiograph Bitewing (BW) vs Periapical (PA). Three ways to run it, easiest first. Pick one.
Status: β Fully verified 2026-07-31 β built from a clean checkout on a fresh machine (a laptop with no prior Docker and none of the author's home-server config) by following this recipe. All tiers pass: Tier 1 (
docker saveβ 334 MB loadable image), Tier 2 (docker compose buildβ container healthy β correct PA/BW), Tier 3a (bare-metal venv + shipped ONNX β correct PA/BW), and Tier 3b (full retrain on a rented RunPod RTX 3090,runpod/pytorch:2.2.1-py3.10-cuda12.1.1,numpy<2β reproduced val-acc 1.0000 / early-stop epoch 6, exported a working ONNX that classified PAβPA & BWβBW, pod self-terminated). Container, bare-metal, and freshly-retrained models all agree. The clean clone lives inclone/next to this file.Sensitive data: none. This app has no API keys, no secrets, no LLM, no
.env. The shipped model was trained only on public dental-radiograph datasets, so distributing the model file is fine. Do not include any real office radiographs β the sample images below are public.
What it is
An EfficientNet-B0 image classifier, exported to ONNX and served as a small HTTP microservice
(FastAPI) plus a CLI. Give it one radiograph; it returns {label, confidence, processing_ms}. The
serving path is torch-free (pure onnxruntime + Pillow + NumPy), so the runtime image is small.
Reference performance (from the original build): 99.6% on 550 unseen external images (450 PA β 100%, 100 BW β 98%). Warm latency β 12β15 ms/image on CPU.
Repo layout (the clone you receive)
bw-pa-classifier/
βββ src/{__init__,dataset,train,export,infer,app}.py # dataset, training, ONNX export, inference, API
βββ scripts/{copy_data,classify,eval_external}.py # data prep Β· CLI Β· external benchmark
βββ tests/{test_dataset,test_infer,test_api}.py
βββ models/classifier.onnx # β the trained model (16 MB, ships with the bundle)
βββ Dockerfile Β· docker-compose.yml
βββ requirements-serve.txt # runtime deps (no torch)
βββ requirements-train.txt # training deps (torch/torchvision) β only needed for Tier 3b
The model file models/classifier.onnx is git-ignored in the original repo, so the bundle ships it
alongside the source (or, if published, it is fetched from a release asset on first build).
Prerequisites
Starting from a bare machine? Install the base tools first β see the shared
../SETUP.md (Docker Desktop + WSL2 on Windows/macOS, or Docker Engine on Linux;
Python 3.11+; git), including the real install gotchas. Then, per tier:
| Tier | You need |
|---|---|
| 1 β prebuilt container | Docker (see ../SETUP.md). ~1 GB free. Nothing else. |
| 2 β build from source | Same as Tier 1, plus the source bundle. |
| 3a β bare-metal inference | Python 3.11+ and pip. CPU only β no GPU required. |
| 3b β retrain from scratch | Python 3.11+, ideally an NVIDIA GPU + recent CUDA (works on CPU, just slower), and the public datasets (Β§3b). ~0.5 GB of images. |
Configuration
None. This is a standalone classifier β no keys, no database, no external services, and no
.env at all. (This is the recipe principle in action: if it isn't needed, it isn't here.)
Tier 1 β Run the prebuilt container (recommended)
Availability: the prebuilt image bundle is available on request β it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.
# 1. Load the image (offline tarball)β¦
docker load -i bw-pa-classifier-image.tar
# 2. Start it
docker compose up -d
# 3. Confirm it's healthy
curl http://localhost:8237/health
# β {"status":"ok","model":"efficientnet_b0_int8"}
That's the whole install. (The efficientnet_b0_int8 string in /health is a cosmetic leftover β
the service actually serves the full-precision FP32 model; see Notes.) Jump to Verify.
Tier 2 β Build the image from source
The Dockerfile and docker-compose.yml are the "how the container is built" documentation.
docker compose up -d --build # builds the image, then runs it
curl http://localhost:8237/health
Dockerfile (unchanged from source β it is already clean and self-contained):
FROM python:3.11-slim
WORKDIR /app
COPY requirements-serve.txt .
RUN pip install --no-cache-dir -r requirements-serve.txt
COPY src/ ./src/
COPY models/classifier.onnx ./models/classifier.onnx
EXPOSE 8237
CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8237", "--workers", "1"]
docker-compose.yml for the standalone clone β note what was removed vs. the original on the
author's home server (see What was stripped, below):
services:
bw-pa-classifier:
build: .
container_name: bw-pa-classifier
ports:
- "8237:8237" # change the left number to remap the host port
restart: unless-stopped
mem_limit: 512m
healthcheck:
test: ["CMD", "python3", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8237/health', timeout=4)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
Tier 3 β Bare metal (no Docker)
3a β Run inference with the shipped model
python -m venv .venv
# Windows: .venv\Scripts\activate Linux/macOS: source .venv/bin/activate
pip install -r requirements-serve.txt
# Option A β the HTTP service (same as the container):
uvicorn src.app:app --host 0.0.0.0 --port 8237 --workers 1
# Option B β the CLI (one image or a glob):
python scripts/classify.py samples/pa_example.jpg
# β PA (confidence: 0.84)
--workers 1 is deliberate: the ONNX InferenceSession is not thread-safe, so a single worker
serializes requests. models/classifier.onnx must be present (it ships with the bundle).
3b β Reproduce the weights (retrain from public data)
Only needed if you want to reproduce the weights rather than use the shipped ONNX. (Verified 2026-07-31: run end-to-end on a rented RunPod RTX 3090 β reproduced val-acc 1.0000 in 6 epochs, exported a working classifier; ~10β15 min, β \$0.10; see Status and Provenance β Training hardware.)
First, assemble the dataset (both options need it). Sort grayscale dental radiographs into two label folders β see Provenance β Training data for the exact public datasets, counts, and licenses (all public; ~1,534 PA + ~271 BW):
data/
βββ pa/ β periapical X-rays (.jpg/.jpeg/.png)
βββ bw/ β bitewing X-rays
The train/val/test split is computed automatically (stratified 70/15/15, fixed seed).
Option A β rent a cloud GPU, fully automated (exactly how the shipped weights were made)
One script, train/runpod-train-bw-pa.py, does the whole thing and cleans up after itself. It
creates a SECURE RTX 3090 pod β polls for SSH β verifies CUDA (aborts + terminates if it
fails) β tars & uploads src/ + data/ β installs deps (numpy<2 + torchvision/onnx/onnxruntime)
β trains (PYTHONPATH=. python src/train.py) β downloads best.pth immediately β exports ONNX
(python src/export.py) β downloads all artifacts into models/ β terminates the pod in a finally
so it can't leak a running GPU even on error.
Prereqs on your machine: Python 3 with requests, the ssh / scp / tar CLIs, and an SSH keypair.
Edit these for your account β the USER CONFIG block at the top of train/runpod-train-bw-pa.py:
1. RunPod API key β create one at runpod.io β Settings β API Keys, then export it (below).
2. SSH keypair β ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 if you don't already have one.
3. GPU β GPU_TYPE_ID (any RTX 3090/4090). Keep the CUDA-12.1 image + numpy<2 pin to match the
shipped weights. (PROJECT_DIR auto-detects this clone, so src/ + data/ are found for you.)
Then, from the clone root:
pip install requests
export RUNPOD_API_KEY="rpa_XXXXXXXX" # β your key
python train/runpod-train-bw-pa.py # rents β trains β exports β downloads β terminates
Measured cost/time: ~10β15 min end-to-end (training itself ~2 min), RunPod SECURE RTX 3090 @
\$0.50/hr β \$0.10. The pod self-terminates β confirm none linger on the RunPod dashboard.
(If the run's SSH drops mid-training, train/runpod-resume-bw-pa.py resumes on the same still-running
pod β paste its id/host/port into that file's EDIT block.)
Option B β train on your own GPU (or CPU), manual
pip install -r requirements-train.txt
PYTHONPATH=. python src/train.py # β models/best.pth (GPU if available, else CPU β just slower)
PYTHONPATH=. python src/export.py # β models/classifier.onnx
Expect ~100% validation accuracy within a few epochs, early-stopped (visually clear-cut task); the
honest headline is the 99.6% on external data (scripts/eval_external.py). export also writes an
INT8 variant β known-broken on this model (identical logits); serve only classifier.onnx.
Provenance β data, model & training hardware
Everything a builder needs to reproduce the weights, not just run them. (Tiers 1β2 and 3a use the shipped model and need none of this; it matters for Tier 3b and for honest attribution.)
Training data
All public β no clinic images. (Dr. Yoder's office radiographs were used only for testing this
classifier, not training it.) data/pa/ and data/bw/, ~1,805 images total:
| View | ~N | Source | License |
|---|---|---|---|
| PA | 534 | Kaggle β muhammadsajad/periapical-xrays (via kagglehub) |
see Kaggle page |
| PA | 1,000 | DenPAR β Rasnayaka S., Leuke Bandara D., Jayasundara A., et al. "DenPAR: Annotated Intra-Oral Periapical Radiographs Dataset for Machine Learning." Sci Data 12, 1615 (2025). Zenodo DOI 10.5281/zenodo.16645076 Β· paper https://doi.org/10.1038/s41597-025-05906-9 | CC-BY 4.0 |
| BW | 271 | Kaggle β shubhamskg/bitewing-datasets |
see Kaggle page |
DenPAR (public on Zenodo β no credentials) is the source of this classifier's PA training images and of
the verified tooth masks credited in the rΓ©sumΓ©; its source population is Sri Lankan. The two Kaggle sets
need a free Kaggle API token (~/.kaggle/kaggle.json) to download; DenPAR does not.
External evaluation set (550 fully-unseen images β the source of the 99.6% headline): - PA, 450 β Fretes LΓ³pez, V., Adorno, C. G., Mello-RomΓ‘n, J. C., Alderete, D., DΓaz, F., MΓΌller, M., & Escobar-Torres, R. (2024). Dataset of dental radiographs for the study of periapical lesions (v1) [Data set]. Zenodo. https://doi.org/10.5281/zenodo.13772918 β CC BY 4.0. - BW, 100 β Kybic, J., TichΓ½, A., & Kunt, L. (2023). Dental caries in bitewing radiographs. Mendeley Data, v1. https://doi.org/10.17632/4fbdxs7s7w.1 β CC BY-NC 3.0 (non-commercial β fine to reproduce/evaluate; not for commercial redistribution).
Clinic-data convention (not triggered here, shown for the standard): where a project trains on Dr. Yoder's own radiographs, they're listed as "de-identified [PA/BW] radiographs from Dr. Yoder's practice β image files carry no embedded patient identifiers (verified: no DICOM/EXIF PHI tags); distributed with the practice owner's authorization." The no-embedded-PHI claim is verified by inspecting the files' metadata when the clone is built, not merely asserted.
Pretrained model / weights
- EfficientNet-B0, ImageNet-pretrained (
torchvision.models.EfficientNet_B0_Weights.IMAGENET1K_V1) β downloaded automatically bytorchvisionon first training run. The classifier head is replaced withLinear(1280, 2)and the whole network is fine-tuned. - No other external checkpoints. The shipped
models/classifier.onnxis this fine-tuned model, ONNX-exported (opset 17).
Training hardware (so a RunPod/Vast.ai session is trivial to match)
The reference weights were trained on a rented cloud GPU β you do not need to own one:
| Provider | RunPod (SECURE cloud) β a Vast.ai instance with the same GPU works identically |
| GPU | 1Γ NVIDIA RTX 3090 (24 GB) β a single mid-range GPU is ample |
| Container image | runpod/pytorch:2.2.1-py3.10-cuda12.1.1-devel-ubuntu22.04 |
| Critical pin | numpy<2 (avoids a torch/onnxruntime ABI clash) |
| Run | 6 epochs, early-stopped (patience 5) β val-acc 1.0000. Terminate the pod when done. |
| Measured (2026-07-31 re-run) | Training itself ~2 min (6 epochs); full rented session ~10β15 min (dominated by the 432 MB data upload + pod provisioning). RunPod SECURE RTX 3090 @ \$0.50/hr β \$0.10 for the session. |
CPU-only training also works (just slower). On Vast.ai, pick any RTX 3090/4090, match the CUDA-12.1
PyTorch image and the numpy<2 pin, and you'll reproduce the same result.
Verify
The clone ships two public sample radiographs in samples/ (a periapical from the Zenodo set, a
bitewing from the Mendeley set β see Provenance). Actual outputs from the verify build:
# health
curl http://localhost:8237/health
# β {"status":"ok","model":"efficientnet_b0_int8"}
# classify the periapical sample
curl -s http://localhost:8237/classify -F "file=@samples/pa_example.jpg"
# β {"label":"PA","confidence":0.84,"processing_ms":115}
# classify the bitewing sample
curl -s http://localhost:8237/classify -F "file=@samples/bw_example.png"
# β {"label":"BW","confidence":0.73,"processing_ms":30}
Success = /health returns 200 with "status":"ok", and /classify returns the correct label.
Confidence on these domain-shifted public images is moderate (~0.7β0.85) β the model signalling
honest uncertainty on out-of-distribution inputs, not a failure; accuracy holds even as confidence
drops. (The first /classify after startup is slower β ~100 ms cold β then ~12β30 ms.)
Notes & gotchas
/healthsaysefficientnet_b0_int8, but the service is FP32. Cosmetic stale label from the original build; behavior is the full-precision model. (The clone commits the FP32 state as canonical so a clean checkout builds the working model β the original repo left that pivot uncommitted.)- INT8 quantization is broken here β shipped FP32 (16 MB) instead of INT8 (4 MB). Documented, not
hidden. Only
classifier.onnxis ever served. - Single worker β ONNX
InferenceSessionisn't thread-safe; keep--workers 1and serialize concurrent demo requests. - Grayscale in, RGB model β single-channel radiographs are repeated to 3 channels and ImageNet-normalized; handled internally, no action needed.
- Training only (3b): if you hit a NumPy/torch/ONNX ABI clash, pin
numpy<2in the training environment (the original cloud build used that pin). The serving path is unaffected. - First request after startup is slower (~100 ms cold) while the ONNX session warms; subsequent calls are ~12β15 ms.
What was stripped from the home-server version (recipe minimalism in action)
The clone is deliberately simpler than the app as it runs on the author's server. Removed because a fresh user doesn't need them:
- External
homeserverDocker network. The original compose joined a shared bridge network on the author's home server (networks: homeserver: external: true) so sibling containers could reach it by name. A standalone clone doesn't have that network β dropped; the container just publishes port 8237. - Nothing else to strip here β this project never used the author's LiteLLM gateway or any multi-machine setup, or any credential. (Other projects' recipes will strip more: the LiteLLM paid/free/backup setup, Juice GPU-over-LAN, etc.)