Student ID card forgery detection
Card checks at the university library were a person looking at a card, and the printed QR code was never wired to any database. This is a four-stage pipeline that decides instead, and shows its reasoning. It reaches 97.74% accuracy and a 0.9912 ROC-AUC at native resolution, and runs in 4 GB of VRAM.
- 1,645 images
- 5 forgery scenarios
- 4 GB VRAM



How the pipeline works
01Qwen3-VLSemantic gate. Rejects anything that is not an ID card, zero-shot.
It has to throw out a photo of a desk, a hand, a blank wall, before any of that reaches the classifier. Evaluated separately with an LLM-as-a-judge pass over card and non-card images, where it scored perfectly.
02Florence-2 + CannyPhrase grounding locates the card, then edge detection crops and deskews it.
Florence-2 says where the card is, Canny finds its edges, and the crop straightens it. The classifier never sees the desk it was lying on, so background stops being a feature it can cheat with.
03EfficientNet-B0Binary classifier, genuine against forged.
Two-phase transfer learning from ImageNet: the head trains first with the backbone frozen, then the whole network fine-tunes. Mixup at alpha 0.4, AdamW, seed 42. The shipped model runs at 1024×474 and fits in 4 GB of VRAM.
04Grad-CAMHeatmap over the region that drove the decision.
This is the part that keeps the rest honest. On a font forgery the heat sits on the text block; on a face swap it sits on the portrait. If it had lit up the background instead, the accuracy figure would have meant nothing.
The audit
Four models scored a perfect 1.0000. I went looking for a data leak, found one that contaminated 29.1% of the test set, fixed it, and the accuracy did not move. The leak was real and it explained nothing.
48 of 165 test images contaminated on the 80:10:10 split. That is 29.1%.0 of 165 contaminated. Every derivative of one card lands in the same subset.
Before the rebuild, images made from the same physical card were shuffled independently, so one card could appear in both the training set and the test set. Rebuilding the split by card family put every derivative of a card into a single subset, bringing contamination to zero.
Read what happened next
Every forgery was built from a real card and kept its number, so real_214 became fake_214. My split script shuffled the genuine files and the forged files independently, which let two halves of one physical card land on opposite sides of the train-test boundary. I rewrote the split to work on card families. Contamination went to zero.
Then I retrained, and it was still 1.0000.
A five-fold group-aware cross-validation agreed. At 1024×474 the two classes genuinely do separate, and the contamination had been riding along without changing the outcome. I had spent the audit proving my own suspicion wrong, and reported it that way.
My examiners raised a different objection. If every model scores 1.0, there is no basis for saying which one is better. They asked for results that were not perfect.
The quick way to do that is to make the numbers worse. I did not. Instead I ran a resolution ablation, holding every other parameter fixed and dropping the input to each architecture's native size, 224×224 for B0 and 300×300 for B3. Accuracy fell to between 0.9606 and 0.9774 on its own, and the fall carried information: the model leans on high-frequency forensic traces that do not survive being shrunk.
Every false negative in those runs came from one scenario. Font inconsistency. Not a single face swap, recapture, text patch, or hybrid got past. Four files defeated every native-resolution model I trained. Letterforms are the finest detail in the dataset, so they are the first thing to go when a card is squeezed to 224 pixels wide.








