Fabio Antonini commited on
Commit
839d89c
·
1 Parent(s): 8210db5

Create 08_dots_ocr_vlm.ipynb

Browse files
Files changed (1) hide show
  1. notebooks/08_dots_ocr_vlm.ipynb +597 -0
notebooks/08_dots_ocr_vlm.ipynb ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Lab 08 — dots.ocr: OCR con Vision-Language Model\n",
8
+ "\n",
9
+ "> **GraphoLab** | Forensic Graphology Laboratory\n",
10
+ "\n",
11
+ "**Modello:** `rednote-hilab/dots.ocr` (Hugging Face) \n",
12
+ "**Task:** Trascrizione di testo manoscritto e stampato da immagini di documenti \n",
13
+ "**Caso d'uso forense:** Testamenti, lettere anonime, documenti storici in italiano\n",
14
+ "\n",
15
+ "---\n",
16
+ "\n",
17
+ "## Come funziona dots.ocr\n",
18
+ "\n",
19
+ "dots.ocr è fondamentalmente diverso da EasyOCR e TrOCR. Invece di una pipeline CNN+CRNN,\n",
20
+ "usa un **Vision-Language Model (VLM)** da **1.7 miliardi di parametri**:\n",
21
+ "\n",
22
+ "```\n",
23
+ "EasyOCR / TrOCR: dots.ocr:\n",
24
+ "──────────────── ─────────────────────────────\n",
25
+ "Immagine Immagine\n",
26
+ " ↓ ↓\n",
27
+ "CRAFT (detector CNN) Vision Encoder (ViT)\n",
28
+ " ↓ ↓\n",
29
+ "CRNN (recognizer) Visual Tokens\n",
30
+ " ↓ ↓\n",
31
+ "Testo LLM (1.7B params) ← comprende il contesto!\n",
32
+ " ↓\n",
33
+ " Testo\n",
34
+ "```\n",
35
+ "\n",
36
+ "Il vantaggio chiave: il **componente LLM usa il contesto linguistico** per correggere\n",
37
+ "ambiguità visive. Per l'italiano, questo significa meno errori su parole con accenti,\n",
38
+ "apostrofi e congiunzioni (es. `è`, `l'arte`, `nell'atto`).\n",
39
+ "\n",
40
+ "| Caratteristica | EasyOCR | TrOCR | **dots.ocr** |\n",
41
+ "|---|---|---|---|\n",
42
+ "| Architettura | CNN + CRNN | ViT + RoBERTa | ViT + LLM 1.7B |\n",
43
+ "| Comprensione layout | parziale | no | **si** (tabelle, formule) |\n",
44
+ "| Contesto linguistico | no | limitato (inglese) | **si** (100+ lingue) |\n",
45
+ "| Dimensione modello | ~100 MB | ~1.3 GB | ~3.5 GB (bf16) |\n",
46
+ "| Velocità su CPU | veloce | lenta | **molto lenta** |\n",
47
+ "| Qualita' su corsivo | media | media | **migliore** |\n",
48
+ "\n",
49
+ "> **Paper:** [arxiv 2512.02498](https://arxiv.org/abs/2512.02498) — RedNote / Xiaohongshu, dic 2024"
50
+ ]
51
+ },
52
+ {
53
+ "cell_type": "markdown",
54
+ "metadata": {},
55
+ "source": [
56
+ "## 1. Verifica Hardware\n",
57
+ "\n",
58
+ "dots.ocr e' pesante. Prima di caricare il modello controlliamo le risorse disponibili\n",
59
+ "e scegliamo la configurazione piu' adatta al laptop."
60
+ ]
61
+ },
62
+ {
63
+ "cell_type": "code",
64
+ "execution_count": null,
65
+ "metadata": {},
66
+ "outputs": [],
67
+ "source": [
68
+ "import torch\n",
69
+ "import psutil\n",
70
+ "import platform\n",
71
+ "\n",
72
+ "ram_gb = psutil.virtual_memory().total / 1e9\n",
73
+ "ram_free = psutil.virtual_memory().available / 1e9\n",
74
+ "has_gpu = torch.cuda.is_available()\n",
75
+ "gpu_name = torch.cuda.get_device_name(0) if has_gpu else 'N/A'\n",
76
+ "vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9 if has_gpu else 0\n",
77
+ "\n",
78
+ "print(f\"Sistema : {platform.system()} {platform.release()}\")\n",
79
+ "print(f\"CPU : {platform.processor()[:60]}\")\n",
80
+ "print(f\"RAM totale : {ram_gb:.1f} GB (libera: {ram_free:.1f} GB)\")\n",
81
+ "print(f\"GPU : {gpu_name}\")\n",
82
+ "print(f\"VRAM GPU : {vram_gb:.1f} GB\" if has_gpu else \"VRAM GPU : N/A\")\n",
83
+ "print()\n",
84
+ "\n",
85
+ "# Raccomandazione\n",
86
+ "if has_gpu and vram_gb >= 8:\n",
87
+ " DEVICE = 'cuda'\n",
88
+ " DTYPE = torch.bfloat16\n",
89
+ " ATTN = 'flash_attention_2'\n",
90
+ " print(\"[OK] GPU con VRAM >= 8 GB — usero' CUDA + bf16 + flash_attention_2 (configurazione ottimale)\")\n",
91
+ "elif has_gpu and vram_gb >= 4:\n",
92
+ " DEVICE = 'cuda'\n",
93
+ " DTYPE = torch.float16\n",
94
+ " ATTN = 'sdpa'\n",
95
+ " print(\"[OK] GPU con VRAM 4-8 GB — usero' CUDA + fp16 + sdpa\")\n",
96
+ "elif ram_free >= 8:\n",
97
+ " DEVICE = 'cpu'\n",
98
+ " DTYPE = torch.float32\n",
99
+ " ATTN = 'eager'\n",
100
+ " print(\"[OK] Solo CPU con RAM libera >= 8 GB — usero' CPU + fp32 (lento ma funziona)\")\n",
101
+ " print(\" Stima tempo per immagine: 2-5 minuti su CPU moderna\")\n",
102
+ "else:\n",
103
+ " DEVICE = 'cpu'\n",
104
+ " DTYPE = torch.float32\n",
105
+ " ATTN = 'eager'\n",
106
+ " print(\"[ATTENZIONE] RAM libera < 8 GB — il modello potrebbe non caricarsi completamente.\")\n",
107
+ " print(\" Chiudi altre applicazioni prima di procedere.\")\n",
108
+ "\n",
109
+ "print(f\"\\nConfigurazione scelta: device={DEVICE}, dtype={DTYPE}, attn={ATTN}\")"
110
+ ]
111
+ },
112
+ {
113
+ "cell_type": "markdown",
114
+ "metadata": {},
115
+ "source": [
116
+ "## 2. Installazione\n",
117
+ "\n",
118
+ "dots.ocr non e' su PyPI. Richiede il clone del repo e l'installazione locale.\n",
119
+ "Eseguire **una volta sola** — la cella e' commentata per evitare reinstallazioni accidentali."
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "code",
124
+ "execution_count": null,
125
+ "metadata": {},
126
+ "outputs": [],
127
+ "source": [
128
+ "# Decommenta ed esegui SOLO la prima volta\n",
129
+ "# -------------------------------------------------------\n",
130
+ "# import subprocess, sys\n",
131
+ "#\n",
132
+ "# # 1. Dipendenze base\n",
133
+ "# subprocess.run([sys.executable, '-m', 'pip', 'install',\n",
134
+ "# 'transformers>=4.49', 'qwen_vl_utils',\n",
135
+ "# 'accelerate', 'Pillow', 'psutil'], check=True)\n",
136
+ "#\n",
137
+ "# # 2. Clona il repo di dots.ocr (usa il nome 'DotsOCR' senza punti!)\n",
138
+ "# subprocess.run(['git', 'clone',\n",
139
+ "# 'https://github.com/rednote-hilab/dots.ocr.git',\n",
140
+ "# 'DotsOCR'], check=True)\n",
141
+ "#\n",
142
+ "# # 3. Installa il pacchetto locale\n",
143
+ "# subprocess.run([sys.executable, '-m', 'pip', 'install', '-e', 'DotsOCR'], check=True)\n",
144
+ "#\n",
145
+ "# print('Installazione completata!')\n",
146
+ "# -------------------------------------------------------\n",
147
+ "print('Cella di installazione — decommenta per eseguire.')"
148
+ ]
149
+ },
150
+ {
151
+ "cell_type": "markdown",
152
+ "metadata": {},
153
+ "source": [
154
+ "## 3. Import e Utility"
155
+ ]
156
+ },
157
+ {
158
+ "cell_type": "code",
159
+ "execution_count": null,
160
+ "metadata": {},
161
+ "outputs": [],
162
+ "source": [
163
+ "import warnings\n",
164
+ "warnings.filterwarnings('ignore')\n",
165
+ "\n",
166
+ "from pathlib import Path\n",
167
+ "import time\n",
168
+ "\n",
169
+ "import torch\n",
170
+ "from PIL import Image\n",
171
+ "import matplotlib.pyplot as plt\n",
172
+ "import matplotlib.gridspec as gridspec\n",
173
+ "\n",
174
+ "# Percorso root del progetto (notebook si trova in notebooks/)\n",
175
+ "ROOT = Path('..').resolve()\n",
176
+ "print(f'Root progetto: {ROOT}')\n",
177
+ "print(f'PyTorch: {torch.__version__}')"
178
+ ]
179
+ },
180
+ {
181
+ "cell_type": "markdown",
182
+ "metadata": {},
183
+ "source": [
184
+ "## 4. Caricamento del Modello\n",
185
+ "\n",
186
+ "Il modello viene scaricato da Hugging Face la prima volta (~3.5 GB in bf16, ~7 GB in fp32)\n",
187
+ "e messo in cache in `~/.cache/huggingface/hub`.\n",
188
+ "\n",
189
+ "> Su CPU la prima inferenza richiede 2-5 minuti. Le successive sono piu' veloci\n",
190
+ "> perche' il modello resta in RAM."
191
+ ]
192
+ },
193
+ {
194
+ "cell_type": "code",
195
+ "execution_count": null,
196
+ "metadata": {},
197
+ "outputs": [],
198
+ "source": [
199
+ "from transformers import AutoModelForCausalLM, AutoProcessor\n",
200
+ "\n",
201
+ "MODEL_ID = 'rednote-hilab/dots.ocr'\n",
202
+ "\n",
203
+ "print(f'Caricamento {MODEL_ID} ...')\n",
204
+ "print(f'Device: {DEVICE} | dtype: {DTYPE} | attn: {ATTN}')\n",
205
+ "print('(Prima volta: scarica ~3.5 GB. Attendi.)')\n",
206
+ "\n",
207
+ "t0 = time.time()\n",
208
+ "\n",
209
+ "processor = AutoProcessor.from_pretrained(\n",
210
+ " MODEL_ID,\n",
211
+ " trust_remote_code=True\n",
212
+ ")\n",
213
+ "\n",
214
+ "load_kwargs = dict(\n",
215
+ " torch_dtype=DTYPE,\n",
216
+ " trust_remote_code=True,\n",
217
+ ")\n",
218
+ "if DEVICE == 'cuda':\n",
219
+ " load_kwargs['device_map'] = 'auto'\n",
220
+ " if ATTN == 'flash_attention_2':\n",
221
+ " load_kwargs['attn_implementation'] = 'flash_attention_2'\n",
222
+ "else:\n",
223
+ " load_kwargs['device_map'] = 'cpu'\n",
224
+ "\n",
225
+ "model = AutoModelForCausalLM.from_pretrained(MODEL_ID, **load_kwargs)\n",
226
+ "model.eval()\n",
227
+ "\n",
228
+ "print(f'Modello pronto in {time.time()-t0:.1f}s')"
229
+ ]
230
+ },
231
+ {
232
+ "cell_type": "markdown",
233
+ "metadata": {},
234
+ "source": [
235
+ "## 5. Funzione di Trascrizione\n",
236
+ "\n",
237
+ "dots.ocr accetta messaggi nel formato chat (come ChatGPT): un'immagine + un prompt testuale\n",
238
+ "che specifica cosa estrarre. Le modalita' principali sono:\n",
239
+ "\n",
240
+ "- `full_ocr` — trascrive tutto il testo mantenendo l'ordine di lettura\n",
241
+ "- `layout_parse` — restituisce anche la struttura (titoli, paragrafi, tabelle)\n",
242
+ "- `formula` — rileva formule matematiche\n",
243
+ "\n",
244
+ "Per i nostri documenti forensi usiamo `full_ocr`."
245
+ ]
246
+ },
247
+ {
248
+ "cell_type": "code",
249
+ "execution_count": null,
250
+ "metadata": {},
251
+ "outputs": [],
252
+ "source": [
253
+ "try:\n",
254
+ " from dots_ocr.utils import dict_promptmode_to_prompt\n",
255
+ " PROMPT_TEXT = dict_promptmode_to_prompt.get('full_ocr',\n",
256
+ " 'Please perform OCR on this image and output all text you can read, preserving line breaks.')\n",
257
+ " print(f'Prompt ufficiale caricato: {PROMPT_TEXT[:80]}...')\n",
258
+ "except ImportError:\n",
259
+ " # Fallback se dots_ocr non e' installato come pacchetto\n",
260
+ " PROMPT_TEXT = (\n",
261
+ " 'Please perform OCR on this image. '\n",
262
+ " 'Output only the transcribed text, preserving line breaks and reading order.'\n",
263
+ " )\n",
264
+ " print('dots_ocr non trovato come pacchetto — uso prompt generico.')\n",
265
+ "\n",
266
+ "\n",
267
+ "def transcribe(image_path: str | Path, max_new_tokens: int = 1024) -> tuple[str, float]:\n",
268
+ " \"\"\"Trascrive il testo in un'immagine con dots.ocr.\n",
269
+ " \n",
270
+ " Args:\n",
271
+ " image_path: percorso all'immagine\n",
272
+ " max_new_tokens: token massimi generati (aumentare per documenti lunghi)\n",
273
+ " \n",
274
+ " Returns:\n",
275
+ " (testo_trascritto, secondi_impiegati)\n",
276
+ " \"\"\"\n",
277
+ " # Prepara il messaggio nel formato chat\n",
278
+ " messages = [\n",
279
+ " {\n",
280
+ " 'role': 'user',\n",
281
+ " 'content': [\n",
282
+ " {'type': 'image', 'image': str(image_path)},\n",
283
+ " {'type': 'text', 'text': PROMPT_TEXT},\n",
284
+ " ]\n",
285
+ " }\n",
286
+ " ]\n",
287
+ "\n",
288
+ " # Tokenizzazione\n",
289
+ " try:\n",
290
+ " from qwen_vl_utils import process_vision_info\n",
291
+ " text = processor.apply_chat_template(\n",
292
+ " messages, tokenize=False, add_generation_prompt=True\n",
293
+ " )\n",
294
+ " image_inputs, video_inputs = process_vision_info(messages)\n",
295
+ " inputs = processor(\n",
296
+ " text=[text],\n",
297
+ " images=image_inputs,\n",
298
+ " videos=video_inputs,\n",
299
+ " padding=True,\n",
300
+ " return_tensors='pt',\n",
301
+ " )\n",
302
+ " except ImportError:\n",
303
+ " # Fallback senza qwen_vl_utils\n",
304
+ " img = Image.open(image_path).convert('RGB')\n",
305
+ " inputs = processor(\n",
306
+ " text=PROMPT_TEXT,\n",
307
+ " images=img,\n",
308
+ " return_tensors='pt'\n",
309
+ " )\n",
310
+ "\n",
311
+ " inputs = {k: v.to(DEVICE) for k, v in inputs.items()}\n",
312
+ "\n",
313
+ " t0 = time.time()\n",
314
+ " with torch.no_grad():\n",
315
+ " output_ids = model.generate(\n",
316
+ " **inputs,\n",
317
+ " max_new_tokens=max_new_tokens,\n",
318
+ " do_sample=False,\n",
319
+ " )\n",
320
+ " elapsed = time.time() - t0\n",
321
+ "\n",
322
+ " # Decodifica (rimuove i token di input dal risultato)\n",
323
+ " generated = output_ids[:, inputs['input_ids'].shape[1]:]\n",
324
+ " text_out = processor.batch_decode(generated, skip_special_tokens=True)[0]\n",
325
+ " return text_out.strip(), elapsed\n",
326
+ "\n",
327
+ "\n",
328
+ "def show_result(image_path: str | Path, text: str, elapsed: float) -> None:\n",
329
+ " \"\"\"Visualizza immagine e trascrizione affiancate.\"\"\"\n",
330
+ " img = Image.open(image_path)\n",
331
+ " fig = plt.figure(figsize=(16, max(5, img.height / img.width * 8)))\n",
332
+ " gs = gridspec.GridSpec(1, 2, width_ratios=[1, 1])\n",
333
+ "\n",
334
+ " ax_img = fig.add_subplot(gs[0])\n",
335
+ " ax_text = fig.add_subplot(gs[1])\n",
336
+ "\n",
337
+ " ax_img.imshow(img)\n",
338
+ " ax_img.set_title('Immagine originale', fontsize=12)\n",
339
+ " ax_img.axis('off')\n",
340
+ "\n",
341
+ " ax_text.text(\n",
342
+ " 0.03, 0.97, text,\n",
343
+ " fontsize=10, va='top', wrap=True,\n",
344
+ " fontfamily='monospace',\n",
345
+ " transform=ax_text.transAxes,\n",
346
+ " bbox=dict(boxstyle='round', facecolor='#f5f5dc', alpha=0.9)\n",
347
+ " )\n",
348
+ " ax_text.set_title(f'Trascrizione dots.ocr ({elapsed:.1f}s)', fontsize=12)\n",
349
+ " ax_text.axis('off')\n",
350
+ "\n",
351
+ " plt.tight_layout()\n",
352
+ " plt.show()\n",
353
+ "\n",
354
+ "print('Funzioni pronte.')"
355
+ ]
356
+ },
357
+ {
358
+ "cell_type": "markdown",
359
+ "metadata": {},
360
+ "source": [
361
+ "## Demo 1 — Campione writer_00 (testo manoscritto)\n",
362
+ "\n",
363
+ "Trascriviamo uno dei campioni di writer_00 che usiamo anche per l'identificazione scrittore.\n",
364
+ "Ogni immagine e' 320x140 px e contiene 3 righe di testo italiano."
365
+ ]
366
+ },
367
+ {
368
+ "cell_type": "code",
369
+ "execution_count": null,
370
+ "metadata": {},
371
+ "outputs": [],
372
+ "source": [
373
+ "sample_path = ROOT / 'data/samples/writer_00/sample_000.png'\n",
374
+ "\n",
375
+ "print(f'Immagine: {sample_path}')\n",
376
+ "print('Avvio trascrizione ... (su CPU: 2-5 minuti)')\n",
377
+ "\n",
378
+ "text, elapsed = transcribe(sample_path)\n",
379
+ "\n",
380
+ "print(f'\\nTrascrizione ({elapsed:.1f}s):')\n",
381
+ "print('-' * 40)\n",
382
+ "print(text)\n",
383
+ "\n",
384
+ "show_result(sample_path, text, elapsed)"
385
+ ]
386
+ },
387
+ {
388
+ "cell_type": "markdown",
389
+ "metadata": {},
390
+ "source": [
391
+ "## Demo 2 — Documento testamento (documento completo)\n",
392
+ "\n",
393
+ "Trascriviamo `testamento_writer00.png`, il documento fittizio composto da campioni\n",
394
+ "reali di writer_00. Questo e' il caso d'uso forense principale.\n",
395
+ "\n",
396
+ "> Con `max_new_tokens=2048` diamo al modello spazio sufficiente per trascrivere\n",
397
+ "> un intero documento di piu' pagine."
398
+ ]
399
+ },
400
+ {
401
+ "cell_type": "code",
402
+ "execution_count": null,
403
+ "metadata": {},
404
+ "outputs": [],
405
+ "source": [
406
+ "doc_path = ROOT / 'data/samples/testamento_writer00.png'\n",
407
+ "\n",
408
+ "if not doc_path.exists():\n",
409
+ " print(f'File non trovato: {doc_path}')\n",
410
+ " print('Esegui prima scripts/create_testamento_writer00.py')\n",
411
+ "else:\n",
412
+ " print(f'Immagine: {doc_path}')\n",
413
+ " print('Avvio trascrizione documento completo ... (piu\\' lungo del campione singolo)')\n",
414
+ "\n",
415
+ " text_doc, elapsed_doc = transcribe(doc_path, max_new_tokens=2048)\n",
416
+ "\n",
417
+ " print(f'\\nTrascrizione ({elapsed_doc:.1f}s):')\n",
418
+ " print('-' * 40)\n",
419
+ " print(text_doc)\n",
420
+ "\n",
421
+ " show_result(doc_path, text_doc, elapsed_doc)"
422
+ ]
423
+ },
424
+ {
425
+ "cell_type": "markdown",
426
+ "metadata": {},
427
+ "source": [
428
+ "## Demo 3 — Immagine Lorella (scrittura reale del mondo reale)\n",
429
+ "\n",
430
+ "Trascriviamo una delle immagini dal dataset Lorella — scrittura reale,\n",
431
+ "non campioni di dettato standardizzati."
432
+ ]
433
+ },
434
+ {
435
+ "cell_type": "code",
436
+ "execution_count": null,
437
+ "metadata": {},
438
+ "outputs": [],
439
+ "source": [
440
+ "lorella_dir = ROOT / 'data/lorella'\n",
441
+ "lorella_images = sorted(lorella_dir.glob('*.png'))[:2] # prime 2 per velocita'\n",
442
+ "\n",
443
+ "if not lorella_images:\n",
444
+ " print(f'Nessuna immagine trovata in {lorella_dir}')\n",
445
+ "else:\n",
446
+ " for img_path in lorella_images:\n",
447
+ " print(f'\\n--- {img_path.name} ---')\n",
448
+ " text_l, elapsed_l = transcribe(img_path)\n",
449
+ " print(f'Trascrizione ({elapsed_l:.1f}s):')\n",
450
+ " print(text_l)\n",
451
+ " show_result(img_path, text_l, elapsed_l)"
452
+ ]
453
+ },
454
+ {
455
+ "cell_type": "markdown",
456
+ "metadata": {},
457
+ "source": [
458
+ "## Demo 4 — Confronto EasyOCR vs dots.ocr\n",
459
+ "\n",
460
+ "Confronto diretto sullo stesso campione per valutare la differenza di qualita'."
461
+ ]
462
+ },
463
+ {
464
+ "cell_type": "code",
465
+ "execution_count": null,
466
+ "metadata": {},
467
+ "outputs": [],
468
+ "source": [
469
+ "import numpy as np\n",
470
+ "\n",
471
+ "compare_path = ROOT / 'data/samples/writer_00/sample_000.png'\n",
472
+ "img_np = np.array(Image.open(compare_path).convert('RGB'))\n",
473
+ "\n",
474
+ "# --- EasyOCR ---\n",
475
+ "print('EasyOCR ...')\n",
476
+ "import easyocr\n",
477
+ "reader = easyocr.Reader(['it', 'en'], gpu=DEVICE == 'cuda')\n",
478
+ "t_easy = time.time()\n",
479
+ "easy_result = reader.readtext(img_np, detail=0, paragraph=True)\n",
480
+ "easy_text = '\\n'.join(easy_result)\n",
481
+ "easy_time = time.time() - t_easy\n",
482
+ "\n",
483
+ "# --- dots.ocr ---\n",
484
+ "print('dots.ocr ...')\n",
485
+ "dots_text, dots_time = transcribe(compare_path)\n",
486
+ "\n",
487
+ "# --- Visualizzazione ---\n",
488
+ "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n",
489
+ "\n",
490
+ "axes[0].imshow(img_np)\n",
491
+ "axes[0].set_title('Immagine originale', fontsize=12)\n",
492
+ "axes[0].axis('off')\n",
493
+ "\n",
494
+ "for ax, title, text, t in [\n",
495
+ " (axes[1], f'EasyOCR ({easy_time:.1f}s)', easy_text, easy_time),\n",
496
+ " (axes[2], f'dots.ocr ({dots_time:.1f}s)', dots_text, dots_time),\n",
497
+ "]:\n",
498
+ " ax.text(0.05, 0.5, text or '(nessun risultato)',\n",
499
+ " fontsize=12, va='center', fontfamily='monospace',\n",
500
+ " transform=ax.transAxes,\n",
501
+ " bbox=dict(boxstyle='round', facecolor='#f0f8ff', alpha=0.9))\n",
502
+ " ax.set_title(title, fontsize=12)\n",
503
+ " ax.axis('off')\n",
504
+ "\n",
505
+ "plt.suptitle('Confronto EasyOCR vs dots.ocr', fontsize=14, fontweight='bold')\n",
506
+ "plt.tight_layout()\n",
507
+ "plt.show()\n",
508
+ "\n",
509
+ "print(f'\\nEasyOCR : {easy_text}')\n",
510
+ "print(f'\\ndots.ocr: {dots_text}')"
511
+ ]
512
+ },
513
+ {
514
+ "cell_type": "markdown",
515
+ "metadata": {},
516
+ "source": [
517
+ "## Misurazione CER (Character Error Rate)\n",
518
+ "\n",
519
+ "Se conosci il testo esatto dell'immagine, puoi misurare l'errore con il CER\n",
520
+ "(frazione di caratteri errati, 0 = perfetto, 1 = tutto sbagliato)."
521
+ ]
522
+ },
523
+ {
524
+ "cell_type": "code",
525
+ "execution_count": null,
526
+ "metadata": {},
527
+ "outputs": [],
528
+ "source": [
529
+ "def cer(reference: str, hypothesis: str) -> float:\n",
530
+ " \"\"\"Character Error Rate tramite distanza di edit.\"\"\"\n",
531
+ " r, h = list(reference.replace(' ', '')), list(hypothesis.replace(' ', ''))\n",
532
+ " d = [[0] * (len(h) + 1) for _ in range(len(r) + 1)]\n",
533
+ " for i in range(len(r) + 1): d[i][0] = i\n",
534
+ " for j in range(len(h) + 1): d[0][j] = j\n",
535
+ " for i in range(1, len(r)+1):\n",
536
+ " for j in range(1, len(h)+1):\n",
537
+ " cost = 0 if r[i-1] == h[j-1] else 1\n",
538
+ " d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost)\n",
539
+ " return d[len(r)][len(h)] / max(len(r), 1)\n",
540
+ "\n",
541
+ "\n",
542
+ "# Testo atteso per sample_000.png\n",
543
+ "# (da leggere manualmente dall'immagine)\n",
544
+ "ground_truth = \"il gatto dorme sul tetto\\nla casa e piccola e bella\\noggi il cielo e molto blu\"\n",
545
+ "\n",
546
+ "cer_easy = cer(ground_truth, easy_text)\n",
547
+ "cer_dots = cer(ground_truth, dots_text)\n",
548
+ "\n",
549
+ "print(f'Ground truth : {ground_truth!r}')\n",
550
+ "print(f'EasyOCR : {easy_text!r} → CER = {cer_easy:.3f} ({cer_easy*100:.1f}%)')\n",
551
+ "print(f'dots.ocr : {dots_text!r} → CER = {cer_dots:.3f} ({cer_dots*100:.1f}%)')\n",
552
+ "\n",
553
+ "winner = 'dots.ocr' if cer_dots < cer_easy else 'EasyOCR'\n",
554
+ "print(f'\\nModello migliore su questo campione: {winner}')"
555
+ ]
556
+ },
557
+ {
558
+ "cell_type": "markdown",
559
+ "metadata": {},
560
+ "source": [
561
+ "## Note Forensi\n",
562
+ "\n",
563
+ "- **dots.ocr e' un VLM**: genera testo token per token. In rari casi puo' \"allucinare\"\n",
564
+ " parole plausibili ma non presenti nell'immagine. Verificare sempre contro l'originale.\n",
565
+ "\n",
566
+ "- **Velocita' su CPU**: 2-5 minuti per immagine su laptop moderno senza GPU. Accettabile\n",
567
+ " per analisi forensi manuali, non adatto a pipeline automatizzate in tempo reale.\n",
568
+ "\n",
569
+ "- **Qualita' su corsivo**: migliore di EasyOCR grazie al contesto linguistico LLM,\n",
570
+ " ma non perfetto — la scrittura corsiva personale rimane la sfida principale.\n",
571
+ "\n",
572
+ "- **Alternativa commerciale per qualita' massima**: [Transkribus](https://www.transkribus.org)\n",
573
+ " ha modelli specializzati su manoscritti storici italiani.\n",
574
+ "\n",
575
+ "- **Integrazione nella demo Gradio**: il modello e' troppo lento per una demo interattiva\n",
576
+ " su laptop. Manteniamo EasyOCR nel tab HTR e usiamo dots.ocr solo offline (questo notebook).\n",
577
+ "\n",
578
+ "---\n",
579
+ "\n",
580
+ "**Lab precedente →** [07 — Named Entity Recognition](07_named_entity_recognition.ipynb)"
581
+ ]
582
+ }
583
+ ],
584
+ "metadata": {
585
+ "kernelspec": {
586
+ "display_name": "Python 3 (GraphoLab)",
587
+ "language": "python",
588
+ "name": "python3"
589
+ },
590
+ "language_info": {
591
+ "name": "python",
592
+ "version": "3.11.0"
593
+ }
594
+ },
595
+ "nbformat": 4,
596
+ "nbformat_minor": 5
597
+ }