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%}]")