A two-stage detector for dental caries on intraoral photographs. YOLO locates candidate lesions; MedGemma (LoRA fine-tuned on the same dataset, then GGUF-quantized for Ollama) writes a per-crop verdict and rationale for each detection.
Ships with:
- Full source for training, inference, and evaluation
- YOLO11m detector weights (
best.pt) trained on a patient-safe, occlusal-only split — no patient-level leakage between train/val/test - LoRA adapter for
google/medgemma-1.5-4b-it(Dataset/model/medgemma_caries_lora/) - GGUF-quantized fine-tune ready for Ollama, hosted on Hugging Face
- End-to-end evaluation on 332 held-out test images with all rendered per-image panels
- A 44MB collage of every single test-set panel
| Asset | What it is | Link |
|---|---|---|
| 📦 Custom model (everything) | Full repo: code, YOLO weights, LoRA, dataset split, eval outputs, panels, collage | git clone https://github.com/Eeman1113/dental_study.git |
| 🎯 Custom YOLO model | best.pt — YOLO11m, ~81 MB, patient-safe occlusal split, mAP50 = 0.860 |
Download best.pt |
| 🦷 Custom DentaMedGemma (LoRA adapter) | PEFT adapter on top of google/medgemma-1.5-4b-it, ~131 MB |
Download adapter_model.safetensors |
| 🦷 Custom DentaMedGemma (Ollama-ready GGUF) | Q4_K_M quantized + vision projector, drop into Ollama | HF: sdvzdfgfngdfgh/caries-medgemma-gguf |
One-liner to pull everything and get the model running:
git lfs install && git clone https://github.com/Eeman1113/dental_study.git && cd dental_study
huggingface-cli download sdvzdfgfngdfgh/caries-medgemma-gguf \
medgemma_caries-Q4_K_M.gguf mmproj-medgemma_caries-F16.gguf \
--local-dir Dataset/model/gguf
ollama create caries-medgemma -f Modelfile.caries- Headline result
- What the fine-tune actually does
- Sample outputs — 8 close-ups
- Full test set at a glance — 332-panel collage
- Repository layout
- Setup
- Run inference
- Reproduce the evaluation
- Training
- Data source
- License
Full held-out test set: 332 images · 850 ground-truth lesions · IoU ≥ 0.5 for TP · YOLO confidence ≥ 0.25.
| Pipeline | TP | FP | FN | Precision | Recall | F1 |
|---|---|---|---|---|---|---|
| YOLO alone | 763 | 151 | 87 | 0.835 | 0.898 | 0.865 |
| YOLO + base MedGemma veto | 568 | 79 | 282 | 0.878 | 0.668 | 0.759 |
| YOLO + fine-tuned MedGemma veto | 599 | 108 | 251 | 0.847 | 0.705 | 0.769 |
Detector-only mAP: mAP50 = 0.860, mAP50-95 = 0.692.
For context: the source paper reports YOLOv8s mAP50 = 0.841 on an image-level split (which leaks patients between train and test). Our split is at the patient level — strictly harder, closer to real deployment.
The LoRA transferred, but not into a useful veto. Two lenses:
For a veto to help, it needs to remove far more false positives than it kills true positives. Neither MedGemma configuration achieves that here.
- Base MedGemma: kills 25.6 % of TPs to save 47.7 % of FPs.
- Fine-tuned: kills 21.5 % of TPs to save only 28.5 % of FPs.
The fine-tune is more permissive than base (fewer rejects overall), which is why it preserves more TPs — but it also lets more FPs through.
Read the reject row: only 21 % of the fine-tune's "reject" verdicts were actually false positives — the other 79 % were real lesions the model wrongly killed. That's why veto costs so much recall.
Fed a full intraoral photo with the training prompt, the fine-tune emits the exact templated negative-example response ("Scanning the visible dentition surface by surface. The enamel appears intact...") across every image, every temperature, every seed. Almost certainly a training-data imbalance — too many all-negatives in the JSONL, so the model memorized the negative template.
Bottom line: ship YOLO alone with MedGemma as a reasoning annotator per detection, not as a filter. analyze.py produces exactly that panel — box + short rationale — leaving the veto decision to a human.
Each panel below is a full test-image analysis: original photo (left), detector overlay (right), per-detection reasoning below. Filenames encode the failure mode being illustrated.
01_best_confirm — 7 detections, 7 correct, MedGemma confirmed all 7.

