Fine-Tuning LFM2-VL-450M for X-ray Defect Detection and Localization

LoRA fine-tuning a 450M local VLM on real GDXray casting X-ray images — detection got measurably better, localization never worked, and I show the raw predictions that prove it.
computer-vision
VLM
fine-tuning
LoRA
semiconductor
X-ray
edge-ai
Author

Hasan Goni

Published

August 9, 2026

Modified

October 26, 2026

Note

Images removed. This post’s figures showed frames from GDXray or outputs of models trained on them. GDXray is free for research and education only, and redistribution is not allowed, so the figures are gone. The text, code, numbers and plots are unchanged. The new synthetic void dataset is what this blog shows from now on.

0.1 Where this picks up

Part 2 ended with local VLMs failing hard at zero-shot wafer-defect classification — 1-2/9, mostly by picking one default answer and riding it regardless of what was on screen. That result bugged me more than I expected it to. The obvious next question: does fine-tuning actually close that gap, or does a 450M model just learn a different, more sophisticated-looking shortcut?

I decided to push further than plain classification, partly because “defect or not” felt like too easy a target to declare victory on. Instead I wanted LFM2-VL-450M to say where — a bounding box, grounded against real annotation data, evaluated with real IoU, not a vibe check where I squint at a prediction and decide it looks close enough.

Short version: detection got measurably better. Localization never worked, in any of the four configurations I tried — and I can show you exactly what it did instead. I went in expecting a messier, more ambiguous result than this. What I got was cleaner and, honestly, a little more humbling.

0.2 The task

Input: one X-ray image + a fixed prompt. Output: a JSON object.

PROMPT = (
    "Examine this X-ray image of a metal casting for defects (cracks, porosity, voids). "
    'Respond with only a JSON object of the form {"defect": true|false, "boxes": [[x1,y1,x2,y2], ...]}. '
    "Each box is a defect region in x1,y1,x2,y2 pixel coordinates normalized to a 0-1000 scale "
    "relative to the image width and height. If there is no defect, use an empty list."
)

Boxes are normalized 0-1000 relative to each image’s own width/height — a standard grounding convention, and necessary here since GDXray images vary in resolution. Schema, enforced at generation time with Outlines:

from pydantic import BaseModel, conlist

MAX_BOXES = 6  # covers 97.3% of GDXray defect images (real max is 11, but rare)

class Detection(BaseModel):
    defect: bool
    boxes: conlist(conlist(int, min_length=4, max_length=4), max_length=MAX_BOXES) = []

