← Kevin Yoder
Digital Dentistry

Radiograph View Classifier

A small EfficientNet-B0 that labels a dental radiograph bitewing, periapical, or panoramic β€” 605 of 605 never-trained external images β€” packaged as torch-free ONNX files that three separate systems consume. The interesting part is not the accuracy; it is what the model does when it isn't sure.

Live service Β· :8237 Built Apr 2026 3-class Β· Aug 2026 EfficientNet-B0

βš™ How it works πŸ“Š Results

01 Overview

A dental sensor saves a single-channel JPEG with no view metadata β€” nothing on the image says whether it is a bitewing, a periapical, or a panoramic. But every downstream pipeline needs to know: tooth-numbering, landmark placement, and view-specific models all branch on it, so a wrong view is a wrong pipeline, not just a wrong label. This is the small model that answers that one question. Give it a radiograph; it returns {label, confidence, spread, uncertain} β€” the last two being how strongly four independently-trained models disagreed, and whether that disagreement is large enough that a person should look.

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:

Live Β· HTTP

home-server microservice

A containerized FastAPI service on :8237, up and healthy today β€” the production surface, called over the LAN and Tailscale.

In-process Β· library

Pathology stack

The byte-identical ONNX is copied into the shipped roboflow pathology pipeline and driven in-process to route the bitewing, periapical, and panoramic model paths β€” no HTTP hop.

Cascade Β· HTTP

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.

It grew a third class later, and for a concrete reason. The pathology pipeline it feeds had gained panoramic support, but a two-class model has no way to say "panoramic" β€” asked an impossible question, it answers bitewing or periapical confidently and the wrong pipeline runs. Panoramics were being routed by a cheap aspect-ratio check instead, which works on the wide ones and silently misses the squarer clinic exports. Adding the class it was missing was the honest fix.

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.

radiograph β€” JPEG / PNG, any view classifier.onnx Γ— 4 FP32 Β· 16 MB each Β· EfficientNet-B0 β†’ 3 logits home server Β· FastAPI POST /classify Β· :8237 live Β· containerized roboflow in-process ModalityClassifier byte-identical ONNX routes BW / PA / pano DenPAR Β· cascade _detect_view() accept if conf β‰₯ 0.55 else heuristic fallback

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.

  1. Train β€” EfficientNet-B0 pretrained on ImageNet, head replaced with a Linear(1280, 3), all layers fine-tuned; class imbalance handled twice over (inverse-frequency weighted loss and a weighted sampler) on a stratified, fixed-seed split. Four members are trained with deliberately different recipes β€” a plain baseline, one with extra landscape-crop augmentation, one with mixup, one with stronger presentation jitter β€” because an ensemble is only useful for spotting errors if its members can fail differently.
  2. Calibrate & export β€” each best checkpoint gets a single temperature fitted on a deliberately hard, half-degraded slice of validation data, folded into the final layer so serving stays a plain softmax; then ONNX (opset 17, dynamic batch) on a rented GPU. The container ships the ONNX, never PyTorch.
  3. 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.
  4. Infer β€” four pre-warmed ONNX Runtime sessions (CPU) each return three logits; the softmax vectors are averaged, argmax gives the label, and the members' disagreement on the winning class becomes spread β†’ {label, confidence, spread, uncertain}.

04 πŸ›  Skills & tech used

Languages
Python 3.11Bash / SSHDockerfileCompose YAML
ML / AI
EfficientNet-B0 transfer learningfull fine-tuneclass-weighted CE + weighted samplerstratified fixed-seed splitONNX export (opset 17)INT8 static quantizationcosine LR Β· AdamW Β· early stopping
Infra / MLOps
RunPod pod-lifecycle automationONNX Runtime servingFastAPI microserviceDocker (slim, no-torch)UFW Β· Tailscalemulti-system ONNX distribution
Data
multi-source dataset assemblycollision-safe renamingpublic-benchmark acquisition (Zenodo Β· Mendeley)
Techniques
1-channel β†’ 3-channel for ImageNet weightstorch-free numpy preprocessconfidence-gated two-model cascadeexternal-generalization discipline

