Spaces:
Running on Zero
Running on Zero
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)
|