Vortex-Flux / src /Algorithms /document_classifier.py
jaimalklyde's picture
Claude Sonnet 5
Nettoie les imports et fonctions mortes detectes par audit statique
5b0dd39
Raw History Blame Contribute Delete
6.54 kB
"""
PrΓ©-filtre visuel CLIP (ViT-B/32) β€” triage zero-shot des documents KYC.
Place dans le pipeline : APRES le televersement, AVANT Docling + Gemini.
Objectif : economiser le quota Gemini en ecartant rapidement (CPU, < 1s)
les documents qui ne ressemblent clairement pas au type attendu.
Design retenu (cf. discussion architecture) :
- Zero-shot : aucun dataset a constituer, aucun fine-tuning. Les classes
sont decrites par des phrases (prompts) comparees aux embeddings d'image.
- Triage, PAS rejet binaire : seuls les desaccords a haute confiance
(>= SEUIL_BLOCAGE) bloquent. Une basse confiance repart vers le
pipeline classique (Docling + Gemini) β€” un document atypique mais
legitime ne doit jamais etre perdu.
- INT8 dynamique sur CPU : quantification des couches Linear (~4x moins
de memoire, accelΓ©ration sur CPU). Fallback FP32 si indisponible.
- PDF : rasterisation de la 1re page via PyMuPDF (aucune dependance
systeme Poppler β€” compatible HF Spaces).
Dependances a ajouter dans requirements.txt :
open_clip_torch
pymupdf
torch (deja present)
Pillow (deja present)
"""
import os
import io
import torch
import open_clip
from PIL import Image
# Seuil de confiance a partir duquel un desaccord type_attendu/detecte
# bloque le document AVANT de consommer du quota Gemini.
# 0.80 = tolerant (recommande pour demarrer, a ajuster apres mesure).
SEUIL_BLOCAGE = 0.80
# Libelles d'affichage (cles = identifiants internes = doc_type du formulaire)
CATEGORY_LABELS = {
"CNI_PASSEPORT": "CNI / Passeport",
"JUSTIFICATIF_ADRESSE": "Justificatif d'adresse",
"BULLETIN_SALAIRE": "Bulletin de paie",
"RIB": "RIB / Releve bancaire",
"RELEVE_COMPTE": "RelevΓ© bancaire / Mobile Money (Wave, OM)",
"AUTRE": "Autre document",
}
# Prompts textuels zero-shot : plusieurs par categorie pour robustesse.
PROMPTS = {
"CNI_PASSEPORT": [
"a photo of an identity card or a passport page with a portrait photo and official stamps",
"a national identity card document",
],
"JUSTIFICATIF_ADRESSE": [
"a utility bill, rent receipt or official proof of address showing a postal address",
"an invoice or official letter showing a home address",
],
"BULLETIN_SALAIRE": [
"a payslip or salary statement document with tables of amounts and deductions",
"a pay stub with a net salary",
],
"RIB": [
"a bank account details document with an IBAN number and a bank logo",
"a bank statement header with bank coordinates",
],
"RELEVE_COMPTE": [
"a bank statement or mobile money statement showing a list of transactions and account balance",
"a mobile money account statement from Wave or Orange Money with transaction history",
],
"AUTRE": [
"a random photo, a screenshot or any unrelated document",
],
}
class DocumentClassifier:
"""Singleton β€” le chargement du modele ne se fait qu'une fois."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(DocumentClassifier, cls).__new__(cls)
cls._instance._load()
return cls._instance
def _load(self):
print("[PREFILTRE] Chargement de CLIP ViT-B/32...")
model, _, self.preprocess = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
# Quantification INT8 dynamique (CPU) β€” les deux tours separement,
# inplace. Si l'un echoue, on continue en FP32 (budget < 1s tenu).
try:
torch.quantization.quantize_dynamic(model.visual, {torch.nn.Linear}, dtype=torch.qint8, inplace=True)
except Exception as e:
print(f"[PREFILTRE] Quantification 'visual' impossible ({e}) β€” FP32.")
try:
torch.quantization.quantize_dynamic(model.text, {torch.nn.Linear}, dtype=torch.qint8, inplace=True)
except Exception as e:
print(f"[PREFILTRE] Quantification 'text' impossible ({e}) β€” FP32.")
model.eval()
self.model = model
self.tokenizer = open_clip.get_tokenizer("ViT-B-32")
# Encodage des prompts : fait UNE SEULE FOIS au chargement.
self.prompt_index = [] # position -> categorie
all_prompts = []
for cat, prompts in PROMPTS.items():
for p in prompts:
all_prompts.append(p)
self.prompt_index.append(cat)
with torch.no_grad():
tokens = self.tokenizer(all_prompts)
txt = self.model.encode_text(tokens)
self.text_features = txt / txt.norm(dim=-1, keepdim=True)
print("[PREFILTRE] Pret.")
@staticmethod
def _to_image(file_bytes, file_name):
"""PDF -> rasterise la 1re page (PyMuPDF, sans Poppler). Sinon PIL."""
ext = os.path.splitext(file_name)[1].lower()
if ext == ".pdf":
import fitz # pymupdf
doc = fitz.open(stream=file_bytes, filetype="pdf")
pix = doc[0].get_pixmap(dpi=144)
img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")
doc.close()
return img
return Image.open(io.BytesIO(file_bytes)).convert("RGB")
@torch.no_grad()
def classify(self, file_bytes, file_name):
"""
Retourne (categorie_detectee, confiance) avec categorie dans
CATEGORY_LABELS. Confiance = probabilite softmax agregee par
categorie (moyenne sur ses prompts).
"""
img = self.preprocess(self._to_image(file_bytes, file_name)).unsqueeze(0)
feats = self.model.encode_image(img)
feats = feats / feats.norm(dim=-1, keepdim=True)
logits = self.model.logit_scale.exp() * feats @ self.text_features.T
probs = logits.softmax(dim=-1).cpu().numpy()[0]
agg = {}
for p, cat in zip(probs, self.prompt_index):
agg[cat] = agg.get(cat, 0.0) + float(p)
total = sum(agg.values()) or 1.0
best = max(agg, key=agg.get)
return best, agg[best] / total
# ── Test rapide en ligne de commande ─────────────────────────────────────────
if __name__ == "__main__":
import sys
clf = DocumentClassifier()
for path in sys.argv[1:]:
with open(path, "rb") as fh:
cat, conf = clf.classify(fh.read(), os.path.basename(path))
print(f"{path} -> {CATEGORY_LABELS.get(cat, cat)} [{conf:.1%}]")