05 Notable challenges & decisions

A visually easy task, but a few honest engineering lessons around it.

Quantization

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.)

Dependencies

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.

Cascade

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.

Evaluation

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 external accuracy on independent public datasets, including smartphone photos of X-rays. Confidence drops on that domain shift; accuracy holds. A saturated validation set is not evidence β€” it is the absence of it.

Failure mode

A softmax cannot flag its own rare mistakes

The model’s errors were rare but confident β€” arriving at 0.99, indistinguishable from its correct answers, so no confidence threshold could catch them. Two wide, crown-bright molar periapicals were misread as bitewings that way. The fix was not a better single model: four members trained on different recipes agree to within ~0.005 on everything easy and visibly diverge on exactly those hard images, so their disagreement β€” not their confidence β€” became the honesty signal the API returns.

Calibration

The calibration step that made things worse, and why

Temperature scaling was fitted on lightly-augmented validation data, where the model scores 99.98%. Minimizing loss on a set with essentially no mistakes in it can only ever conclude “be more confident” β€” it returned a temperature of 0.33, multiplying every logit roughly threefold and sharpening the wrong answers along with the right ones. The exact failure being fixed. Calibrating instead on a deliberately hard, half-degraded slice (aggressive crops, blur, resolution loss, contrast crush) β€” where accuracy genuinely falls to ~80% β€” produced sensible temperatures, and a hard floor now forbids the model from ever being sharpened.

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

Current three-class model (August 2026). Every external set below was never trained on.

605/605
never-trained external images, all correct
99.98%
held-out test split (1 miss of 5,490)
101/101
panoramics too square for the cheap shape check to catch
0.52
confidence on the hardest image β€” correct, and honestly unsure
~12–15ms
per image per member, CPU, warm
3
consumers of the same exported files

The two periapicals the previous version got confidently wrong are now both correct β€” one at 0.80, the hardest at 0.52 β€” and both are flagged as uncertain by the disagreement signal rather than being asserted. The panoramic tile is the one that mattered practically: a cheap aspect-ratio check already routes obvious panoramics, so the model earns its place on the ones that check cannot see.

One gate was not met, and it is worth stating plainly: overall calibration error is 0.068 against a 0.03 target. The model is under-confident β€” it averages 0.93 on answers that are right 99.98% of the time β€” because the training deliberately smooths its targets. That humility is the trade that buys the honest 0.52 on hard images, so it was accepted rather than tuned away. The wrong-answer confidence figure looks good at 0.58, but it rests on a single error across 6,095 images and should not be read as a reliable statistic.

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.

[ Gradio review UI ]
per-tooth mask Β· FDI tooth-picker grid Β· accept / correct
The verification UI: one mask at a time, confirm or correct its FDI number, save to ground truth. (The underlying radiographs are patient imagery and are not shown.)

08 Screenshots

No production screenshots are published β€” the inputs are patient radiographs. Placeholders below stand in for the figures that matter.

[ external-generalization bars ]
605 / 605 never-trained external images
bitewing 50 Β· periapical 225 Β· panoramic 330
The chart that carries the project: every never-trained external image correct, across three independent public sources per view.
The view classifier's Swagger UI: title with OpenAPI 3.1 badge, a GET /health endpoint and a POST /classify endpoint, and the request/response schemas below.
Proof it is a running service, not a notebook: the API's own docs. A real call β€” POST /classify with a bitewing β€” returns a label, a confidence, and how much the four models disagreed, in tens of milliseconds.
[ the two hard periapicals ]
previously wrong at ~0.99
now correct at 0.80 and 0.52, both flagged uncertain
The failure mode that drove the rebuild: confident errors are worse than uncertain ones, because only the second kind can be caught.

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. A three-tier runnable clone exists via the project's reproduce recipe, and the hosted demo linked above runs the same model.

