File size: 11,857 Bytes
b137257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46b12e1
b137257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Nutrient Document Classification — open-vocab, zero-shot demo (public-ready).

Censored like the sibling grounding demo: NO proprietary weights and NO custom architecture code ship in this
Space. Everything trained runs as ONNX loaded at RUNTIME:
  * v2 flagship (commercial) — the full model runs as ONNX fetched from the PRIVATE repo via the
    HF_TOKEN secret (server-side only, never committed here). Marked unavailable if the secret is absent.
  * v1 (open-weight)         — ONNX from the public v1 repo.
The preprocessor + tokenizer are bundled alongside the ONNX in the source repos, so no model identifiers
appear in this app. Runs on ZeroGPU; ONNX inference on CPU. Results are a ranked Markdown table.
"""
import glob as _glob
import os

import gradio as gr
import numpy as np
import spaces
from huggingface_hub import hf_hub_download
from transformers import AutoImageProcessor, AutoTokenizer

V2_PRIV = os.environ.get("V2_REPO", "nutrientdocs/document-classification-v2-private")
V1_REPO = os.environ.get("V1_REPO", "nutrientdocs/document-classification-v1")
TOKEN = os.environ.get("HF_TOKEN")

_V2, _V1 = {}, {}   # lazy caches: v2 (private) ONNX + processors / v1 (public) ONNX

DEFAULT_ROWS = [
    ["invoice", "an itemized bill listing goods or services and a total due"],
    ["letter", "correspondence with a salutation, body, and signature"],
    ["memo", "an internal memorandum headed To, From, Date, Subject"],
    ["form", "a structured template with labeled fields to fill in"],
    ["scientific article", "a research paper with an abstract, methods, and references"],
    ["resume", "a summary of education, work experience, and skills"],
]


def _rows_to_text(rows):
    return "\n".join(f"{lab} | {desc}" if desc else lab for lab, desc in rows)


DEFAULT_TEXT = _rows_to_text(DEFAULT_ROWS)

# Clickable examples — each loads the page image AND its candidate-label table. Only pages the model
# classifies correctly are included (verified on the benchmark fixtures).
_DESC = {
    "financial reports": "a corporate financial statement with balance sheets, income tables, and figures",
    "scientific articles": "a research paper with an abstract, methods, results, and references",
    "laws and regulations": "statutory legal text with numbered sections and articles",
    "government tenders": "a public procurement notice or call for bids",
    "manuals": "a product or technical manual with instructions and labeled diagrams",
    "patents": "a patent document with claims, drawings, and an application number",
    "invoice": "an itemized bill listing goods or services, quantities, prices, and a total due",
    "letter": "correspondence with a salutation, body, and signature",
    "presentation": "a slide from a slideshow presentation", "receipt": "a store receipt with a total",
    "bar chart": "a chart comparing values with rectangular bars",
    "line chart": "a chart showing a trend as a line over an axis",
    "pie chart": "a circular chart divided into proportional slices",
    "qr code": "a square two-dimensional matrix barcode", "bar code": "a striped one-dimensional barcode",
    "signature": "a handwritten signature mark", "screenshot": "a screenshot of a software user interface",
    "table": "a grid of rows and columns of data",
}
_DOC = ["financial reports", "scientific articles", "laws and regulations", "government tenders", "manuals",
        "patents", "invoice", "letter", "presentation", "receipt"]
_ELEM = ["bar chart", "line chart", "pie chart", "qr code", "bar code", "signature", "screenshot", "table",
         "invoice", "financial reports"]
_ELEM_GT = {"bar chart", "pie chart", "line chart", "signature", "screenshot", "qr code", "bar code", "table"}


def _example_set():
    out = []
    for p in sorted(_glob.glob(os.path.join(os.path.dirname(__file__), "examples", "*.png"))):
        gt = os.path.basename(p).rsplit("_", 1)[0].replace("-", " ")
        group = _ELEM if gt in _ELEM_GT else _DOC
        if gt not in group:
            group = group + [gt]
        out.append((gt, p, [[lab, _DESC.get(lab, "")] for lab in group]))
    return out


EXAMPLES = _example_set()
# one clickable example per document type (keeps the row short + each pick unambiguous)
_EX_BY_NAME = {}
for _gt, _p, _t in EXAMPLES:
    _EX_BY_NAME.setdefault(_gt, (_p, _t))
EXAMPLE_NAMES = list(_EX_BY_NAME)


def _load_example(name):
    p, t = _EX_BY_NAME.get(name, (None, DEFAULT_ROWS))
    return p, _rows_to_text(t)


def _parse(text):
    labels, queries = [], []
    for line in (text or "").splitlines():
        lab, _, desc = line.partition("|")
        lab, desc = lab.strip(), desc.strip()
        if not lab:
            continue
        labels.append(lab); queries.append(f"{lab}. {desc}" if desc else lab)
    return labels, queries


def _table(labels, probs):
    ranked = sorted(zip(labels, probs), key=lambda x: -x[1])
    rows = []
    for i, (lab, p) in enumerate(ranked, 1):
        pct = max(0.0, min(1.0, p)) * 100
        star = " ✅" if i == 1 else ""
        rows.append(
            f"<tr><td style='text-align:right;padding:4px 10px;color:#5c6773'>{i}</td>"
            f"<td style='padding:4px 10px'><b>{lab}</b>{star}</td>"
            f"<td style='padding:4px 10px;width:180px'>"
            f"<div style='background:#e6ecf5;border-radius:4px;height:14px'>"
            f"<div style='background:#2f52d0;height:14px;border-radius:4px;width:{pct:.0f}%'></div></div></td>"
            f"<td style='text-align:right;padding:4px 10px;font-variant-numeric:tabular-nums'>{p:.3f}</td></tr>")
    return ("<table style='border-collapse:collapse;font:14px system-ui'>"
            "<thead><tr>"
            "<th style='text-align:right;padding:4px 10px'>#</th>"
            "<th style='text-align:left;padding:4px 10px'>Label</th>"
            "<th style='padding:4px 10px'></th>"
            "<th style='text-align:right;padding:4px 10px'>Score</th></tr></thead>"
            "<tbody>" + "".join(rows) + "</tbody></table>")


def _ort(path):
    import onnxruntime as ort
    return ort.InferenceSession(path, providers=["CPUExecutionProvider"])


# ---------- v2 flagship: full model as ONNX from the PRIVATE repo (censored) ----------
def _ensure_v2():
    if _V2:
        return
    if not TOKEN:
        _V2["error"] = "no HF_TOKEN secret"; return
    try:
        dl = lambda f: hf_hub_download(V2_PRIV, f, repo_type="model", token=TOKEN)
        _V2["img"] = _ort(dl("weights/image_encoder.onnx"))    # pixel_values -> pooled patches [1,256,1152]
        _V2["txt"] = _ort(dl("weights/text_encoder.onnx"))     # input_ids/mask -> L2 label embeds [N,1024]
        _V2["head"] = _ort(dl("weights/head.onnx"))            # patches + label_embeds -> probs
        _V2["ip"] = AutoImageProcessor.from_pretrained(V2_PRIV, subfolder="encoder/image", token=TOKEN)
        _V2["tk"] = AutoTokenizer.from_pretrained(V2_PRIV, subfolder="encoder/text", token=TOKEN)
    except Exception as e:  # noqa: BLE001
        _V2["error"] = f"{type(e).__name__}"


def _run_v2(image, queries):
    _ensure_v2()
    if "error" in _V2:
        return None, f"⚠️ Flagship unavailable ({_V2['error']}). This model needs the commercial weights."
    pv = _V2["ip"](images=[image.convert("RGB")], return_tensors="np")["pixel_values"].astype(np.float32)
    patches = _V2["img"].run(["patches"], {"pixel_values": pv})[0]
    enc = _V2["tk"](queries, padding=True, truncation=True, max_length=512, return_tensors="np")
    lab = _V2["txt"].run(["embeds"], {"input_ids": enc["input_ids"].astype(np.int64),
                                      "attention_mask": enc["attention_mask"].astype(np.int64)})[0]
    probs = _V2["head"].run(["probs"], {"patches": patches.astype(np.float32),
                                        "label_embeds": lab.astype(np.float32)})[0][0].tolist()
    return probs, None


# ---------- v1 open-weight: public ONNX (image + text towers) ----------
def _ensure_v1():
    if _V1:
        return
    try:
        img = hf_hub_download(V1_REPO, "modules/omni-image/image_model.onnx", repo_type="model", token=TOKEN)
        txt = hf_hub_download(V1_REPO, "modules/omni-image/text_model.onnx", repo_type="model", token=TOKEN)
        import json
        cfg = hf_hub_download(V1_REPO, "modules/omni-image/config.json", repo_type="model", token=TOKEN)
        cal = json.load(open(cfg)).get("calibration") or {"scale": 1.0, "bias": 0.0}
        _V1["img"] = _ort(img); _V1["txt"] = _ort(txt)
        _V1["tok"] = AutoTokenizer.from_pretrained(V1_REPO, subfolder="modules/omni-image", token=TOKEN)
        _V1["proc"] = AutoImageProcessor.from_pretrained(V1_REPO, subfolder="modules/omni-image", token=TOKEN)
        _V1["scale"], _V1["bias"] = float(cal["scale"]), float(cal["bias"])
    except Exception as e:  # noqa: BLE001
        _V1["error"] = f"{type(e).__name__}"


def _run_v1(image, queries):
    _ensure_v1()
    if "error" in _V1:
        return None, f"⚠️ v1 unavailable ({_V1['error']})."
    pix = _V1["proc"](images=[image.convert("RGB")], return_tensors="np")["pixel_values"].astype(np.float16)
    ie = _V1["img"].run(["image_emb"], {"pixel_values": pix})[0]                 # [1,D]
    enc = _V1["tok"](queries, padding=True, truncation=True, max_length=64, return_tensors="np")
    te = _V1["txt"].run(["text_emb"], {"input_ids": enc["input_ids"].astype(np.int64),
                                       "attention_mask": enc["attention_mask"].astype(np.int64)})[0]  # [N,D]
    cos = (ie @ te.T)[0]
    probs = (1.0 / (1.0 + np.exp(-(_V1["scale"] * cos + _V1["bias"])))).tolist()
    return probs, None


LINKS = ("→ [model](https://huggingface.co/nutrientdocs/document-classification-v2) · "
         "[leaderboard](https://huggingface.co/spaces/nutrientdocs/document-classification-leaderboard) · "
         "[benchmark](https://huggingface.co/datasets/nutrientdocs/document-classification-benchmark)")

MODELS = {"v2 flagship — best accuracy (commercial)": "v2",
          "v1 — open-weight (downloadable)": "v1"}


@spaces.GPU(duration=120)
def classify(image, text, model_name):
    if image is None:
        return "Upload a document image first."
    labels, queries = _parse(text)
    if not labels:
        return "Add at least one class (one <b>label</b> per line)."
    probs, err = (_run_v2 if MODELS.get(model_name) == "v2" else _run_v1)(image, queries)
    return err if err else _table(labels, probs)


with gr.Blocks(title="Nutrient Document Classification", theme=gr.themes.Soft()) as demo:
    gr.Markdown("# Classify any document against labels you choose\n"
                "Open-vocabulary, zero-shot — add candidate classes (label + optional description), upload a "
                "page, and the model ranks them. No fixed class list.\n\n" + LINKS)
    with gr.Row():
        img = gr.Image(type="pil", label="Document image", height=360)
        with gr.Column():
            model = gr.Radio(list(MODELS), value=list(MODELS)[0], label="Model")
            tbl = gr.Textbox(value=DEFAULT_TEXT, lines=10, label="Candidate classes",
                             info="One class per line — `label | description` (the description is optional).")
    btn = gr.Button("Classify", variant="primary")
    out = gr.HTML()
    sel = gr.Textbox(visible=False)
    gr.Examples(examples=[[n] for n in EXAMPLE_NAMES], inputs=[sel], outputs=[img, tbl],
                fn=_load_example, run_on_click=True, cache_examples=False,
                label="Examples the model classifies correctly — click to load the page + its labels")
    btn.click(classify, [img, tbl, model], out)

    def _q(request: gr.Request):
        m = (request.query_params or {}).get("model", "")
        return list(MODELS)[1] if "v1" in m.lower() else list(MODELS)[0]
    demo.load(_q, None, model)

demo.queue().launch(ssr_mode=False)