Two-stage caries screening for intraoral photographs: a custom-trained detector locates suspected carious lesions, and an off-the-shelf medical vision-language model generates a plain-language description of each region.
The goal isn't a diagnostic replacement — it's a decision-support pipeline that (a) surfaces regions of interest and (b) hands a clinician a readable rationale for each one, so a dentist doesn't have to trust an opaque bounding box.
Trained on the Aga Khan University Zenodo dataset — 6,313 annotated intraoral photographs from adolescents/young adults in Mithi, Sindh, Pakistan (Ahmed et al., Scientific Data 2025).
Evaluated on a patient-safe occlusal-only test split — 332 images, no patient appearing in both train and test. This is a strictly harder evaluation than the paper's, which uses an image-level split (same patients in train, val, and test).
| model | split type | mAP50 | mAP50-95 | Precision | Recall |
|---|---|---|---|---|---|
| Paper's YOLOv8s (published) | image-level (has patient leakage) | 0.841 | — | 0.828 | 0.807 |
This repo's best.pt |
patient-safe, occlusal-only | 0.860 | 0.692 | 0.824 | 0.845 |
The detector matches or exceeds the paper's baseline on every metric and it does so on a split that eliminates patient leakage. mAP50-95 is reported for this work only; the paper does not publish it.
See "Full ML metrics" below for the per-class breakdown and combined-pipeline numbers.
Two suspected occlusal lesions on posterior molars, each with an independent per-region reasoning:
Single subtle detection on a healthy-looking maxillary arch — model appropriately hedges its confidence:
Ground truth vs. model prediction (test-set image with 4 annotated lesions, all recovered by the model; 2 low-confidence extras marked separately for review):
┌──────────────────┐
Intraoral photo ─▶ │ Detector (YOLO) │ ─▶ boxes + class + confidence
│ best.pt (77 MB) │
└──────────────────┘
│
▼ (each detection is cropped with 15% padding, padded to a square)
┌──────────────────┐
│ MedGemma 1.5 │ ─▶ short description + confidence hedge
│ 4B via Ollama │
└──────────────────┘
│
▼
┌──────────────────────────────┐
│ Client-facing report │
│ • annotated image │
│ • per-lesion reasoning │
│ • findings summary │
└──────────────────────────────┘
A YOLOv11m fine-tune on the occlusal-only subset (Mandibular + Maxillary_Occlusal views, ~2,500 images). Frontal and lateral views were excluded during training because caries lesions aren't visible from those angles — the paper trains on all views, which we found injects noise into the "no caries" signal. Training config:
- imgsz = 1280
- batch = 32
- 120 epochs, SGD + cosine schedule, mosaic aug
- A100-40GB (via the Colab notebook in
notebooks/)
MedGemma 1.5 4B served locally through Ollama (medgemma1.5:latest, Q4_K_M quantization, ~3.3 GB). For every crop produced by the detector, MedGemma answers a fixed prompt asking what it sees and how confident it is that it's caries. The response is the reasoning that ships to the clinician.
A single-stage VLM being asked to both localize and describe caries in a full-mouth photograph has a well-documented failure mode: it rationalizes whatever box it drew. Separating the two — a specialist detector for localization, then a generalist for narration — means MedGemma only ever sees a tight crop and can't hallucinate a lesion from context that isn't in the crop. It also lets us evaluate the two components independently.
The published paper uses a random 80/10/10 image-level split. In this dataset, every patient contributes ~5 views to the corpus — a random image-level split leaks the same mouth into train, val, and test. That inflates all reported metrics. Our splitter (src/prepare_data.py) groups images by patient ID (parsed from the anonymised filenames) and splits at the patient level. The --views filter restricts to occlusal views where caries is actually visible.
src/
prepare_data.py build the patient-safe, occlusal-only split
train_yolo.py train the detector (M4-tuned defaults)
build_vlm_data.py convert annotations to VLM instruction JSONL
finetune_medgemma.py LoRA fine-tune MedGemma (CUDA/A100)
main.py single-stage MedGemma inference on a whole image
two_stage.py detector -> MedGemma per crop
analyze.py clinician-facing two-panel report generator
eval_two_stage.py measures whether MedGemma vetos improve precision
test_full.py full test-set evaluation, aggregates all metrics
notebooks/
colab_yolo_training.ipynb A100 notebook to reproduce best.pt (~30 min)
colab_medgemma_finetune.ipynb A100 notebook to LoRA fine-tune MedGemma (~1-2 h)
weights/
best.pt the trained detector (77 MB, YOLOv11m)
assets/
example_detection_*.png sample client-facing reports
gt_vs_prediction.jpg ground-truth vs. model comparison
- Python 3.10+
- Ollama installed and running (
ollama serve) - MedGemma pulled:
ollama pull medgemma1.5 pip install ultralytics pillow pyyaml numpy scikit-learn
python src/analyze.py path/to/photo.jpg --out ./out.png --report ./out.jsonProduces a two-panel image (original left, annotated right) with a "Findings" section listing each detected lesion and MedGemma's reasoning.
Open notebooks/colab_yolo_training.ipynb in Google Colab, select an A100 runtime, Run All. Takes ~30 minutes and downloads the Zenodo dataset itself.
python src/prepare_data.py --root ./Dataset --out ./yolo_split \
--patient-regex 'anonymous[_-](\d+[_-]\d+[_-]\d+)' \
--views 'Mandibular,Maxillary_Occlusal' --copy
python src/test_full.py --conf 0.25 --iou-thr 0.5Writes test/METRICS.json, test/REPORT.md, and 332 per-image PNG + JSON reports to test/testset/.
All numbers below are computed on the same 332-image patient-safe test split — 850 ground-truth caries boxes total. Run: src/test_full.py, wall time 1h 39min on M4 (bottlenecked by second-stage MedGemma inference).
| Metric | Value | Meaning |
|---|---|---|
| mAP50 | 0.8597 | mean Average Precision at IoU=0.5 (standard COCO/YOLO summary metric) |
| mAP50-95 | 0.6916 | averaged over IoU thresholds 0.5→0.95 in steps of 0.05 (stricter localization) |
| Precision | 0.8243 | of the boxes the model draws, this fraction are real lesions |
| Recall | 0.8445 | of all real lesions in the images, this fraction are found |
Class definitions from Ahmed et al. 2025: d = primary (deciduous) tooth decay, D = permanent tooth decay.
| Class | Support (train boxes) | mAP50 | mAP50-95 | Precision | Recall |
|---|---|---|---|---|---|
d — primary caries |
5,923 | 0.9353 | 0.7372 | 0.8580 | 0.8764 |
D — permanent caries |
503 | 0.7842 | 0.6459 | 0.7905 | 0.8126 |
| weighted mean | — | 0.8597 | 0.6916 | 0.8243 | 0.8445 |
The 11.8:1 class imbalance in training data is visible in the primary/permanent gap (~15pp mAP50), but not catastrophically so — recall on the rare D class is still 0.81. If parity across classes matters for a deployment, class-weighted training or a single merged "caries" class would close the gap.
Every detection from the detector is cropped (with 15% padding) and passed to MedGemma 1.5 (Ollama, Q4_K_M). MedGemma returns a free-text description that we parse into confirm / uncertain / reject. Every detection is matched to ground truth by IoU ≥ 0.5 to label it TP or FP.
| Signal | count | notes |
|---|---|---|
| Total detections | 914 | ~2.75 detections per image on average |
| Total ground-truth boxes | 850 | across 332 images |
| YOLO-alone TP | 763 | |
| YOLO-alone FP | 151 | |
| YOLO-alone FN | 87 | boxes the detector missed entirely |
YOLO alone at this operating point: P = 0.8348, R = 0.8976.
The interesting bit — is MedGemma's confidence a useful filter?
| MedGemma verdict | correct (TP) | wrong (FP) | precision of this verdict |
|---|---|---|---|
| confirm | 473 | 63 | 88.2% — very reliable positive signal |
| uncertain | 95 | 16 | 85.6% — also mostly correct |
| reject | 195 | 72 | only 27.0% correct — mostly rejecting real TPs |
The "reject" verdict is not a good filter: three of every four "rejects" are actually true positives the detector correctly found. If we drop them:
| Metric | YOLO alone | + MedGemma veto | Δ |
|---|---|---|---|
| TP | 763 | 568 | -195 |
| FP | 151 | 79 | -72 |
| FN | 87 | 282 | +195 |
| Precision | 0.8348 | 0.8779 | +4.3pp |
| Recall | 0.8976 | 0.6682 | -22.9pp |
- FPs removed by veto: 72 / 151 = 47.7%
- TPs lost by veto: 195 / 763 = 25.6%
Conclusion: off-the-shelf MedGemma-as-veto is the wrong architecture. You buy 4pp of precision for 23pp of recall. For a screening tool where missed cavities are worse than false alarms a dentist can dismiss in two seconds, this trade is a loss. The useful signal is the "confirm" verdict, not the "reject" one — 88% precision on confirms means MedGemma reliably corroborates real TPs, and that's what should ship: highlight the confirmed detections, present the rest with a "review this" flag.
| System | Test split | mAP50 | Precision | Recall | mAP50-95 |
|---|---|---|---|---|---|
| YOLOv5s (paper) | image-level (leaky) | 0.780 | 0.768 | 0.743 | — |
| YOLOv11s (paper) | image-level (leaky) | 0.812 | 0.776 | 0.735 | — |
| YOLOv8s (paper, best) | image-level (leaky) | 0.841 | 0.828 | 0.807 | — |
| SSD-MobileNet-v2 (paper) | image-level (leaky) | 0.680 | — | — | — |
| Faster R-CNN (paper) | image-level (leaky) | 0.306 | — | 0.461 | — |
This work best.pt |
patient-safe | 0.860 | 0.824 | 0.845 | 0.692 |
The apples-to-apples comparison is with YOLOv8s (paper's strongest baseline). This repo's best.pt beats it on mAP50 (+1.9pp) and recall (+3.8pp) while on a strictly harder split.
- Per-image ground truth, predictions, IoUs, MedGemma verdicts, and TP/FP labels:
test/testset/*.json(332 files) - Client-facing annotated PNGs (Original | Analysis + Findings):
test/testset/*.png(332 files) - Aggregate JSON:
test/METRICS.json - Human-readable summary:
test/REPORT.md
- Class imbalance. Training data has 5,923 primary-decay boxes vs 503 permanent-decay boxes (~12:1). The model is heavily biased toward
d(primary). Per-class recall onD(permanent) is materially lower. The two classes should probably be merged for a first clinical version. - Only occlusal views. The detector was intentionally trained only on Mandibular and Maxillary_Occlusal views — the ones where caries is visible. Frontal/lateral views are out-of-distribution and the model will either return nothing or fire on spurious edges.
- Templated reasoning at training time. If you use the MedGemma fine-tune notebook, the training traces are geometrically derived from bounding-box positions ("a lesion in the upper-left region"). This teaches the model to sound clinical, not to reason clinically. For real clinical reasoning you need clinician-authored rationales. The current shipped pipeline uses off-the-shelf MedGemma (no fine-tune), which describes what it sees on each crop without any training-set bias.
- The dataset itself. The Ahmed et al. 2025 dataset is collected from a single mobile device (Samsung Galaxy A23) in a single geographic setting (Mithi, Sindh, Pakistan), covering ages 10-24. Any model trained on it inherits those distributional limits.
- Not a medical device. This is decision-support research code. It does not replace a dentist's judgment.
Dataset — the sole source of all training data used in this project:
Ahmed, S.M.F., Ghori, M.H., Khalid, A., Nooruddin, A., Adnan, N., Lal, A., & Umer, F. (2025). Annotated intraoral image dataset for dental caries detection. Scientific Data, 12, 1297. https://doi.org/10.1038/s41597-025-05647-9
Models used:
MIT — see LICENSE.
The dataset used to train best.pt is licensed CC BY-NC-ND 4.0 by its authors and is not redistributed in this repository. Download it directly from Zenodo if you need to retrain.