The honest caveats. The calibration gate described above is failed, not fixed β€” the model is systematically under-confident on easy images, and the fix for that (training without smoothed targets) would likely bring back the confident errors it was built to avoid, so the trade was made knowingly. The wrong-answer confidence number rests on one error and is not yet a real measurement; it needs more failures than the model currently produces. The earlier two-class version's headline was 99.6% on a 550-image external set; half of those images were later folded into training, so the current figures use the never-trained halves plus new panoramic sets, and the two numbers are not directly comparable. And the INT8 export remains broken on this model β€” full-precision files are what ship, documented rather than hidden.

BW/PA/Pano Radiograph View Classifier β€” Build Recipe

Take a bare PC to a running copy of the classifier that labels a dental radiograph Bitewing (BW), Periapical (PA), or (since the 2026-08-30 retrain) Panoramic (Pano). 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 in clone/ 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 a label β€” Bitewing, Periapical, or (since 2026-08-30) Pano β€” a confidence, a processing time, and (since the 2026-08-28 ensemble) a disagreement signal (spread / uncertain) flagging images the ensemble members don't agree on. 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.

2026-08-28 β€” v4b retrain + ensemble. The shipped weights were retrained on an 18,944-image multi-source corpus (dedup'd + label-noise-purged; 100% on the held-out split and on two never-trained external sets). The service now runs a 4-model ensemble: reported confidence is the ensemble mean, and disagreement between members is returned as spread / uncertain in the API and surfaced as a caution in the UI (a single model's softmax cannot flag its own rare errors β€” they arrive at ~1.0). Corpus assembly: clone/scripts/build_data_v2.py; evaluation battery: clone/scripts/eval_v2.py; disagreement measurement: clone/scripts/ensemble_probe.py.

2026-08-30 β€” v5, 3-class retrain. The classifier now has a real Pano label instead of forcing every image into Bitewing or Periapical β€” the two indices those views already used are unchanged, so nothing downstream of the label breaks. Trained on 36,487 images: the same Bitewing/Periapical pools reused as-is, plus a newly built and cross-source-deduplicated Pano pool from seven public sources (1,741 exact/near-duplicate images dropped in the process, including 44 training images that turned out to be byte-copies of a validation holdout). Still a 4-member ensemble, same reported-confidence convention (ensemble mean + disagreement spread). Held-out accuracy 99.98%; on the never-trained short-aspect panoramics that a simple image-shape heuristic can't catch, 101/101. Both of this project's own known hard-case misses are now correct β€” one lands at 52% confidence, flagged uncertain, which is the intended "honestly not sure" behavior rather than a confident wrong answer. One check did not pass: calibrated confidence (measured ECE 0.068 against a 0.03 target). The miss is in the forgiving direction β€” the model under-states confidence on easy images rather than over-stating it on hard ones β€” and was accepted deliberately as the trade-off for the harder-case honesty above, not glossed over. Corpus assembly: clone/scripts/build_data_v3.py; full gate results: clone/models/EVAL_V5.md.

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)
β”œβ”€β”€ models/modality_ens_v5{b,c,d}.onnx                  # ← the other 3 ensemble members (ensemble added 2026-08-28; 3-class since 2026-08-30)
β”œβ”€β”€ 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_fp32_ensemble_v5_3class"}

That's the whole install. (/health's model string tracks the actual model in production β€” it no longer lags behind like the original build's efficientnet_b0_int8 cosmetic leftover did; 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 (excerpt below predates the 2026-08-04 UI rebuild β€” the current clone/Dockerfile additionally COPYs samples/ and the image serves an upload-and-classify UI at / alongside the API at /docs; see PREBUILT_IMAGES.md):

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 β€” this is the original, Tier-3b-reproducible training set; see below for what actually trained the weights this recipe ships today:

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).