08_single_clean_tp — one lesion, one detection, confidently confirmed.

02_veto_saved_fp — YOLO overdetected (2 real + 5 FPs). The fine-tune correctly rejected 3 of the 5 FPs.

03_veto_killed_tp — 9 real lesions, all 9 detected, but the fine-tune rejected 2 of them. This is the failure mode that dominates our recall drop.

04_confirmed_fp — YOLO produced 3 real + 5 FPs. MedGemma confirmed all 8, including every false positive. Fluent rationalization of noise.

05_complex_mixed — 3 real detected + 1 FP + 1 GT missed entirely. Two uncertain, one reject (killed a TP), one confirm on the FP. The model's every verdict here is wrong in the "helpful" direction.

07_missed_lesion — 7 GT lesions, YOLO found only 2 (both confirmed correctly). MedGemma can't fix what the detector never proposed.

06_true_negative — no ground-truth lesions, YOLO returned nothing, no verdict needed.

Every one of the 332 test-set panels, tiled into a single image. Click to view full-resolution (8084 × 6872, 44 MB) — each panel is the same layout as the close-ups above.
Individual full-resolution panels + per-image JSONs are in test_caries_full/testset/.
.
├── analyze.py Clinician-facing single-image analysis (image + boxes + findings panel)
├── two_stage.py Minimal two-stage runner (image → boxes + per-crop verdict)
├── main.py Original whole-image localization probe with grounding checks
├── test_full.py Full test-set evaluation: mAP + veto cross-tab + panels
├── eval_two_stage.py Two-stage eval used before test_full
├── train_yolo.py YOLO11 training script
├── prepare_data.py Build the patient-safe YOLO split from the Zenodo dataset
├── build_vlm_data.py Convert YOLO split into MedGemma JSONL (with grounded reasoning traces)
├── finetune_medgemma.py LoRA fine-tune of MedGemma 1.5 4B (CUDA / transformers)
├── colab_medgemma.ipynb Same fine-tune wrapped for Colab A100
├── merge_lora.py Merge LoRA into base and save as HF format
├── fix_prefix.py One-shot: strip transformers-5.x 'model.' prefix from merged safetensors
├── try_caries_medgemma.py Whole-image inference with the training prompt (against Ollama)
├── make_collage.py Tile all 332 eval panels into one PNG
├── Modelfile.caries Ollama Modelfile for the fine-tuned model
├── best.pt / best_m4.pt / best_colab.pt YOLO11m weights (various runs; best.pt is canonical)
├── yolo11s.pt YOLO base weights
├── Dataset/model/
│ └── medgemma_caries_lora/ LoRA adapter (PEFT format)
│ (GGUF weights on HF: sdvzdfgfngdfgh/caries-medgemma-gguf)
├── yolo_split/ Patient-safe YOLO split (train / val / test)
├── test/ Pre-finetune baseline eval (base medgemma1.5)
├── test_caries_30/ 30-image subset eval (fine-tuned)
├── test_caries_full/ Full 332-image eval (fine-tuned) ← headline numbers
└── reports/
├── collage.png Full 332-panel collage
├── samples/ Close-up representative panels
└── charts/ Metrics figures
- macOS (tested on Apple Silicon M4) or Linux
- Python 3.11+ with
torch>=2.11,transformers>=5.5,peft>=0.19,ultralytics,pillow,matplotlib - Ollama 0.32+ (for serving the quantized fine-tune)
git-lfsfor cloning this repo with weights
git lfs install
git clone https://github.com/Eeman1113/dental_study.git
cd dental_studyGGUF weights live on Hugging Face (GitHub LFS caps files at 2GB; F16 is 7.2GB): 👉 sdvzdfgfngdfgh/caries-medgemma-gguf
mkdir -p Dataset/model/gguf
huggingface-cli download sdvzdfgfngdfgh/caries-medgemma-gguf \
medgemma_caries-Q4_K_M.gguf mmproj-medgemma_caries-F16.gguf \
--local-dir Dataset/model/gguf
ollama create caries-medgemma -f Modelfile.caries
ollama show caries-medgemmaOr rebuild from scratch:
# 1. Merge LoRA into base (needs HF login + license accepted on gated repo)
huggingface-cli login
python merge_lora.py
# 2. Fix transformers-5.x prefix
python fix_prefix.py
# 3. Convert to GGUF (needs a local llama.cpp clone)
git clone https://github.com/ggerganov/llama.cpp .llama.cpp
python .llama.cpp/convert_hf_to_gguf.py Dataset/model/medgemma_caries_merged \
--outtype f16 --outfile Dataset/model/gguf/medgemma_caries-F16.gguf
python .llama.cpp/convert_hf_to_gguf.py Dataset/model/medgemma_caries_merged \
--mmproj --outtype f16
mv Dataset/model/medgemma_caries_merged/mmproj-*.gguf \
Dataset/model/gguf/mmproj-medgemma_caries-F16.gguf
# 4. Quantize + import into Ollama
llama-quantize Dataset/model/gguf/medgemma_caries-F16.gguf \
Dataset/model/gguf/medgemma_caries-Q4_K_M.gguf Q4_K_M
ollama create caries-medgemma -f Modelfile.caries# Single image, full clinician-facing panel (recommended)
python analyze.py path/to/photo.jpg --model caries-medgemma --out out.png
# Compact two-stage runner (prints verdicts to stdout)
python two_stage.py path/to/photo.jpg --model caries-medgemma
# Whole-image detection with the training prompt
# (mostly collapses to negative — see "what the fine-tune actually does")
python try_caries_medgemma.py path/to/photo.jpg# Full test set (~90 min on M4 Mac mini)
python test_full.py --model caries-medgemma --out ./test_caries_full
# Rebuild the collage of all 332 panels
python make_collage.py
# Regenerate the README charts (needs matplotlib)
python /tmp/make_charts.py # or copy the script from git historyOutputs land in <out>/METRICS.json, <out>/REPORT.md, and <out>/testset/*.{png,json}.
The fine-tune ran on Colab A100-40GB with QLoRA:
| Hyperparameter | Value |
|---|---|
| Base model | google/medgemma-1.5-4b-it |
| LoRA rank | 16 |
| LoRA α | 32 |
| LoRA dropout | 0.05 |
| Target modules | q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj (language tower only; vision tower frozen) |
| Epochs | 3 |
| Batch × grad-accum | 1 × 8 |
| Learning rate | 1e-4 (cosine, warmup 3 %) |
| Precision | bf16 |
| Quantization | 4-bit NF4 |
Known failure mode: the training data (built by build_vlm_data.py) uses a fixed templated negative response for every image without lesions:
"Scanning the visible dentition surface by surface. The enamel appears intact with no discoloration, cavitation, or shadowing that would indicate decay."
If the negative-to-positive ratio in your split is too high, the model memorizes that template and outputs it on every whole-image query. Rebalance the JSONL or paraphrase the negative template before retraining.
The full training scripts are finetune_medgemma.py (local CUDA) and colab_medgemma.ipynb (Colab A100).
Original dataset: Zenodo record 14827784 (~1.6 GB, ~2000 intraoral photographs with pixel-level caries annotations).
prepare_data.py builds a patient-safe YOLO split with only Mandibular and Maxillary_Occlusal views (the two views with consistent framing).
Code: MIT. Model weights inherit their upstream licenses:
- YOLO11 weights: AGPL-3.0 (Ultralytics)
- MedGemma weights: HAI-DEF Terms of Use (Google) — not for clinical use.





