Spaces:
Running
Running
File size: 6,538 Bytes
a25284c 6038199 a25284c 6038199 a25284c 37903ad a25284c 37903ad a25284c 37903ad a25284c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | """
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%}]") |