Demo sample sources β€” the three images shipped in samples/ for the Verify walkthrough below (full record in samples/ATTRIBUTIONS.md): - PA (pa_example.jpg) β€” from the Zenodo 13772918 external evaluation set above β€” CC BY 4.0. - BW (bw_example.png) β€” from the Mendeley 4fbdxs7s7w.1 external evaluation set above β€” CC BY-NC 3.0. - Pano (pano_example.jpg, added 2026-08-30) β€” Panoramic radiographs with periapical lesions Dataset, Mendeley Data kx52tk2ddj (https://data.mendeley.com/datasets/kx52tk2ddj) β€” CC BY 4.0 (original source image stem 16481).

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.

What actually trained today's shipped weights: still all-public, no clinic images, but a much larger and still-growing corpus β€” 18,944 images for the 2026-08-28 retrain, then 36,487 for the 2026-08-30 3-class retrain (the original BW/PA pools reused as-is, plus a newly built Pano pool from seven public sources). That multi-source assembly pulls from a larger local dataset cache than fits this zero-prerequisite recipe, so it isn't reproduced step-by-step here β€” Tier 3b below reproduces the original, smaller public baseline instead, which is still an honest end-to-end retrain, just not a byte-for-byte match of the production weights. See the version notes above for what changed and why between the original build, v4b, and v5.

Pretrained model / weights

  • EfficientNet-B0, ImageNet-pretrained (torchvision.models.EfficientNet_B0_Weights.IMAGENET1K_V1) β€” downloaded automatically by torchvision on first training run. The classifier head is replaced with Linear(1280, 2) (the original/Tier-3b baseline) and the whole network is fine-tuned. The currently-shipped production weights use a Linear(1280, 3) head (adding Pano) across a 4-member ensemble with label smoothing 0.05 and post-hoc temperature calibration folded into the export β€” see the 2026-08-30 note above.
  • No other external checkpoints. The shipped models/classifier.onnx is a fine-tuned EfficientNet-B0 of this family, 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 three public sample radiographs in samples/ (a periapical from the Zenodo set, a bitewing from the Mendeley set, and β€” added 2026-08-30 for the v5 3-class model β€” a panoramic from a third public Mendeley set; see Provenance). Actual outputs from the v5 verify build:

# health
curl http://localhost:8237/health
# β†’ {"status":"ok","model":"efficientnet_b0_fp32_ensemble_v5_3class","n_models":4}

# classify the periapical sample
curl -s http://localhost:8237/classify -F "file=@samples/pa_example.jpg"
# β†’ {"label":"PA","confidence":0.961,"processing_ms":152}

# classify the bitewing sample
curl -s http://localhost:8237/classify -F "file=@samples/bw_example.png"
# β†’ {"label":"BW","confidence":0.946,"processing_ms":79}

# classify the pano sample
curl -s http://localhost:8237/classify -F "file=@samples/pano_example.jpg"
# β†’ {"label":"OPG","confidence":0.910,"processing_ms":63}

Success = /health returns 200 with "status":"ok", and /classify returns the correct label. Confidence on these domain-shifted public images is moderate-to-high (~0.9–0.96 on the v5 3-class ensemble) β€” a step up from the original 2-class baseline's ~0.7–0.85. Even where confidence dips lower on a harder image, that reflects the model honestly signalling uncertainty on an out-of-distribution input rather than a failure β€” accuracy holds even as confidence drops. The full response also carries spread (disagreement across the 4 ensemble members) and uncertain (a boolean flag when that spread crosses a threshold), both omitted above for brevity. (The first /classify after startup is slower β€” ~100 ms cold β€” then ~12–30 ms.)

Notes & gotchas

  • /health's model string now tracks reality (efficientnet_b0_fp32_ensemble_v5_3class) β€” it no longer lags behind like the original build did, where it said efficientnet_b0_int8 while actually serving FP32 (a cosmetic stale label; the clone always committed 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.onnx is ever served.
  • Single worker β€” ONNX InferenceSession isn't thread-safe; keep --workers 1 and 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<2 in 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 homeserver Docker 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.)