hung-k-nguyen commited on
Commit
b137257
·
0 Parent(s):

document-classification-demo — open-vocab, zero-shot Gradio demo

Browse files
.gitattributes ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/financial-reports_2.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/manuals_1.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/scientific-articles_2.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Document Classification Demo
3
+ emoji: 🖼️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 6.20.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: other
11
+ short_description: Classify any document image against labels you choose
12
+ ---
13
+
14
+ # Nutrient document classification demo
15
+
16
+ Open-vocabulary, zero-shot: add candidate classes (a **label** + optional **description** per row), upload a
17
+ document page, and the model ranks them — no fixed class list, no per-class training. Two models, one tab each:
18
+
19
+ - **v2 flagship** — the commercial model (best accuracy).
20
+ - **v1 (open-weight)** — a downloadable open-vocab classifier.
21
+
22
+ → [model](https://huggingface.co/nutrientdocs/document-classification-v2) ·
23
+ [leaderboard](https://huggingface.co/spaces/nutrientdocs/document-classification-leaderboard) ·
24
+ [benchmark](https://huggingface.co/datasets/nutrientdocs/document-classification-benchmark)
25
+
26
+ ## Secret required
27
+
28
+ The **v2** model is commercial and loaded from a **private** repo, so this Space needs an `HF_TOKEN`
29
+ **secret** (Settings → Variables and secrets) with read access to it. The weights are used server-side only
30
+ and are never downloadable. Without the secret, the demo marks the v2 tab _unavailable_ and the open **v1**
31
+ model still runs. Inference runs on **ZeroGPU**.
32
+
33
+ Sample document images for quick testing are in `examples/`.
app.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nutrient Document Classification — open-vocab, zero-shot demo (public-ready).
2
+
3
+ Censored like the sibling grounding demo: NO proprietary weights and NO custom architecture code ship in this
4
+ Space. Everything trained runs as ONNX loaded at RUNTIME:
5
+ * v2 flagship (commercial) — the full model runs as ONNX fetched from the PRIVATE repo via the
6
+ HF_TOKEN secret (server-side only, never committed here). Marked unavailable if the secret is absent.
7
+ * v1 (open-weight) — ONNX from the public v1 repo.
8
+ The preprocessor + tokenizer are bundled alongside the ONNX in the source repos, so no model identifiers
9
+ appear in this app. Runs on ZeroGPU; ONNX inference on CPU. Results are a ranked Markdown table.
10
+ """
11
+ import glob as _glob
12
+ import os
13
+
14
+ import gradio as gr
15
+ import numpy as np
16
+ import spaces
17
+ from huggingface_hub import hf_hub_download
18
+ from transformers import AutoImageProcessor, AutoTokenizer
19
+
20
+ V2_PRIV = os.environ.get("V2_REPO", "nutrientdocs/document-classification-v2-private")
21
+ V1_REPO = os.environ.get("V1_REPO", "nutrientdocs/document-classification-v1")
22
+ TOKEN = os.environ.get("HF_TOKEN")
23
+
24
+ _V2, _V1 = {}, {} # lazy caches: v2 (private) ONNX + processors / v1 (public) ONNX
25
+
26
+ DEFAULT_ROWS = [
27
+ ["invoice", "an itemized bill listing goods or services and a total due"],
28
+ ["letter", "correspondence with a salutation, body, and signature"],
29
+ ["memo", "an internal memorandum headed To, From, Date, Subject"],
30
+ ["form", "a structured template with labeled fields to fill in"],
31
+ ["scientific article", "a research paper with an abstract, methods, and references"],
32
+ ["resume", "a summary of education, work experience, and skills"],
33
+ ]
34
+
35
+
36
+ def _rows_to_text(rows):
37
+ return "\n".join(f"{lab} | {desc}" if desc else lab for lab, desc in rows)
38
+
39
+
40
+ DEFAULT_TEXT = _rows_to_text(DEFAULT_ROWS)
41
+
42
+ # Clickable examples — each loads the page image AND its candidate-label table. Only pages the model
43
+ # classifies correctly are included (verified on the benchmark fixtures).
44
+ _DESC = {
45
+ "financial reports": "a corporate financial statement with balance sheets, income tables, and figures",
46
+ "scientific articles": "a research paper with an abstract, methods, results, and references",
47
+ "laws and regulations": "statutory legal text with numbered sections and articles",
48
+ "government tenders": "a public procurement notice or call for bids",
49
+ "manuals": "a product or technical manual with instructions and labeled diagrams",
50
+ "patents": "a patent document with claims, drawings, and an application number",
51
+ "invoice": "an itemized bill listing goods or services, quantities, prices, and a total due",
52
+ "letter": "correspondence with a salutation, body, and signature",
53
+ "presentation": "a slide from a slideshow presentation", "receipt": "a store receipt with a total",
54
+ "bar chart": "a chart comparing values with rectangular bars",
55
+ "line chart": "a chart showing a trend as a line over an axis",
56
+ "pie chart": "a circular chart divided into proportional slices",
57
+ "qr code": "a square two-dimensional matrix barcode", "bar code": "a striped one-dimensional barcode",
58
+ "signature": "a handwritten signature mark", "screenshot": "a screenshot of a software user interface",
59
+ "table": "a grid of rows and columns of data",
60
+ }
61
+ _DOC = ["financial reports", "scientific articles", "laws and regulations", "government tenders", "manuals",
62
+ "patents", "invoice", "letter", "presentation", "receipt"]
63
+ _ELEM = ["bar chart", "line chart", "pie chart", "qr code", "bar code", "signature", "screenshot", "table",
64
+ "invoice", "financial reports"]
65
+ _ELEM_GT = {"bar chart", "pie chart", "line chart", "signature", "screenshot", "qr code", "bar code", "table"}
66
+
67
+
68
+ def _example_set():
69
+ out = []
70
+ for p in sorted(_glob.glob(os.path.join(os.path.dirname(__file__), "examples", "*.png"))):
71
+ gt = os.path.basename(p).rsplit("_", 1)[0].replace("-", " ")
72
+ group = _ELEM if gt in _ELEM_GT else _DOC
73
+ if gt not in group:
74
+ group = group + [gt]
75
+ out.append((gt, p, [[lab, _DESC.get(lab, "")] for lab in group]))
76
+ return out
77
+
78
+
79
+ EXAMPLES = _example_set()
80
+ # one clickable example per document type (keeps the row short + each pick unambiguous)
81
+ _EX_BY_NAME = {}
82
+ for _gt, _p, _t in EXAMPLES:
83
+ _EX_BY_NAME.setdefault(_gt, (_p, _t))
84
+ EXAMPLE_NAMES = list(_EX_BY_NAME)
85
+
86
+
87
+ def _load_example(name):
88
+ p, t = _EX_BY_NAME.get(name, (None, DEFAULT_ROWS))
89
+ return p, _rows_to_text(t)
90
+
91
+
92
+ def _parse(text):
93
+ labels, queries = [], []
94
+ for line in (text or "").splitlines():
95
+ lab, _, desc = line.partition("|")
96
+ lab, desc = lab.strip(), desc.strip()
97
+ if not lab:
98
+ continue
99
+ labels.append(lab); queries.append(f"{lab}. {desc}" if desc else lab)
100
+ return labels, queries
101
+
102
+
103
+ def _table(labels, probs):
104
+ ranked = sorted(zip(labels, probs), key=lambda x: -x[1])
105
+ rows = []
106
+ for i, (lab, p) in enumerate(ranked, 1):
107
+ pct = max(0.0, min(1.0, p)) * 100
108
+ star = " ✅" if i == 1 else ""
109
+ rows.append(
110
+ f"<tr><td style='text-align:right;padding:4px 10px;color:#5c6773'>{i}</td>"
111
+ f"<td style='padding:4px 10px'><b>{lab}</b>{star}</td>"
112
+ f"<td style='padding:4px 10px;width:180px'>"
113
+ f"<div style='background:#e6ecf5;border-radius:4px;height:14px'>"
114
+ f"<div style='background:#2f52d0;height:14px;border-radius:4px;width:{pct:.0f}%'></div></div></td>"
115
+ f"<td style='text-align:right;padding:4px 10px;font-variant-numeric:tabular-nums'>{p:.3f}</td></tr>")
116
+ return ("<table style='border-collapse:collapse;font:14px system-ui'>"
117
+ "<thead><tr>"
118
+ "<th style='text-align:right;padding:4px 10px'>#</th>"
119
+ "<th style='text-align:left;padding:4px 10px'>Label</th>"
120
+ "<th style='padding:4px 10px'></th>"
121
+ "<th style='text-align:right;padding:4px 10px'>Score</th></tr></thead>"
122
+ "<tbody>" + "".join(rows) + "</tbody></table>")
123
+
124
+
125
+ def _ort(path):
126
+ import onnxruntime as ort
127
+ return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
128
+
129
+
130
+ # ---------- v2 flagship: full model as ONNX from the PRIVATE repo (censored) ----------
131
+ def _ensure_v2():
132
+ if _V2:
133
+ return
134
+ if not TOKEN:
135
+ _V2["error"] = "no HF_TOKEN secret"; return
136
+ try:
137
+ dl = lambda f: hf_hub_download(V2_PRIV, f, repo_type="model", token=TOKEN)
138
+ _V2["img"] = _ort(dl("weights/image_encoder.onnx")) # pixel_values -> pooled patches [1,256,1152]
139
+ _V2["txt"] = _ort(dl("weights/text_encoder.onnx")) # input_ids/mask -> L2 label embeds [N,1024]
140
+ _V2["head"] = _ort(dl("weights/head-v3-ep6.onnx")) # patches + label_embeds -> probs
141
+ _V2["ip"] = AutoImageProcessor.from_pretrained(V2_PRIV, subfolder="encoder/image", token=TOKEN)
142
+ _V2["tk"] = AutoTokenizer.from_pretrained(V2_PRIV, subfolder="encoder/text", token=TOKEN)
143
+ except Exception as e: # noqa: BLE001
144
+ _V2["error"] = f"{type(e).__name__}"
145
+
146
+
147
+ def _run_v2(image, queries):
148
+ _ensure_v2()
149
+ if "error" in _V2:
150
+ return None, f"⚠️ Flagship unavailable ({_V2['error']}). This model needs the commercial weights."
151
+ pv = _V2["ip"](images=[image.convert("RGB")], return_tensors="np")["pixel_values"].astype(np.float32)
152
+ patches = _V2["img"].run(["patches"], {"pixel_values": pv})[0]
153
+ enc = _V2["tk"](queries, padding=True, truncation=True, max_length=512, return_tensors="np")
154
+ lab = _V2["txt"].run(["embeds"], {"input_ids": enc["input_ids"].astype(np.int64),
155
+ "attention_mask": enc["attention_mask"].astype(np.int64)})[0]
156
+ probs = _V2["head"].run(["probs"], {"patches": patches.astype(np.float32),
157
+ "label_embeds": lab.astype(np.float32)})[0][0].tolist()
158
+ return probs, None
159
+
160
+
161
+ # ---------- v1 open-weight: public ONNX (image + text towers) ----------
162
+ def _ensure_v1():
163
+ if _V1:
164
+ return
165
+ try:
166
+ img = hf_hub_download(V1_REPO, "modules/omni-image/image_model.onnx", repo_type="model", token=TOKEN)
167
+ txt = hf_hub_download(V1_REPO, "modules/omni-image/text_model.onnx", repo_type="model", token=TOKEN)
168
+ import json
169
+ cfg = hf_hub_download(V1_REPO, "modules/omni-image/config.json", repo_type="model", token=TOKEN)
170
+ cal = json.load(open(cfg)).get("calibration") or {"scale": 1.0, "bias": 0.0}
171
+ _V1["img"] = _ort(img); _V1["txt"] = _ort(txt)
172
+ _V1["tok"] = AutoTokenizer.from_pretrained(V1_REPO, subfolder="modules/omni-image", token=TOKEN)
173
+ _V1["proc"] = AutoImageProcessor.from_pretrained(V1_REPO, subfolder="modules/omni-image", token=TOKEN)
174
+ _V1["scale"], _V1["bias"] = float(cal["scale"]), float(cal["bias"])
175
+ except Exception as e: # noqa: BLE001
176
+ _V1["error"] = f"{type(e).__name__}"
177
+
178
+
179
+ def _run_v1(image, queries):
180
+ _ensure_v1()
181
+ if "error" in _V1:
182
+ return None, f"⚠️ v1 unavailable ({_V1['error']})."
183
+ pix = _V1["proc"](images=[image.convert("RGB")], return_tensors="np")["pixel_values"].astype(np.float16)
184
+ ie = _V1["img"].run(["image_emb"], {"pixel_values": pix})[0] # [1,D]
185
+ enc = _V1["tok"](queries, padding=True, truncation=True, max_length=64, return_tensors="np")
186
+ te = _V1["txt"].run(["text_emb"], {"input_ids": enc["input_ids"].astype(np.int64),
187
+ "attention_mask": enc["attention_mask"].astype(np.int64)})[0] # [N,D]
188
+ cos = (ie @ te.T)[0]
189
+ probs = (1.0 / (1.0 + np.exp(-(_V1["scale"] * cos + _V1["bias"])))).tolist()
190
+ return probs, None
191
+
192
+
193
+ LINKS = ("→ [model](https://huggingface.co/nutrientdocs/document-classification-v2) · "
194
+ "[leaderboard](https://huggingface.co/spaces/nutrientdocs/document-classification-leaderboard) · "
195
+ "[benchmark](https://huggingface.co/datasets/nutrientdocs/document-classification-benchmark)")
196
+
197
+ MODELS = {"v2 flagship — best accuracy (commercial)": "v2",
198
+ "v1 — open-weight (downloadable)": "v1"}
199
+
200
+
201
+ @spaces.GPU(duration=120)
202
+ def classify(image, text, model_name):
203
+ if image is None:
204
+ return "Upload a document image first."
205
+ labels, queries = _parse(text)
206
+ if not labels:
207
+ return "Add at least one class (one <b>label</b> per line)."
208
+ probs, err = (_run_v2 if MODELS.get(model_name) == "v2" else _run_v1)(image, queries)
209
+ return err if err else _table(labels, probs)
210
+
211
+
212
+ with gr.Blocks(title="Nutrient Document Classification", theme=gr.themes.Soft()) as demo:
213
+ gr.Markdown("# Classify any document against labels you choose\n"
214
+ "Open-vocabulary, zero-shot — add candidate classes (label + optional description), upload a "
215
+ "page, and the model ranks them. No fixed class list.\n\n" + LINKS)
216
+ with gr.Row():
217
+ img = gr.Image(type="pil", label="Document image", height=360)
218
+ with gr.Column():
219
+ model = gr.Radio(list(MODELS), value=list(MODELS)[0], label="Model")
220
+ tbl = gr.Textbox(value=DEFAULT_TEXT, lines=10, label="Candidate classes",
221
+ info="One class per line — `label | description` (the description is optional).")
222
+ btn = gr.Button("Classify", variant="primary")
223
+ out = gr.HTML()
224
+ sel = gr.Textbox(visible=False)
225
+ gr.Examples(examples=[[n] for n in EXAMPLE_NAMES], inputs=[sel], outputs=[img, tbl],
226
+ fn=_load_example, run_on_click=True, cache_examples=False,
227
+ label="Examples the model classifies correctly — click to load the page + its labels")
228
+ btn.click(classify, [img, tbl, model], out)
229
+
230
+ def _q(request: gr.Request):
231
+ m = (request.query_params or {}).get("model", "")
232
+ return list(MODELS)[1] if "v1" in m.lower() else list(MODELS)[0]
233
+ demo.load(_q, None, model)
234
+
235
+ demo.queue().launch(ssr_mode=False)
examples/bar-chart_1.png ADDED
examples/financial-reports_1.png ADDED
examples/financial-reports_2.png ADDED

Git LFS Details

  • SHA256: 78de62efe9d8bc1030c2a7814d18f41436b4d243408c8043f1c61979c540a809
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
examples/manuals_1.png ADDED

Git LFS Details

  • SHA256: 90edcc738a51167950e8d3e39d9199539b31ce6fa5d12e05c6ac78b43f0f0136
  • Pointer size: 131 Bytes
  • Size of remote file: 149 kB
examples/manuals_2.png ADDED
examples/pie-chart_1.png ADDED
examples/scientific-articles_1.png ADDED
examples/scientific-articles_2.png ADDED

Git LFS Details

  • SHA256: c4b15a9ca994a5d308d7cd16c0bd65efff1a50b21cbbb270bac2dc76f7e41242
  • Pointer size: 131 Bytes
  • Size of remote file: 239 kB
examples/screenshot_1.png ADDED
examples/signature_1.png ADDED
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ torch
2
+ torchvision
3
+ transformers>=4.49
4
+ onnxruntime
5
+ sentencepiece
6
+ protobuf
7
+ pillow
8
+ accelerate
9
+ spaces