File size: 8,604 Bytes
09ba195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1d2f62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
---
language:
  - bo
license: apache-2.0
library_name: transformers
tags:
  - image-classification
  - dinov3
  - tibetan
  - script-classification
  - paleography
  - fine-tuned
  - document-analysis
base_model: facebook/dinov3-vits16-pretrain-lvd1689m
datasets:
  - custom
metrics:
  - f1
  - accuracy
pipeline_tag: image-classification
model-index:
  - name: Tibetan Script Classifier (DINOv3 ViT-S)
    results:
      - task:
          type: image-classification
          name: Tibetan Script Classification
        metrics:
          - name: Macro F1 (whole page)
            type: f1
            value: 0.512
          - name: Accuracy (whole page)
            type: accuracy
            value: 0.571
          - name: Macro F1 (CLAHE patches, page-level)
            type: f1
            value: 0.529
---

# Tibetan Script Classifier (DINOv3)

This repository contains fine-tuned Tibetan script classification checkpoints for 18 classes, trained from the DINOv3 ViT-S backbone:

- Backbone: `facebook/dinov3-vits16-pretrain-lvd1689m`
- Task: 18-way script classification
- Training script included: `finetune_dinov3.py`

**Hugging Face access:** DINOv3 requires access approval at [huggingface.co/facebook/dinov3-vits16-pretrain-lvd1689m](https://huggingface.co/facebook/dinov3-vits16-pretrain-lvd1689m) before `from_pretrained` / downloads will work. Anyone cloning this repo will see the same gated-model error until their HF account is granted access and they are logged in (`huggingface-cli login` or `HF_TOKEN`).

## Label Set

`dhumri`, `difficult`, `drathung`, `drudring`, `druring`, `druthung`, `khyuyig`, `multi_scripts`, `non_tibetan`, `peri`, `petsuk`, `trinyig`, `tsegdrig`, `tsugchung`, `tsumachug`, `uchen_sugdring`, `uchen_sugthung`, `yigchung`.

## Preprocessing (per experiment)

Images for training were produced as follows (see `preprocess.py` in the parent project):

- **`whole_page`:** resize so the **short edge is 224 px**, then **center crop** to 224×224 (one crop per source page).
- **`patches_color`:** same short-edge resize to 224, then **sliding-window** 224×224 patches with **25% overlap** between windows (multiple crops per page).
- **`patches_clahe`:** identical patch layout as `patches_color`; each patch is converted to grayscale and **CLAHE** contrast normalization is applied (`clipLimit=2.0`, `tileGridSize=(8,8)`), then saved as BGR/RGB for training.

## Training recipe

- **Progressive unfreezing (defaults in `finetune_dinov3.py`):**
  - **Stage A — head only:** 20 epochs, backbone frozen, classifier head LR **1e-3** (backbone LR 0).
  - **Stage B — last 2 blocks:** 10 epochs, backbone LR **1e-5**, head LR **1e-3**.
  - **Stage C — last 4 blocks:** 10 epochs, backbone LR **5e-6**, head LR **5e-4**.
- **Loss:** class-weighted **cross-entropy** with inverse-frequency weights over the training split (`nn.CrossEntropyLoss(weight=...)`).
- **Sampling:** the published runs use a standard `DataLoader` with **`shuffle=True`**. The script also defines **`get_weighted_sampler` → `WeightedRandomSampler`** if you want to switch the train loader to explicit class-balanced sampling.
- **Document-aware augmentations (train only):** `RandomRotation` **±5°** (fill white), `ColorJitter` brightness/contrast **±20%** (`0.2`), plus `RandomResizedCrop` and light `RandomErasing` as in `ScriptDataset`; **no horizontal flip**.

## Class distribution (`whole_page` split totals)

The whole-page split has **5,684** samples in total (**train 3,996 / val 844 / test 844**). The **844** figure in test metrics is **only the test split** (roughly 15% of pages per class held out for testing), not the full dataset.

The per-class table below sums **train + val + test** counts. A benchmark exclusion manifest (`benchmark_page_ids.json`, **88** page IDs across 18 classes) is consulted when building splits: any file whose page ID matches is skipped and counted in `splits.json` under `skipped_excluded_files_by_class`. For the published `whole_page/splits.json`, that skip map is **empty**—so either those pages were **not present** under the training `data_dir`, or they had **already been removed** before this split was built. The **5,684** totals are whatever files remained for stratified splitting.

| Class | Samples |
|---|---:|
| dhumri | 98 |
| difficult | 170 |
| drathung | 129 |
| drudring | 132 |
| druring | 119 |
| druthung | 207 |
| khyuyig | 113 |
| multi_scripts | 235 |
| non_tibetan | 192 |
| peri | 614 |
| petsuk | 1388 |
| trinyig | 42 |
| tsegdrig | 749 |
| tsugchung | 77 |
| tsumachug | 178 |
| uchen_sugdring | 835 |
| uchen_sugthung | 240 |
| yigchung | 166 |

## Experiments Included

### 1) `whole_page`

- Files: `whole_page/final_model.pt`, `results.json`, `confusion_matrix.png`, `confusion_matrix.csv`, `splits.json`
- Test (image-level) macro-F1: **0.5124**
- Test accuracy: **0.5711**

### 2) `patches_color`

- Files: `patches_color/final_model.pt`, `results.json`, `confusion_matrix.png`, `checkpoint_page_eval.json`, `splits.json`
- Test (patch-level) macro-F1: **0.4899**
- Re-eval **page-level** macro-F1 for shipped `final_model.pt` (`checkpoint_page_eval.json`): **0.5017**
- Best **page-level** macro-F1 among stage checkpoints on the same grid: **0.5043** (**Stage A**)

### 3) `patches_clahe`

- Files: `patches_clahe/final_model.pt`, `results.json`, `confusion_matrix.png`, `checkpoint_page_eval.json`, `splits.json`
- Test (patch-level) macro-F1: **0.4911**
- Re-eval page-level macro-F1 for shipped `final_model.pt`: **0.5261**
- Best **page-level** macro-F1 among stage checkpoints: **0.529** (**Stage B**)

## Which stage produced which checkpoint?

- **`final_model.pt` in each folder** is the stage with the highest **validation macro-F1** among `best_stage_*.pt` checkpoints (see `best_val_checkpoint` in each `results.json`): **Stage B** for `whole_page`, **Stage C** for both `patches_color` and `patches_clahe`.
- For **page-level** quality on the patch runs, the best single stage on the re-eval grid differs: **Stage A** (`patches_color`) and **Stage B** (`patches_clahe`) beat their respective `final_model.pt` page scores—use `checkpoint_page_eval.json` if you want to deploy a stage checkpoint instead of the val-selected default.

## Which experiment won?

CLAHE patches achieved the highest **page-level** macro-F1 (**0.529** on the best stage checkpoint), while **whole page** achieved the best **image-level** macro-F1 (**0.512**). **Whole page** is recommended for production due to simpler inference.

## How To Load a Checkpoint

```python
import torch
from pathlib import Path
from finetune_dinov3 import DINOv3Classifier, DINOV3_MODEL_ID

ckpt_path = Path("whole_page/final_model.pt")
payload = torch.load(ckpt_path, map_location="cpu")

label_to_idx = payload["label_to_idx"]
idx_to_label = {v: k for k, v in label_to_idx.items()}
num_classes = len(label_to_idx)

model = DINOv3Classifier(DINOV3_MODEL_ID, num_classes)
model.load_state_dict(payload["model_state_dict"])
model.eval()
```

## Inference (Single Image)

```python
import torch
from PIL import Image
from transformers import AutoImageProcessor

processor = AutoImageProcessor.from_pretrained(DINOV3_MODEL_ID)
img = Image.open("example.png").convert("RGB")
inputs = processor(images=img, return_tensors="pt")

with torch.no_grad():
    logits = model(inputs["pixel_values"])
    probs = torch.softmax(logits, dim=1)[0].cpu().numpy()
pred_idx = int(probs.argmax())
pred_label = idx_to_label[pred_idx]
print(pred_label, float(probs[pred_idx]))
```

## Page-Level Inference (Patch Aggregation)

For patch experiments (`patches_color`, `patches_clahe`), aggregate by page stem:

1. group patch probabilities by page ID (strip `_pN` suffix),
2. average probabilities per page,
3. take `argmax` of averaged probabilities.

This is the same logic used in the re-evaluation script output (`checkpoint_page_eval.json`).

## Known Limitations

- Class imbalance is high (for example `petsuk` and `uchen_sugdring` dominate, while `trinyig` is small).
- Results can vary by preprocessing variant and by patch vs page-level evaluation protocol.
- Patch-level metrics and page-level metrics are not directly interchangeable.
- The model expects Tibetan manuscript-style inputs; performance can drop on out-of-domain scans or mixed/noisy pages.
- Checkpoints are tied to the exact label mapping saved in each payload (`label_to_idx`).

## Reproducibility Notes

- Exclusion manifest support is enabled in training (`benchmark_page_ids.json`).
- Full training code used for these artifacts is included at `finetune_dinov3.py`.