Image Classification
Transformers
Safetensors
siglip2_hier_doc
feature-extraction
siglip2
document-classification
hierarchical
multi-task
custom_code
Instructions to use ekacare/med-doc-classifier with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use ekacare/med-doc-classifier with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-classification", model="ekacare/med-doc-classifier", trust_remote_code=True) pipe("https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/hub/parrots.png")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("ekacare/med-doc-classifier", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
eka-doc-classifier: base1024 hier (flat L2 default + scope/l1 select, quality 1-100 score)
Browse files- README.md +157 -0
- benchmark_results.json +51 -0
- config.json +201 -0
- configuration_siglip2_hier.py +83 -0
- example.py +10 -0
- model.safetensors +3 -0
- modeling_siglip2_hier.py +348 -0
- preprocessor_config.json +20 -0
- requirements.txt +5 -0
README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
pipeline_tag: image-classification
|
| 4 |
+
tags:
|
| 5 |
+
- siglip2
|
| 6 |
+
- document-classification
|
| 7 |
+
- hierarchical
|
| 8 |
+
- multi-task
|
| 9 |
+
library_name: transformers
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Hierarchical SigLIP2 Document Classifier (~97M)
|
| 13 |
+
|
| 14 |
+
A **frozen SigLIP2 base** vision tower (`google/siglip2-base-patch16-naflex`, single
|
| 15 |
+
high-res forward at `max_num_patches=1024`) plus lightweight heads that classify a
|
| 16 |
+
document image across several tasks from one shared pooled embedding:
|
| 17 |
+
|
| 18 |
+
| Task | Output |
|
| 19 |
+
|------|--------|
|
| 20 |
+
| `l2` (flat, **default**) | leaf class — flat 27-way head; **L1 inferred** from it |
|
| 21 |
+
| `l1` | document group — one of 5 (Document, Miscellaneous, Diagnostic imaging, Card / credential, Body images) |
|
| 22 |
+
| `l2` (leaf) | leaf class via a per-L1 head, when the group is fixed/predicted |
|
| 23 |
+
| `medical` | sigmoid — `medical` / `non_medical` (+ `p_positive`) |
|
| 24 |
+
| `handwritten` | sigmoid — `handwritten` / `printed` (+ `p_positive`); tuned on documents |
|
| 25 |
+
| `quality` | sigmoid — a single **`score` in 1–100** (P(good) mapped onto 1–100; higher = better), no label |
|
| 26 |
+
|
| 27 |
+
## L2 has two paths, selected by `scope`
|
| 28 |
+
|
| 29 |
+
There is **both** a flat 27-way L2 head (the backbone's own end-to-end classifier,
|
| 30 |
+
run on the raw pooled feature) **and** a per-L1 leaf head per group (run on the
|
| 31 |
+
standardized embedding). You choose how L2 / L1 are produced:
|
| 32 |
+
|
| 33 |
+
| `scope` | what runs | L1 | L2 |
|
| 34 |
+
|---------|-----------|----|----|
|
| 35 |
+
| `"flat"` *(default)* | flat 27-way head | **inferred** from the L2 prediction | flat |
|
| 36 |
+
| `"l1"` | L1 group head only | predicted | — (not produced) |
|
| 37 |
+
| `"hierarchical"` | L1 head → that group's leaf head | predicted | leaf |
|
| 38 |
+
|
| 39 |
+
**Fix the group yourself** by passing `l1="Document"` (any group name): L2 is then
|
| 40 |
+
read from that group's leaf head, overriding the scope's routing.
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
image → frozen SigLIP2 tower → pooled embedding ──► flat L2 head ─► L2 (+ inferred L1) [scope="flat"]
|
| 44 |
+
│
|
| 45 |
+
├─ standardize (mu/sd) → L1 head ─► L1 [scope="l1"]
|
| 46 |
+
│ └─► leaf L2 head[L1] ─► L2 [scope="hierarchical" / fixed l1]
|
| 47 |
+
└─ medical · handwritten · quality (sigmoid heads, independent)
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### Taxonomy (5 L1 groups → 27 L2 leaves)
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
Document (10 leaves)
|
| 54 |
+
├─ Letter (key: letter)
|
| 55 |
+
├─ Other document (key: other_document)
|
| 56 |
+
├─ Scan interpretation (key: scan_interpretation)
|
| 57 |
+
├─ Lab / diagnostic report (key: lab_diagnostic_report)
|
| 58 |
+
├─ OPD Consultation Record (key: opd_consultation_record)
|
| 59 |
+
├─ Discharge summary (key: discharge_summary)
|
| 60 |
+
├─ Insurance document (key: insurance_document)
|
| 61 |
+
├─ Form (key: form)
|
| 62 |
+
├─ Invoice / bill (key: invoice_bill)
|
| 63 |
+
├─ Certificate (key: certificate)
|
| 64 |
+
Miscellaneous (2 leaves)
|
| 65 |
+
├─ Other (Miscellaneous) (key: other_miscellaneous)
|
| 66 |
+
├─ Medication image (key: medication_image)
|
| 67 |
+
Diagnostic imaging (5 leaves)
|
| 68 |
+
├─ Ultrasound (key: ultrasound)
|
| 69 |
+
├─ X-ray (key: x_ray)
|
| 70 |
+
├─ MRI (key: mri)
|
| 71 |
+
├─ CT (key: ct)
|
| 72 |
+
├─ Other (Diagnostic imaging) (key: other_diagnostic_imaging)
|
| 73 |
+
Card / credential (7 leaves)
|
| 74 |
+
├─ ABHA card (key: abha_card)
|
| 75 |
+
├─ Voter ID (key: voter_id)
|
| 76 |
+
├─ Aadhaar card (key: aadhaar_card)
|
| 77 |
+
├─ Insurance e-card (key: insurance_e_card)
|
| 78 |
+
├─ PAN card (key: pan_card)
|
| 79 |
+
├─ PMJAY / Ayushman (key: pmjay_ayushman)
|
| 80 |
+
├─ Other card (key: other_card)
|
| 81 |
+
Body images (3 leaves)
|
| 82 |
+
├─ Headshot (key: headshot)
|
| 83 |
+
├─ Skin / wound (key: skin_wound)
|
| 84 |
+
├─ Other (Body images) (key: other_body_images)
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## Usage
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
from transformers import AutoModel
|
| 91 |
+
from PIL import Image
|
| 92 |
+
|
| 93 |
+
model = AutoModel.from_pretrained("ekacare/eka-doc-classifier", trust_remote_code=True).eval()
|
| 94 |
+
img = Image.open("doc.jpg")
|
| 95 |
+
|
| 96 |
+
model.classify(img) # default: flat L2 + inferred L1 + binaries
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
```jsonc
|
| 100 |
+
// example return value (scope="flat")
|
| 101 |
+
{
|
| 102 |
+
"l2": { "source": "flat", "key": "lab_diagnostic_report",
|
| 103 |
+
"value": "Lab / diagnostic report", "confidence": 0.97 },
|
| 104 |
+
"l1": { "value": "Document", "source": "inferred_from_flat_l2" },
|
| 105 |
+
"medical": { "value": "medical", "confidence": 0.99, "p_positive": 0.99 },
|
| 106 |
+
"handwritten": { "value": "printed", "confidence": 0.98, "p_positive": 0.02 },
|
| 107 |
+
"quality": { "score": 94, "p_good": 0.94 }
|
| 108 |
+
}
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Choosing the L2 scope
|
| 112 |
+
|
| 113 |
+
```python
|
| 114 |
+
model.classify(img) # scope="flat" (default): flat L2, L1 inferred
|
| 115 |
+
model.classify(img, scope="l1") # L1 group only (no L2)
|
| 116 |
+
model.classify(img, scope="hierarchical") # L1 head → leaf L2
|
| 117 |
+
model.classify(img, l1="Document") # FIX L1 = Document → that group's leaf L2
|
| 118 |
+
model.classify(img, medical=False, handwritten=False, quality=False) # L2/L1 only
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
`medical`, `handwritten`, `quality` are independent sigmoid heads — toggle them
|
| 122 |
+
freely. `quality` reports a single **`score` in 1–100** (the sigmoid P(good) mapped
|
| 123 |
+
onto the range; higher = better quality) plus the raw `p_good` — there is no
|
| 124 |
+
poor/good label. `handwritten` is trained on document images.
|
| 125 |
+
|
| 126 |
+
### Top-k
|
| 127 |
+
|
| 128 |
+
```python
|
| 129 |
+
model.classify(img, top_k=3) # top-3 candidates for the multi-class heads
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Quantized loading (optimum-quanto — CPU & GPU)
|
| 133 |
+
|
| 134 |
+
int8 **and int4** run on CPU as well as GPU. Only the vision tower is quantized;
|
| 135 |
+
`patch_embedding` and the heads stay full precision.
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
# pip install optimum-quanto
|
| 139 |
+
from modeling_siglip2_hier import load_classifier # downloaded by trust_remote_code
|
| 140 |
+
|
| 141 |
+
model = load_classifier("ekacare/eka-doc-classifier", quantization="int4") # None | "int8" | "int4"
|
| 142 |
+
model.classify(img)
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Flat-L2 accuracy by precision (vision tower quantized; full internal test = 16,204
|
| 146 |
+
images, external OOD = 953; see `benchmark_results.json`):
|
| 147 |
+
|
| 148 |
+
| precision | internal acc / mF1 (cluster) | external acc / mF1 (cluster) |
|
| 149 |
+
|-----------|------------------------------|------------------------------|
|
| 150 |
+
| bf16 | 96.45 / 0.965 (97.36) | 80.90 / 0.702 (90.95) |
|
| 151 |
+
| int8 | 96.43 / 0.965 (97.36) | 80.90 / 0.653 (91.15) |
|
| 152 |
+
| int4 | 95.22 / 0.955 (94.23) | 77.23 / 0.630 (85.60) |
|
| 153 |
+
|
| 154 |
+
**int8 is effectively lossless on accuracy** (internal −0.02, external acc identical;
|
| 155 |
+
only the external macro-F1 dips as rare classes lose a little balance). **int4** trades
|
| 156 |
+
~1.2pt internal / ~3.7pt external for the smallest footprint. Base weights ship in bf16
|
| 157 |
+
(~193 MB); int8/int4 quantize the vision tower only (heads + `patch_embedding` stay fp).
|
benchmark_results.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"flat_l2": {
|
| 3 |
+
"bf16": {
|
| 4 |
+
"internal": {
|
| 5 |
+
"acc": 96.45,
|
| 6 |
+
"macro_f1": 0.9646,
|
| 7 |
+
"cluster_acc": 97.36,
|
| 8 |
+
"n": 16204
|
| 9 |
+
},
|
| 10 |
+
"external": {
|
| 11 |
+
"acc": 80.9,
|
| 12 |
+
"macro_f1": 0.7019,
|
| 13 |
+
"cluster_acc": 90.95,
|
| 14 |
+
"n": 953
|
| 15 |
+
},
|
| 16 |
+
"img_per_s": 78.4
|
| 17 |
+
},
|
| 18 |
+
"int8": {
|
| 19 |
+
"internal": {
|
| 20 |
+
"acc": 96.43,
|
| 21 |
+
"macro_f1": 0.9645,
|
| 22 |
+
"cluster_acc": 97.36,
|
| 23 |
+
"n": 16204
|
| 24 |
+
},
|
| 25 |
+
"external": {
|
| 26 |
+
"acc": 80.9,
|
| 27 |
+
"macro_f1": 0.6527,
|
| 28 |
+
"cluster_acc": 91.15,
|
| 29 |
+
"n": 953
|
| 30 |
+
},
|
| 31 |
+
"img_per_s": 69.3
|
| 32 |
+
},
|
| 33 |
+
"int4": {
|
| 34 |
+
"internal": {
|
| 35 |
+
"acc": 95.22,
|
| 36 |
+
"macro_f1": 0.9553,
|
| 37 |
+
"cluster_acc": 94.23,
|
| 38 |
+
"n": 16204
|
| 39 |
+
},
|
| 40 |
+
"external": {
|
| 41 |
+
"acc": 77.23,
|
| 42 |
+
"macro_f1": 0.6304,
|
| 43 |
+
"cluster_acc": 85.6,
|
| 44 |
+
"n": 953
|
| 45 |
+
},
|
| 46 |
+
"img_per_s": 24.8
|
| 47 |
+
}
|
| 48 |
+
},
|
| 49 |
+
"internal_n": 16204,
|
| 50 |
+
"external_n": 953
|
| 51 |
+
}
|
config.json
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"Siglip2HierForDocClassification"
|
| 4 |
+
],
|
| 5 |
+
"auto_map": {
|
| 6 |
+
"AutoConfig": "configuration_siglip2_hier.Siglip2HierConfig",
|
| 7 |
+
"AutoModel": "modeling_siglip2_hier.Siglip2HierForDocClassification"
|
| 8 |
+
},
|
| 9 |
+
"base_model_id": "google/siglip2-base-patch16-naflex",
|
| 10 |
+
"default_scope": "flat",
|
| 11 |
+
"dtype": "bfloat16",
|
| 12 |
+
"flat_l2_classes": [
|
| 13 |
+
"letter",
|
| 14 |
+
"other_document",
|
| 15 |
+
"scan_interpretation",
|
| 16 |
+
"lab_diagnostic_report",
|
| 17 |
+
"opd_consultation_record",
|
| 18 |
+
"discharge_summary",
|
| 19 |
+
"insurance_document",
|
| 20 |
+
"form",
|
| 21 |
+
"invoice_bill",
|
| 22 |
+
"certificate",
|
| 23 |
+
"other_miscellaneous",
|
| 24 |
+
"medication_image",
|
| 25 |
+
"ultrasound",
|
| 26 |
+
"x_ray",
|
| 27 |
+
"mri",
|
| 28 |
+
"ct",
|
| 29 |
+
"other_diagnostic_imaging",
|
| 30 |
+
"abha_card",
|
| 31 |
+
"voter_id",
|
| 32 |
+
"aadhaar_card",
|
| 33 |
+
"insurance_e_card",
|
| 34 |
+
"pan_card",
|
| 35 |
+
"pmjay_ayushman",
|
| 36 |
+
"other_card",
|
| 37 |
+
"headshot",
|
| 38 |
+
"skin_wound",
|
| 39 |
+
"other_body_images"
|
| 40 |
+
],
|
| 41 |
+
"flat_l2_to_l1": {
|
| 42 |
+
"aadhaar_card": "Card / credential",
|
| 43 |
+
"abha_card": "Card / credential",
|
| 44 |
+
"certificate": "Document",
|
| 45 |
+
"ct": "Diagnostic imaging",
|
| 46 |
+
"discharge_summary": "Document",
|
| 47 |
+
"form": "Document",
|
| 48 |
+
"headshot": "Body images",
|
| 49 |
+
"insurance_document": "Document",
|
| 50 |
+
"insurance_e_card": "Card / credential",
|
| 51 |
+
"invoice_bill": "Document",
|
| 52 |
+
"lab_diagnostic_report": "Document",
|
| 53 |
+
"letter": "Document",
|
| 54 |
+
"medication_image": "Miscellaneous",
|
| 55 |
+
"mri": "Diagnostic imaging",
|
| 56 |
+
"opd_consultation_record": "Document",
|
| 57 |
+
"other_body_images": "Body images",
|
| 58 |
+
"other_card": "Card / credential",
|
| 59 |
+
"other_diagnostic_imaging": "Diagnostic imaging",
|
| 60 |
+
"other_document": "Document",
|
| 61 |
+
"other_miscellaneous": "Miscellaneous",
|
| 62 |
+
"pan_card": "Card / credential",
|
| 63 |
+
"pmjay_ayushman": "Card / credential",
|
| 64 |
+
"scan_interpretation": "Document",
|
| 65 |
+
"skin_wound": "Body images",
|
| 66 |
+
"ultrasound": "Diagnostic imaging",
|
| 67 |
+
"voter_id": "Card / credential",
|
| 68 |
+
"x_ray": "Diagnostic imaging"
|
| 69 |
+
},
|
| 70 |
+
"hand_classes": [
|
| 71 |
+
"printed",
|
| 72 |
+
"handwritten"
|
| 73 |
+
],
|
| 74 |
+
"head_hidden": 512,
|
| 75 |
+
"hidden_size": 768,
|
| 76 |
+
"l1_classes": [
|
| 77 |
+
"Document",
|
| 78 |
+
"Miscellaneous",
|
| 79 |
+
"Diagnostic imaging",
|
| 80 |
+
"Card / credential",
|
| 81 |
+
"Body images"
|
| 82 |
+
],
|
| 83 |
+
"l2_by_l1": {
|
| 84 |
+
"Body images": [
|
| 85 |
+
"headshot",
|
| 86 |
+
"skin_wound",
|
| 87 |
+
"other_body_images"
|
| 88 |
+
],
|
| 89 |
+
"Card / credential": [
|
| 90 |
+
"abha_card",
|
| 91 |
+
"voter_id",
|
| 92 |
+
"aadhaar_card",
|
| 93 |
+
"insurance_e_card",
|
| 94 |
+
"pan_card",
|
| 95 |
+
"pmjay_ayushman",
|
| 96 |
+
"other_card"
|
| 97 |
+
],
|
| 98 |
+
"Diagnostic imaging": [
|
| 99 |
+
"ultrasound",
|
| 100 |
+
"x_ray",
|
| 101 |
+
"mri",
|
| 102 |
+
"ct",
|
| 103 |
+
"other_diagnostic_imaging"
|
| 104 |
+
],
|
| 105 |
+
"Document": [
|
| 106 |
+
"letter",
|
| 107 |
+
"other_document",
|
| 108 |
+
"scan_interpretation",
|
| 109 |
+
"lab_diagnostic_report",
|
| 110 |
+
"opd_consultation_record",
|
| 111 |
+
"discharge_summary",
|
| 112 |
+
"insurance_document",
|
| 113 |
+
"form",
|
| 114 |
+
"invoice_bill",
|
| 115 |
+
"certificate"
|
| 116 |
+
],
|
| 117 |
+
"Miscellaneous": [
|
| 118 |
+
"other_miscellaneous",
|
| 119 |
+
"medication_image"
|
| 120 |
+
]
|
| 121 |
+
},
|
| 122 |
+
"l2_display": {
|
| 123 |
+
"aadhaar_card": "Aadhaar card",
|
| 124 |
+
"abha_card": "ABHA card",
|
| 125 |
+
"certificate": "Certificate",
|
| 126 |
+
"ct": "CT",
|
| 127 |
+
"discharge_summary": "Discharge summary",
|
| 128 |
+
"form": "Form",
|
| 129 |
+
"headshot": "Headshot",
|
| 130 |
+
"insurance_document": "Insurance document",
|
| 131 |
+
"insurance_e_card": "Insurance e-card",
|
| 132 |
+
"invoice_bill": "Invoice / bill",
|
| 133 |
+
"lab_diagnostic_report": "Lab / diagnostic report",
|
| 134 |
+
"letter": "Letter",
|
| 135 |
+
"medication_image": "Medication image",
|
| 136 |
+
"mri": "MRI",
|
| 137 |
+
"opd_consultation_record": "OPD Consultation Record",
|
| 138 |
+
"other_body_images": "Other (Body images)",
|
| 139 |
+
"other_card": "Other card",
|
| 140 |
+
"other_diagnostic_imaging": "Other (Diagnostic imaging)",
|
| 141 |
+
"other_document": "Other document",
|
| 142 |
+
"other_miscellaneous": "Other (Miscellaneous)",
|
| 143 |
+
"pan_card": "PAN card",
|
| 144 |
+
"pmjay_ayushman": "PMJAY / Ayushman",
|
| 145 |
+
"scan_interpretation": "Scan interpretation",
|
| 146 |
+
"skin_wound": "Skin / wound",
|
| 147 |
+
"ultrasound": "Ultrasound",
|
| 148 |
+
"voter_id": "Voter ID",
|
| 149 |
+
"x_ray": "X-ray"
|
| 150 |
+
},
|
| 151 |
+
"max_num_patches": 1024,
|
| 152 |
+
"med_classes": [
|
| 153 |
+
"non_medical",
|
| 154 |
+
"medical"
|
| 155 |
+
],
|
| 156 |
+
"model_type": "siglip2_hier_doc",
|
| 157 |
+
"qual_classes": [
|
| 158 |
+
"poor",
|
| 159 |
+
"good"
|
| 160 |
+
],
|
| 161 |
+
"quality_score_label": "good",
|
| 162 |
+
"quality_score_max": 100,
|
| 163 |
+
"quality_score_min": 1,
|
| 164 |
+
"sigmoid_heads": [
|
| 165 |
+
"med",
|
| 166 |
+
"hand",
|
| 167 |
+
"qual"
|
| 168 |
+
],
|
| 169 |
+
"transformers_version": "5.10.2",
|
| 170 |
+
"vision_config": {
|
| 171 |
+
"_name_or_path": "",
|
| 172 |
+
"architectures": null,
|
| 173 |
+
"attention_dropout": 0.0,
|
| 174 |
+
"chunk_size_feed_forward": 0,
|
| 175 |
+
"dtype": null,
|
| 176 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 177 |
+
"hidden_size": 768,
|
| 178 |
+
"id2label": {
|
| 179 |
+
"0": "LABEL_0",
|
| 180 |
+
"1": "LABEL_1"
|
| 181 |
+
},
|
| 182 |
+
"intermediate_size": 3072,
|
| 183 |
+
"is_encoder_decoder": false,
|
| 184 |
+
"label2id": {
|
| 185 |
+
"LABEL_0": 0,
|
| 186 |
+
"LABEL_1": 1
|
| 187 |
+
},
|
| 188 |
+
"layer_norm_eps": 1e-06,
|
| 189 |
+
"model_type": "siglip2_vision_model",
|
| 190 |
+
"num_attention_heads": 12,
|
| 191 |
+
"num_channels": 3,
|
| 192 |
+
"num_hidden_layers": 12,
|
| 193 |
+
"num_patches": 256,
|
| 194 |
+
"output_attentions": false,
|
| 195 |
+
"output_hidden_states": false,
|
| 196 |
+
"patch_size": 16,
|
| 197 |
+
"problem_type": null,
|
| 198 |
+
"return_dict": true,
|
| 199 |
+
"transformers_version": "5.10.2"
|
| 200 |
+
}
|
| 201 |
+
}
|
configuration_siglip2_hier.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Config for the hierarchical SigLIP2 document classifier.
|
| 2 |
+
|
| 3 |
+
Self-contained on purpose: every piece of taxonomy/metadata needed at inference
|
| 4 |
+
(L1 groups, the L2 leaves under each L1, the flat-L2 label space, the binary/quality
|
| 5 |
+
class orders, and the human-readable display names) is stored on the config, so the
|
| 6 |
+
published model repo does not depend on the parrotlet package.
|
| 7 |
+
|
| 8 |
+
Two L2 paths are shipped:
|
| 9 |
+
* a **flat** 27-way L2 classifier (the backbone's own end-to-end head, run on the
|
| 10 |
+
RAW pooled feature) — this is the DEFAULT path; L1 is *inferred* from the flat
|
| 11 |
+
prediction via ``flat_l2_to_l1``.
|
| 12 |
+
* per-L1 **leaf** L2 heads (run on the standardized embedding) — used when the
|
| 13 |
+
caller fixes the L1 group, or asks for ``scope="hierarchical"``.
|
| 14 |
+
|
| 15 |
+
The medical / handwritten heads are single-logit **sigmoid** heads; ``*_classes`` is
|
| 16 |
+
ordered ``[negative, positive]`` and the sigmoid value is P(positive). The ``quality``
|
| 17 |
+
head is also a sigmoid, but is reported **only** as a single continuous ``score`` in
|
| 18 |
+
``[quality_score_min, quality_score_max]`` (default 1–100) — no poor/good label —
|
| 19 |
+
where ``score = round(min + P(good) * (max - min))``.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from typing import Dict, List, Optional
|
| 24 |
+
|
| 25 |
+
from transformers import PretrainedConfig
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Siglip2HierConfig(PretrainedConfig):
|
| 29 |
+
model_type = "siglip2_hier_doc"
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
base_model_id: str = "google/siglip2-base-patch16-naflex",
|
| 34 |
+
vision_config: Optional[dict] = None,
|
| 35 |
+
hidden_size: int = 768,
|
| 36 |
+
head_hidden: int = 512,
|
| 37 |
+
max_num_patches: int = 1024,
|
| 38 |
+
l1_classes: Optional[List[str]] = None,
|
| 39 |
+
l2_by_l1: Optional[Dict[str, List[str]]] = None,
|
| 40 |
+
flat_l2_classes: Optional[List[str]] = None,
|
| 41 |
+
flat_l2_to_l1: Optional[Dict[str, str]] = None,
|
| 42 |
+
med_classes: Optional[List[str]] = None,
|
| 43 |
+
hand_classes: Optional[List[str]] = None,
|
| 44 |
+
qual_classes: Optional[List[str]] = None,
|
| 45 |
+
sigmoid_heads: Optional[List[str]] = None,
|
| 46 |
+
quality_score_label: str = "good",
|
| 47 |
+
quality_score_min: int = 1,
|
| 48 |
+
quality_score_max: int = 100,
|
| 49 |
+
default_scope: str = "flat",
|
| 50 |
+
l2_display: Optional[Dict[str, str]] = None,
|
| 51 |
+
**kwargs,
|
| 52 |
+
):
|
| 53 |
+
super().__init__(**kwargs)
|
| 54 |
+
self.base_model_id = base_model_id
|
| 55 |
+
self.vision_config = vision_config
|
| 56 |
+
self.hidden_size = hidden_size
|
| 57 |
+
self.head_hidden = head_hidden
|
| 58 |
+
self.max_num_patches = max_num_patches
|
| 59 |
+
# Taxonomy. l1_classes is the ordered L1 label space; l2_by_l1 maps each L1
|
| 60 |
+
# to its ordered list of leaf L2 keys (the per-L1 leaf-head output space).
|
| 61 |
+
self.l1_classes = list(l1_classes or [])
|
| 62 |
+
self.l2_by_l1 = {k: list(v) for k, v in (l2_by_l1 or {}).items()}
|
| 63 |
+
# Flat 27-way L2 head label space + key -> L1 group (for inferring L1 from
|
| 64 |
+
# the flat prediction in the default scope).
|
| 65 |
+
self.flat_l2_classes = list(flat_l2_classes or [])
|
| 66 |
+
self.flat_l2_to_l1 = dict(flat_l2_to_l1 or {})
|
| 67 |
+
# Binary heads: ordered [negative, positive]; sigmoid value is P(positive).
|
| 68 |
+
self.med_classes = list(med_classes or ["no", "yes"])
|
| 69 |
+
self.hand_classes = list(hand_classes or ["no", "yes"])
|
| 70 |
+
self.qual_classes = list(qual_classes or ["poor", "good"])
|
| 71 |
+
# Names of the heads that are single-logit sigmoid (vs softmax).
|
| 72 |
+
self.sigmoid_heads = list(sigmoid_heads or ["med", "hand", "qual"])
|
| 73 |
+
# The quality class whose sigmoid probability is reported as the score.
|
| 74 |
+
self.quality_score_label = quality_score_label
|
| 75 |
+
# quality is reported as a single number in [min, max] (no poor/good label):
|
| 76 |
+
# score = round(min + P(good) * (max - min)).
|
| 77 |
+
self.quality_score_min = int(quality_score_min)
|
| 78 |
+
self.quality_score_max = int(quality_score_max)
|
| 79 |
+
# Default L2 path: "flat" (flat head + inferred L1), "l1" (L1 head only),
|
| 80 |
+
# or "hierarchical" (L1 head -> leaf head).
|
| 81 |
+
self.default_scope = default_scope
|
| 82 |
+
# Optional pretty names for L2 keys (e.g. "x_ray" -> "X-ray").
|
| 83 |
+
self.l2_display = dict(l2_display or {})
|
example.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal usage example. Run: python example.py path/to/image.jpg"""
|
| 2 |
+
import sys
|
| 3 |
+
from transformers import AutoModel
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
model = AutoModel.from_pretrained(".", trust_remote_code=True).eval()
|
| 7 |
+
img = Image.open(sys.argv[1] if len(sys.argv) > 1 else "doc.jpg")
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
print(json.dumps(model.classify(img), indent=2))
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:737a15c2d5173dd41233ec742aedee843ac714a1f0cdb2f82a8f6b62e17cb228
|
| 3 |
+
size 193058476
|
modeling_siglip2_hier.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hierarchical SigLIP2 document classifier as a self-contained HF model.
|
| 2 |
+
|
| 3 |
+
One pooled embedding of the (frozen) SigLIP2 tower drives several heads:
|
| 4 |
+
|
| 5 |
+
image -> SigLIP2 vision tower -> pooled [d]
|
| 6 |
+
flat L2 head (on the RAW pooled feature) -> 27-way leaf, L1 inferred
|
| 7 |
+
standardize (mu/sd) -> z, then:
|
| 8 |
+
L1 head -> 5-way document group
|
| 9 |
+
leaf L2 head[L1] -> leaf within a (predicted or fixed) L1 group
|
| 10 |
+
medical / handwritten -> sigmoid (P positive)
|
| 11 |
+
quality -> sigmoid (P good) reported as a 1-100 score
|
| 12 |
+
|
| 13 |
+
Three L2 *scopes* (``classify(..., scope=...)``):
|
| 14 |
+
* ``"flat"`` (DEFAULT): run the flat 27-way head; **L1 is inferred** from the
|
| 15 |
+
predicted L2 class. This is the backbone's own strong end-to-end path.
|
| 16 |
+
* ``"l1"``: run the L1 group head only (no L2).
|
| 17 |
+
* ``"hierarchical"``: L1 head picks the group, then that group's leaf L2 head.
|
| 18 |
+
|
| 19 |
+
Fixing the group: pass ``l1="Document"`` (any L1 name) to **fix the L1** and read L2
|
| 20 |
+
from that group's leaf head — regardless of scope.
|
| 21 |
+
|
| 22 |
+
Medical / handwritten are single-logit sigmoids (yes/no + ``p_positive``);
|
| 23 |
+
``quality`` returns only a single ``score`` in 1-100 (P(good) mapped onto the range),
|
| 24 |
+
with no poor/good label.
|
| 25 |
+
|
| 26 |
+
Public API
|
| 27 |
+
----------
|
| 28 |
+
model = AutoModel.from_pretrained(repo, trust_remote_code=True)
|
| 29 |
+
model.classify(image) # flat L2 + inferred L1 + binaries
|
| 30 |
+
model.classify(image, scope="l1") # L1 group only
|
| 31 |
+
model.classify(image, l1="Document") # fixed L1 -> leaf L2
|
| 32 |
+
|
| 33 |
+
Quantized loading (int8/int4) uses optimum-quanto; see ``load_classifier`` /
|
| 34 |
+
``quantize_in_place`` at the bottom.
|
| 35 |
+
"""
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import os
|
| 39 |
+
from typing import Optional
|
| 40 |
+
|
| 41 |
+
import torch
|
| 42 |
+
import torch.nn as nn
|
| 43 |
+
from transformers import PreTrainedModel
|
| 44 |
+
|
| 45 |
+
from .configuration_siglip2_hier import Siglip2HierConfig
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class _MLPHead(nn.Module):
|
| 49 |
+
"""Linear -> GELU -> Dropout -> Linear. Must match the training-time head so
|
| 50 |
+
saved weights load 1:1. ``n_out=1`` for the sigmoid binary heads."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, d_in: int, n_out: int, hidden: int = 512, dropout: float = 0.0):
|
| 53 |
+
super().__init__()
|
| 54 |
+
self.net = nn.Sequential(
|
| 55 |
+
nn.Linear(d_in, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, n_out)
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def forward(self, x):
|
| 59 |
+
return self.net(x)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Siglip2HierForDocClassification(PreTrainedModel):
|
| 63 |
+
config_class = Siglip2HierConfig
|
| 64 |
+
main_input_name = "pixel_values"
|
| 65 |
+
_tied_weights_keys = []
|
| 66 |
+
_keys_to_ignore_on_load_missing = []
|
| 67 |
+
|
| 68 |
+
def __init__(self, config: Siglip2HierConfig):
|
| 69 |
+
super().__init__(config)
|
| 70 |
+
from transformers import Siglip2VisionConfig, Siglip2VisionModel
|
| 71 |
+
|
| 72 |
+
vc = config.vision_config
|
| 73 |
+
if isinstance(vc, dict):
|
| 74 |
+
vc = Siglip2VisionConfig(**vc)
|
| 75 |
+
# Only the vision tower of SigLIP2 — the text encoder is never instantiated.
|
| 76 |
+
self.vision = Siglip2VisionModel(vc) if vc is not None else None
|
| 77 |
+
|
| 78 |
+
d, hid = config.hidden_size, config.head_hidden
|
| 79 |
+
sig = set(config.sigmoid_heads)
|
| 80 |
+
self.l1_head = _MLPHead(d, len(config.l1_classes), hid)
|
| 81 |
+
# ModuleList aligned to config.l1_classes order (L1 names contain spaces /
|
| 82 |
+
# slashes, not valid ModuleDict keys).
|
| 83 |
+
self.leaf_heads = nn.ModuleList(
|
| 84 |
+
[_MLPHead(d, len(config.l2_by_l1[l1]), hid) for l1 in config.l1_classes]
|
| 85 |
+
)
|
| 86 |
+
# Flat 27-way L2 head = the backbone's own end-to-end classifier (a bare
|
| 87 |
+
# Linear), applied to the RAW pooled feature (NOT standardized).
|
| 88 |
+
self.flat_head = nn.Linear(d, len(config.flat_l2_classes)) if config.flat_l2_classes else None
|
| 89 |
+
# Binary heads: 1 logit when sigmoid, else len(classes).
|
| 90 |
+
self.med_head = _MLPHead(d, 1 if "med" in sig else len(config.med_classes), hid)
|
| 91 |
+
self.hand_head = _MLPHead(d, 1 if "hand" in sig else len(config.hand_classes), hid)
|
| 92 |
+
self.qual_head = _MLPHead(d, 1 if "qual" in sig else len(config.qual_classes), hid)
|
| 93 |
+
|
| 94 |
+
# Feature standardization fit on the training embeddings (fp32, always).
|
| 95 |
+
self.register_buffer("mu", torch.zeros(1, d, dtype=torch.float32))
|
| 96 |
+
self.register_buffer("sd", torch.ones(1, d, dtype=torch.float32))
|
| 97 |
+
|
| 98 |
+
self._processor = None
|
| 99 |
+
self.post_init()
|
| 100 |
+
|
| 101 |
+
# ------------------------------------------------------------------ utils
|
| 102 |
+
def gradient_checkpointing_enable(self, **kw):
|
| 103 |
+
if self.vision is not None:
|
| 104 |
+
self.vision.gradient_checkpointing_enable(**kw)
|
| 105 |
+
|
| 106 |
+
def set_processor(self, processor):
|
| 107 |
+
self._processor = processor
|
| 108 |
+
return self
|
| 109 |
+
|
| 110 |
+
def _get_processor(self):
|
| 111 |
+
if self._processor is None:
|
| 112 |
+
from transformers import AutoImageProcessor
|
| 113 |
+
|
| 114 |
+
src = self.config._name_or_path or self.name_or_path
|
| 115 |
+
if not src:
|
| 116 |
+
raise RuntimeError(
|
| 117 |
+
"No image processor available. Load from a repo/dir that ships a "
|
| 118 |
+
"preprocessor, or call model.set_processor(proc)."
|
| 119 |
+
)
|
| 120 |
+
proc = AutoImageProcessor.from_pretrained(src)
|
| 121 |
+
proc.max_num_patches = self.config.max_num_patches
|
| 122 |
+
self._processor = proc
|
| 123 |
+
return self._processor
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def _compute_dtype(self) -> torch.dtype:
|
| 127 |
+
dt = self.dtype # PreTrainedModel.dtype skips int (quantized) params
|
| 128 |
+
return dt if dt.is_floating_point else torch.float16
|
| 129 |
+
|
| 130 |
+
# --------------------------------------------------------------- forward
|
| 131 |
+
@torch.no_grad()
|
| 132 |
+
def _embed_raw(self, image) -> torch.Tensor:
|
| 133 |
+
"""Image -> RAW pooled embedding [1, d] (fp32). One vision forward."""
|
| 134 |
+
from PIL import Image
|
| 135 |
+
|
| 136 |
+
if isinstance(image, (str, os.PathLike)):
|
| 137 |
+
image = Image.open(image)
|
| 138 |
+
elif hasattr(image, "read"): # file-like / bytes buffer
|
| 139 |
+
image = Image.open(image)
|
| 140 |
+
image = image.convert("RGB")
|
| 141 |
+
|
| 142 |
+
proc = self._get_processor()
|
| 143 |
+
enc = proc(images=[image], return_tensors="pt")
|
| 144 |
+
device, cdtype = self.device, self._compute_dtype
|
| 145 |
+
enc = {
|
| 146 |
+
k: (v.to(device=device, dtype=cdtype) if torch.is_floating_point(v) else v.to(device))
|
| 147 |
+
for k, v in enc.items()
|
| 148 |
+
}
|
| 149 |
+
return self.vision(**enc).pooler_output.float()
|
| 150 |
+
|
| 151 |
+
def _standardize(self, feat: torch.Tensor) -> torch.Tensor:
|
| 152 |
+
return (feat - self.mu.float()) / self.sd.float()
|
| 153 |
+
|
| 154 |
+
@torch.no_grad()
|
| 155 |
+
def embed(self, image) -> torch.Tensor:
|
| 156 |
+
"""Back-compat: standardized pooled embedding [1, d] (fp32)."""
|
| 157 |
+
return self._standardize(self._embed_raw(image))
|
| 158 |
+
|
| 159 |
+
# --------------------------------------------------------- head helpers
|
| 160 |
+
def _softmax_probs(self, head: nn.Module, x: torch.Tensor) -> torch.Tensor:
|
| 161 |
+
logits = head(x.to(self._compute_dtype))
|
| 162 |
+
return torch.softmax(logits.float()[0], dim=-1)
|
| 163 |
+
|
| 164 |
+
def _sigmoid_pos(self, head: nn.Module, z: torch.Tensor) -> float:
|
| 165 |
+
"""P(positive) from a single-logit sigmoid head."""
|
| 166 |
+
logit = head(z.to(self._compute_dtype)).float().reshape(-1)[0]
|
| 167 |
+
return float(torch.sigmoid(logit))
|
| 168 |
+
|
| 169 |
+
def _top(self, probs: torch.Tensor, classes, top_k: Optional[int]):
|
| 170 |
+
if top_k and top_k > 1:
|
| 171 |
+
k = min(top_k, probs.numel())
|
| 172 |
+
vals, idx = torch.topk(probs, k)
|
| 173 |
+
return [{"value": classes[int(i)], "confidence": round(float(v), 4)}
|
| 174 |
+
for v, i in zip(vals, idx)]
|
| 175 |
+
i = int(probs.argmax())
|
| 176 |
+
return {"value": classes[i], "confidence": round(float(probs[i]), 4)}
|
| 177 |
+
|
| 178 |
+
def _flat_l2(self, raw: torch.Tensor, top_k):
|
| 179 |
+
"""Flat 27-way L2 on the RAW feature; infer L1 from the top prediction."""
|
| 180 |
+
probs = self._softmax_probs(self.flat_head, raw)
|
| 181 |
+
disp = self.config.l2_display
|
| 182 |
+
res = self._top(probs, self.config.flat_l2_classes, top_k)
|
| 183 |
+
if isinstance(res, list): # top_k
|
| 184 |
+
for r in res:
|
| 185 |
+
r["key"] = r["value"]
|
| 186 |
+
r["value"] = disp.get(r["key"], r["key"])
|
| 187 |
+
top_key = res[0]["key"]
|
| 188 |
+
l2 = {"source": "flat", "candidates": res}
|
| 189 |
+
else:
|
| 190 |
+
top_key = res["value"]
|
| 191 |
+
l2 = {"source": "flat", "key": top_key, "value": disp.get(top_key, top_key),
|
| 192 |
+
"confidence": res["confidence"]}
|
| 193 |
+
l1_name = self.config.flat_l2_to_l1.get(top_key)
|
| 194 |
+
return {"l2": l2, "l1": {"value": l1_name, "source": "inferred_from_flat_l2"}}
|
| 195 |
+
|
| 196 |
+
def _leaf_l2(self, z: torch.Tensor, l1_name: str, top_k):
|
| 197 |
+
"""Leaf L2 within a given L1 group."""
|
| 198 |
+
idx = self.config.l1_classes.index(l1_name)
|
| 199 |
+
probs = self._softmax_probs(self.leaf_heads[idx], z)
|
| 200 |
+
leaf_classes = self.config.l2_by_l1[l1_name]
|
| 201 |
+
disp = self.config.l2_display
|
| 202 |
+
res = self._top(probs, leaf_classes, top_k)
|
| 203 |
+
if isinstance(res, list):
|
| 204 |
+
for r in res:
|
| 205 |
+
r["key"] = r["value"]
|
| 206 |
+
r["value"] = disp.get(r["key"], r["key"])
|
| 207 |
+
return {"source": "leaf", "l1": l1_name, "candidates": res}
|
| 208 |
+
key = res["value"]
|
| 209 |
+
return {"source": "leaf", "l1": l1_name, "key": key,
|
| 210 |
+
"value": disp.get(key, key), "confidence": res["confidence"]}
|
| 211 |
+
|
| 212 |
+
def _binary(self, name: str, z: torch.Tensor, head, classes):
|
| 213 |
+
"""yes/no from a sigmoid head; classes = [negative, positive]."""
|
| 214 |
+
if name in self.config.sigmoid_heads:
|
| 215 |
+
p = self._sigmoid_pos(head, z) # P(positive)
|
| 216 |
+
else: # softmax fallback
|
| 217 |
+
probs = self._softmax_probs(head, z)
|
| 218 |
+
p = float(probs[-1])
|
| 219 |
+
pos, neg = classes[1], classes[0]
|
| 220 |
+
is_pos = p >= 0.5
|
| 221 |
+
return {"value": pos if is_pos else neg,
|
| 222 |
+
"confidence": round(p if is_pos else 1 - p, 4),
|
| 223 |
+
"p_positive": round(p, 4)}
|
| 224 |
+
|
| 225 |
+
def _quality(self, z: torch.Tensor) -> dict:
|
| 226 |
+
"""Quality as a single number in [min, max] (default 1-100) — no poor/good label.
|
| 227 |
+
|
| 228 |
+
The sigmoid head's P(good) is linearly mapped onto the configured range:
|
| 229 |
+
``score = round(min + P(good) * (max - min))`` (P=0 -> min, P=1 -> max)."""
|
| 230 |
+
p = self._sigmoid_pos(self.qual_head, z) # P(good)
|
| 231 |
+
if self.config.quality_score_label != self.config.qual_classes[1]:
|
| 232 |
+
p = 1.0 - p # score tracks the configured label
|
| 233 |
+
lo, hi = self.config.quality_score_min, self.config.quality_score_max
|
| 234 |
+
return {"score": int(round(lo + p * (hi - lo))), "p_good": round(p, 4)}
|
| 235 |
+
|
| 236 |
+
@torch.no_grad()
|
| 237 |
+
def classify(
|
| 238 |
+
self,
|
| 239 |
+
image,
|
| 240 |
+
scope: Optional[str] = None,
|
| 241 |
+
l1: Optional[str] = None,
|
| 242 |
+
medical: bool = True,
|
| 243 |
+
quality: bool = True,
|
| 244 |
+
handwritten: bool = True,
|
| 245 |
+
top_k: Optional[int] = None,
|
| 246 |
+
) -> dict:
|
| 247 |
+
"""Classify one image.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
image: PIL.Image, path, or file-like.
|
| 251 |
+
scope: L2 path — ``"flat"`` (default; flat head, L1 inferred), ``"l1"``
|
| 252 |
+
(L1 group head only), or ``"hierarchical"`` (L1 head -> leaf head).
|
| 253 |
+
Defaults to ``config.default_scope``.
|
| 254 |
+
l1: fix the L1 group (any name in ``config.l1_classes``) and read L2 from
|
| 255 |
+
that group's leaf head — overrides scope's L1/L2 routing.
|
| 256 |
+
medical / handwritten: sigmoid binaries (also report ``p_positive``).
|
| 257 |
+
quality: sigmoid head; reports a single ``score`` in 1-100 (mapped from
|
| 258 |
+
P(good)) plus ``p_good`` — no poor/good label.
|
| 259 |
+
top_k: if >1, return the top-k candidates for the multi-class heads.
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
dict keyed by task. ``l2``/``l1`` entries carry a ``source`` field
|
| 263 |
+
indicating how they were produced.
|
| 264 |
+
"""
|
| 265 |
+
scope = (scope or self.config.default_scope).lower()
|
| 266 |
+
if l1 is not None and l1 not in self.config.l1_classes:
|
| 267 |
+
raise ValueError(f"unknown l1 group {l1!r}; choose from {self.config.l1_classes}")
|
| 268 |
+
if scope not in ("flat", "l1", "hierarchical"):
|
| 269 |
+
raise ValueError(f"unknown scope {scope!r}; use 'flat', 'l1', or 'hierarchical'.")
|
| 270 |
+
if scope == "flat" and self.flat_head is None and l1 is None:
|
| 271 |
+
raise ValueError("scope='flat' requires a flat L2 head; this model has none.")
|
| 272 |
+
|
| 273 |
+
raw = self._embed_raw(image) # single vision forward
|
| 274 |
+
z = self._standardize(raw)
|
| 275 |
+
out: dict = {}
|
| 276 |
+
|
| 277 |
+
if l1 is not None:
|
| 278 |
+
# Fixed L1 group -> leaf L2.
|
| 279 |
+
out["l1"] = {"value": l1, "source": "fixed"}
|
| 280 |
+
out["l2"] = self._leaf_l2(z, l1, top_k)
|
| 281 |
+
elif scope == "l1":
|
| 282 |
+
out["l1"] = {**self._top(self._softmax_probs(self.l1_head, z),
|
| 283 |
+
self.config.l1_classes, top_k), "source": "l1_head"}
|
| 284 |
+
elif scope == "flat":
|
| 285 |
+
out.update(self._flat_l2(raw, top_k))
|
| 286 |
+
else: # hierarchical
|
| 287 |
+
l1_probs = self._softmax_probs(self.l1_head, z)
|
| 288 |
+
l1_name = self.config.l1_classes[int(l1_probs.argmax())]
|
| 289 |
+
out["l1"] = {**self._top(l1_probs, self.config.l1_classes, top_k), "source": "l1_head"}
|
| 290 |
+
out["l2"] = self._leaf_l2(z, l1_name, top_k)
|
| 291 |
+
|
| 292 |
+
if medical:
|
| 293 |
+
out["medical"] = self._binary("med", z, self.med_head, self.config.med_classes)
|
| 294 |
+
if handwritten:
|
| 295 |
+
out["handwritten"] = self._binary("hand", z, self.hand_head, self.config.hand_classes)
|
| 296 |
+
if quality:
|
| 297 |
+
out["quality"] = self._quality(z)
|
| 298 |
+
return out
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# --------------------------------------------------------------------------- #
|
| 302 |
+
# optimum-quanto quantization shortcuts (int8/int4, CPU & GPU).
|
| 303 |
+
# --------------------------------------------------------------------------- #
|
| 304 |
+
# patch_embedding is excluded — SigLIP2 casts the pixel input to its weight dtype,
|
| 305 |
+
# which an int weight breaks. Heads stay full precision (a few MB, decisive for
|
| 306 |
+
# accuracy); pass quantize_heads=True to override.
|
| 307 |
+
_EXCLUDE_FROM_QUANT = ["*patch_embedding*"]
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def quantize_in_place(model, bits: int = 4, quantize_heads: bool = False):
|
| 311 |
+
"""Quantize a loaded model's vision tower with optimum-quanto (int8 or int4)."""
|
| 312 |
+
from optimum.quanto import freeze, qint4, qint8, quantize
|
| 313 |
+
|
| 314 |
+
if bits not in (4, 8):
|
| 315 |
+
raise ValueError(f"bits must be 4 or 8, got {bits}")
|
| 316 |
+
weights = qint8 if bits == 8 else qint4
|
| 317 |
+
target = model if quantize_heads else model.vision
|
| 318 |
+
quantize(target, weights=weights, exclude=list(_EXCLUDE_FROM_QUANT))
|
| 319 |
+
freeze(target)
|
| 320 |
+
return model.eval()
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def load_classifier(
|
| 324 |
+
model_id: str,
|
| 325 |
+
quantization: Optional[str] = None,
|
| 326 |
+
device: Optional[str] = None,
|
| 327 |
+
dtype: Optional[torch.dtype] = torch.bfloat16,
|
| 328 |
+
trust_remote_code: bool = True,
|
| 329 |
+
quantize_heads: bool = False,
|
| 330 |
+
**kwargs,
|
| 331 |
+
):
|
| 332 |
+
"""Load the hierarchical classifier, optionally quanto-quantized (int8/int4)."""
|
| 333 |
+
from transformers import AutoModel
|
| 334 |
+
|
| 335 |
+
model = AutoModel.from_pretrained(
|
| 336 |
+
model_id, trust_remote_code=trust_remote_code, dtype=dtype, **kwargs
|
| 337 |
+
).eval()
|
| 338 |
+
if device:
|
| 339 |
+
model = model.to(device)
|
| 340 |
+
|
| 341 |
+
q = (quantization or "").lower()
|
| 342 |
+
if q in ("int8", "8bit", "8"):
|
| 343 |
+
quantize_in_place(model, bits=8, quantize_heads=quantize_heads)
|
| 344 |
+
elif q in ("int4", "4bit", "4"):
|
| 345 |
+
quantize_in_place(model, bits=4, quantize_heads=quantize_heads)
|
| 346 |
+
elif q not in ("", "none", "fp16", "bf16", "fp32"):
|
| 347 |
+
raise ValueError(f"Unknown quantization {quantization!r}; use None/int8/int4.")
|
| 348 |
+
return model.eval()
|
preprocessor_config.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_normalize": true,
|
| 3 |
+
"do_rescale": true,
|
| 4 |
+
"do_resize": true,
|
| 5 |
+
"image_mean": [
|
| 6 |
+
0.5,
|
| 7 |
+
0.5,
|
| 8 |
+
0.5
|
| 9 |
+
],
|
| 10 |
+
"image_processor_type": "Siglip2ImageProcessor",
|
| 11 |
+
"image_std": [
|
| 12 |
+
0.5,
|
| 13 |
+
0.5,
|
| 14 |
+
0.5
|
| 15 |
+
],
|
| 16 |
+
"max_num_patches": 1024,
|
| 17 |
+
"patch_size": 16,
|
| 18 |
+
"resample": 2,
|
| 19 |
+
"rescale_factor": 0.00392156862745098
|
| 20 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.7.0
|
| 2 |
+
transformers>=4.52.0
|
| 3 |
+
pillow
|
| 4 |
+
# Optional — only needed for int8/int4 quantized loading (runs on CPU and GPU):
|
| 5 |
+
optimum-quanto>=0.2.6
|