Outlines constrains decoding token-by-token so the model cannot emit malformed JSON — no retry loops, no regex parsing of free text. This is the kind of infrastructure choice that feels like overkill until the first time a model hands you {"defect": true, "boxes": [[12, 34, and you realize you’d otherwise be writing a janky recovery parser at 11pm.

One deliberate scope decision: no defect-type label (crack vs. porosity vs. shrinkage). GDXray doesn’t provide one — only bounding boxes. I was tempted, briefly, to invent plausible type labels just to make the task look richer for this post. I didn’t, because that would have broken the one thing that actually makes this series credible to me: every ground-truth label is real.

0.3 Dataset: GDXray Castings

GDXray+ Castings group: 2,727 X-ray images across 67 series, mostly aluminum automotive parts (wheels, knuckles), each with defect bounding-box annotations in a per-series BoundingBox.mat. Free for research/education use.

0.3.1 Obstacle #1: the official download is scripted-download-blocked

GDXray’s Dropbox links return a JS-gated interstitial to any non-browser client — curl with a spoofed user-agent, cookies, referer headers, none of it got past a Dropbox - Error page. This ate more of my afternoon than I’d like to admit. I found a Kaggle mirror instead (samarthgoel2604/gdxray-without-synthetic-for-defectd) that preserves the original per-series folder structure, pulled via kagglehub:

import kagglehub
path = kagglehub.dataset_download("samarthgoel2604/gdxray-without-synthetic-for-defectd")

0.3.2 Obstacle #2: the bounding-box column order is not what you’d guess

BoundingBox.mat has one row per defect region: 5 numbers. I assumed [image_index, x1, y1, x2, y2] — the “obvious” convention, because it’s the one I’ve seen everywhere else. Wrong. Cross-checking against a reference implementation (maxkferg/metal-defect-detection), the actual column order is [image_index, x1, x2, y1, y2] — x-range then y-range, not interleaved. My first guess would have silently produced a degenerate box (x2 < x1) for at least one real annotation I checked by hand — the kind of bug that doesn’t crash, doesn’t warn you, and just quietly trains the model on garbage. Small thing, but it’s exactly the sort of mistake that would have invalidated everything downstream without ever throwing an error.

I didn’t just trust the reference code either — I rendered the corrected box on a real image, because at this point I trust my own eyes more than either source:

[Figure removed: it showed GDXray pixels, or a model output made from them, and the GDXray licence does not allow redistribution.]

Lesson I keep re-learning: when a dataset format is undocumented and a reference implementation disagrees with the “obvious” reading, render a box on a real image before trusting either.

0.3.3 Manifest and split

Ground truth gives a clean per-image label for free: an image is defect if its index appears in any row of its series’ BoundingBox.mat, no_defect otherwise.

boxes_by_index: dict[int, list[list[float]]] = {}
for r in bb:
    idx, x1, x2, y1, y2 = int(r[0]), float(r[1]), float(r[2]), float(r[3]), float(r[4])
    boxes_by_index.setdefault(idx, []).append([x1, y1, x2, y2])

87 series, 3,981 images, 714 defect (17.9%) / 3,267 no_defect (82.1%) — a real, imbalanced, single-label split close to real-world defect rates. 1,319 total boxes; 318 images have more than one defect region.

Split train/eval by series, not by image — images within one series are highly correlated (same casting, adjacent rotation angles), so a naive image-level split would leak information between train and eval and quietly flatter every number in this post. I was paranoid enough about this to check it twice before running anything. Result: 3,313 train images (70 series) / 668 eval images (17 series), same seed reused across every experiment below so comparisons stay fair.

0.4 Zero-shot baseline

Before touching any weights, I ran the same prompt against the vanilla model on all 668 eval images.

accuracy precision recall mean IoU
Zero-shot 26.3% 21.0% 100% 0.00

100% recall sounds great for about as long as it takes to open the confusion matrix: the model predicted defect: true for 623 of 668 images (93.3%) — it wasn’t detecting defects, it was just saying yes to nearly everything, which is a strategy but not a useful one. And on the 131 true positives, mean IoU against ground truth was exactly 0.00 — zero real localization signal, zero-shot. Not a great start, but at least an honest one.

(Side note, and this one genuinely amused me: a differently-worded plain-text prompt asking only for defect/no_defect — no JSON, no boxes — produced the opposite degenerate behavior: no_defect on all 668 images, 0% recall. Prompt framing alone flipped which majority-class shortcut the model defaulted to. Neither framing engaged with the actual image content zero-shot — the model just picked whichever lazy answer the prompt nudged it toward.)

0.5 LoRA fine-tune

0.5.1 Config

Following Liquid AI’s own fine-tuning guidance: rank 16, lora_alpha=32 (2×r). Their docs list generic target-module names for their model family (q_proj, v_proj, fc1, fc2, linear, gate_proj, up_proj, down_proj) — but half of those don’t exist as literal module names in LFM2-VL-450M’s actual architecture, which I only found out because I checked instead of copy-pasting. I checked with named_modules() before trusting the doc:

LORA_TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "out_proj", "fc1", "fc2"]

0.5.2 Training target = the same schema Outlines will constrain at eval time

def target_json(row: dict) -> str:
    boxes = [normalize_box(b, row["width"], row["height"]) for b in row["boxes"][:MAX_BOXES]]
    det = Detection(defect=row["label"] == "defect", boxes=boxes)
    return det.model_dump_json()

Training the model to emit exactly what Outlines will later constrain it to, rather than free-text, so there’s no train/eval mismatch in output format. Felt obvious once I’d written it, less obvious before — my first draft of this pipeline trained on loosely-formatted text and I had to backtrack.

0.5.3 Label masking via prompt-prefix trick

Loss should only apply to the response tokens, not the image+prompt context. Rather than parsing the chat template’s special-token structure by hand — which I really did not want to do — I exploited that the tokenized prompt is an exact prefix of the tokenized full conversation (image processing is deterministic regardless of what text follows it). I verified this holds before relying on it, because “should be true” and “is true in this specific tokenizer” are not the same claim:

prompt_inputs = processor(images=[img], text=prompt_text, return_tensors="pt")
full_inputs = processor(images=[img], text=full_text, return_tensors="pt")
prompt_len = prompt_inputs["input_ids"].shape[1]
labels = full_inputs["input_ids"].clone()
labels[:, :prompt_len] = -100  # ignored by cross-entropy

0.5.4 Class-weighted sampling

Train split is 17.6% defect. Left alone, a fine-tune on this data will happily relearn “always say no_defect” — it’s an easy 82% accuracy, and I’ve watched a model take that shortcut before, so I wasn’t going to let it happen twice. Inverse-frequency weighted sampling forces roughly balanced defect/no_defect exposure per epoch:

weight_defect = 1.0 / n_defect
weight_ok = 1.0 / n_ok
sample_weights = [weight_defect if l == "defect" else weight_ok for l in labels_for_weight]
sampler = WeightedRandomSampler(sample_weights, num_samples=len(train_rows), replacement=True)

0.5.5 Obstacle #3: training instability

First run (flat lr=1e-4, no schedule, 800 steps, checkpoint every 200): loss trended down (1.23 → 0.56), which looked promising, right up until quick-eval on a held-back 80-image subset oscillated between collapsing to “always no_defect” and a more balanced state, checkpoint to checkpoint:

step accuracy recall precision
0 (baseline) 52.5% 100% 51.3%
200 50.0% 0% — collapsed
400 60.0% 52.5% 61.8%
600 50.0% 0% — collapsed again
800 (final) 48.75% 12.5% 45.5%

Watching recall bounce from 52.5% to 0% to 52.5% to 0% again across a single training run is not a fun way to spend an evening. I added a warmup + cosine LR decay schedule (peak lr=5e-5, 10% warmup) and reran, checking every 100 steps instead of 200, half-expecting that to fix it. It didn’t fix the oscillation — recall still bounced 0%→100%→12.5%→0%→32.5%→30% across the run. That ruled out “missing LR schedule” as the root cause and points instead at the small effective batch size (gradient-accumulated batch of 4, on a dataset with only ~583 real defect training examples after oversampling) producing a high-variance gradient estimate. Something I’d want to fix with a larger effective batch before trusting this recipe further — I’m noting it here rather than quietly smoothing it over, because I’d want to know this if I were reading someone else’s post.

0.5.6 Results — full 668-image eval, four configurations

config accuracy precision recall mean IoU
Zero-shot 26.3% 21.0% 100% 0.00
LoRA, flat lr, step 400 60.3% 26.6% 58.0% 0.00
LoRA, flat lr, step 800 (final) 75.6% 26.5% 13.7% 0.00
LoRA, warmup+cosine, step 800 (final) 66.0% 23.9% 33.6% 0.00

Detection genuinely improved, and this is the part I’m actually pleased with. Zero-shot’s degenerate “flag everything” (100% recall / 21% precision) became a real, much more balanced decision at step 400 — 58% recall at similar precision. Unstable across configurations, not a clean monotonic win — I’d be lying if I called this recipe “solved” — but real signal that the model learned something about defect presence.

Localization never moved. At all. In any configuration. Mean IoU is exactly 0.00 across zero-shot and all three fine-tuned checkpoints. Four separate runs, same dead number every time.

0.5.7 I didn’t just report 0.00 — I checked why

A number that’s exactly 0.00 in four separate runs is either a very clean finding or a bug in my eval code, and I’ve been burned by the second option enough times to not just trust the first. Before writing this up, I pulled the raw predictions:

true: [[456.0, 502.0, 489.0, 545.0]]  pred: [[0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0]]
true: [[459.0, 483.0, 489.0, 524.0]]  pred: [[0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0]]
true: [[462.0, 465.0, 492.0, 503.0]]  pred: [[0,0,0,0], [0,0,0,0]]
true: [[466.0, 447.0, 499.0, 483.0]]  pred: [[0,0,0,0], [0,0,0,0], [0,0,0,0], [0,0,0,0]]

Every single predicted box, on every image, is [0,0,0,0] — regardless of where the real defect actually is. That’s a genuinely funny (in a bleak way) thing to discover after a training run: the fine-tuned model learned to say "defect": true more accurately, then just… gave up on the harder task hiding inside the same JSON object, and found the laziest possible way to make the coordinate loss go down. [0,0,0,0] is a cheap, constant way to reduce the coordinate-token loss without ever learning to regress a real value through next-token prediction — and apparently a local optimum the training found and settled into, every time.

That’s a much stronger, more specific finding than “IoU was low,” and honestly a more satisfying one to have dug up than to have just reported the number and moved on. Detecting that something’s wrong turned out to be learnable in an 800-step LoRA budget on a 450M model. Detecting where — asking a generalist small VLM to learn numeric grounding purely through text-token generation, from a few hundred examples — did not work once, in four attempts.

0.6 What’s next

I don’t think a 450M generalist VLM, fine-tuned this way, is the right tool for pixel-level localization from scratch, and I say that having genuinely wanted it to work. The natural next experiment: split the job. Use a dedicated segmentation/prompted-detection model (SAM3, or similar) to find candidate defect regions, and hand that result to the VLM for reasoning — instead of asking one small model to learn both “what” and “where” end-to-end through the same text-generation objective.

If you’ve combined a segmentation model with a small VLM this way for a narrow visual task, I’d genuinely like to hear about it — curious whether the extra moving part is worth it in practice, or just trades one set of headaches for another.


Full code, all scripts referenced above, and the raw per-image results: github.com/HasanGoni/lfm2-vl-xray-defects

Reuse

Citation

BibTeX citation:
@online{goni2026,
  author = {Goni, Hasan},
  title = {Fine-Tuning {LFM2-VL-450M} for {X-ray} {Defect} {Detection}
    and {Localization}},
  date = {2026-08-09},
  langid = {en}
}
For attribution, please cite this work as:
Goni, Hasan. 2026. “Fine-Tuning LFM2-VL-450M for X-Ray Defect Detection and Localization.” August 9.
Free email course · 7 days

Industrial Vision Foundations

AI can write the code, but someone still has to know which method fits a real inspection line. One short lesson a day for 7 days on the classical computer vision techniques that still run production systems.

Runnable code in every lesson. No spam, unsubscribe any time.

Questions, corrections or feedback?

Found a mistake, or want a topic covered next? Leave a comment below (sign in with GitHub), or write to hasanme1412@gmail.com. I read everything.