Karez commited on
Commit
1a3cc45
·
verified ·
1 Parent(s): 88fdab2

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* 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
 
 
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
+ Sample/sample_paragraph.tif filter=lfs diff=lfs merge=lfs -text
DASNUS-Kurdish-ParagraphHTR/README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ckb
4
+ license: cc-by-nc-4.0
5
+ tags:
6
+ - handwritten-text-recognition
7
+ - paragraph-recognition
8
+ - ckb
9
+ - densenet
10
+ - transformer
11
+ - pytorch
12
+ - safetensors
13
+ datasets:
14
+ - DASNUS
15
+ metrics:
16
+ - cer
17
+ - wer
18
+ pipeline_tag: image-to-text
19
+ ---
20
+
21
+ # DASNUS-Kurdish: DenseNet121-Transformer Paragraph HTR
22
+
23
+ ## Model Description
24
+ Kurdish handwritten paragraph recognition model fine-tuned on the external DASNUS dataset accessed through: https://data.mendeley.com/datasets/xdj9f55rkm/1. Pre-trained on 12,000 synthetic Kurdish paragraphs from DASTNUS, then fine-tuned on 1,843 reconstructed DASNUS paragraphs. Demonstrates cross-dataset transfer capability.
25
+
26
+ ## Architecture
27
+ - **CNN Backbone:** DenseNet-121 (pretrained on ImageNet)
28
+ - **Horizontal Upsample:** Yes
29
+ - **Encoder:** 3 Transformer encoder layers
30
+ - **Decoder:** 6 Transformer decoder layers
31
+ - **Attention Heads:** 8
32
+ - **Hidden Size:** 256
33
+ - **Feed-Forward Dim:** 2048
34
+ - **Vocabulary Size:** 116
35
+ - **Parameters:** 22,746,927
36
+
37
+ ## Performance on DASNUS
38
+ | Metric | Value |
39
+ |--------|-------|
40
+ | CER (greedy) | 0.0856 |
41
+ | WER (greedy) | 0.3148 |
42
+
43
+ ## Input Format
44
+ - **Image size:** 600 x 1235 pixels
45
+ - **Preprocessing:** Aspect-ratio-preserving resize, right-aligned on white canvas (RTL)
46
+ - **Normalization:** ImageNet mean/std
47
+
48
+ ## Training
49
+ - **Pre-training:** 12,000 synthetic paragraph images with curriculum learning
50
+ - **Fine-tuning:** Real handwritten paragraphs from DASNUS
51
+ - **Two-stage strategy:** Encoder frozen for first 10 epochs during fine-tuning
52
+
53
+ ## Usage
54
+ ```python
55
+ from safetensors.torch import load_file
56
+ import json
57
+
58
+ # Load model weights
59
+ state_dict = load_file("model.safetensors")
60
+
61
+ # Load config
62
+ with open("config.json", "r") as f:
63
+ config = json.load(f)
64
+
65
+ # Load vocabulary
66
+ with open("vocab.json", "r") as f:
67
+ vocab = json.load(f)
68
+
69
+ # Load reverse mapping
70
+ with open("idx_to_char.json", "r") as f:
71
+ idx_to_char = json.load(f)
72
+ ```
73
+
74
+ ## Citation
75
+ ```
76
+ [Citation to be added upon publication]
77
+ ```
78
+
79
+ ## License
80
+ This model is released under CC-BY-NC-4.0 for non-commercial research purposes only.
DASNUS-Kurdish-ParagraphHTR/config.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "DenseNet121-Transformer",
3
+ "model_type": "custom",
4
+ "task": "handwritten-text-recognition",
5
+ "language": "Kurdish (Central / Sorani)",
6
+ "language_code": "ckb",
7
+ "script": "Kurdish",
8
+ "dataset": "DASNUS",
9
+ "cnn_backbone": "densenet121",
10
+ "use_upsample": true,
11
+ "hidden_size": 256,
12
+ "num_encoder_layers": 3,
13
+ "num_decoder_layers": 6,
14
+ "num_attention_heads": 8,
15
+ "feed_forward_dim": 2048,
16
+ "dropout_pretrain": 0.3,
17
+ "dropout_finetune": 0.2,
18
+ "vocab_size": 116,
19
+ "max_sequence_length": 555,
20
+ "image_height": 600,
21
+ "image_width": 1235,
22
+ "total_parameters": 22746927,
23
+ "training": {
24
+ "best_epoch": 76,
25
+ "best_val_cer": 0.08455350686912509,
26
+ "best_val_loss": null,
27
+ "pretrain_optimizer": "AdamW",
28
+ "pretrain_lr": 0.0001,
29
+ "finetune_optimizer": "AdamW",
30
+ "finetune_lr": 5e-05,
31
+ "pretrain_scheduler": "StepLR (step=15, gamma=0.5)",
32
+ "finetune_scheduler": "ReduceLROnPlateau (patience=5, factor=0.5)",
33
+ "pretrain_batch_size": 16,
34
+ "finetune_batch_size": 16,
35
+ "pretrain_epochs": 80,
36
+ "finetune_epochs": 80,
37
+ "curriculum_learning": true,
38
+ "teacher_forcing_noise_pretrain": 0.15,
39
+ "teacher_forcing_noise_finetune": 0.05,
40
+ "encoder_freeze_epochs": 10,
41
+ "encoder_lr_multiplier": 0.1
42
+ },
43
+ "performance": {
44
+ "test_cer": 0.0856,
45
+ "test_wer": 0.3148,
46
+ "test_cer_with_lm": null
47
+ }
48
+ }
DASNUS-Kurdish-ParagraphHTR/idx_to_char.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "0": "<PAD>",
3
+ "1": "<SOS>",
4
+ "2": "<EOS>",
5
+ "3": "\n",
6
+ "4": " ",
7
+ "5": "!",
8
+ "6": "\"",
9
+ "7": "#",
10
+ "8": "%",
11
+ "9": "&",
12
+ "10": "'",
13
+ "11": "(",
14
+ "12": ")",
15
+ "13": "*",
16
+ "14": "+",
17
+ "15": "-",
18
+ "16": ".",
19
+ "17": "/",
20
+ "18": "0",
21
+ "19": "1",
22
+ "20": "2",
23
+ "21": "4",
24
+ "22": ":",
25
+ "23": ";",
26
+ "24": "=",
27
+ "25": "@",
28
+ "26": "C",
29
+ "27": "D",
30
+ "28": "F",
31
+ "29": "H",
32
+ "30": "P",
33
+ "31": "[",
34
+ "32": "]",
35
+ "33": "_",
36
+ "34": "a",
37
+ "35": "c",
38
+ "36": "d",
39
+ "37": "e",
40
+ "38": "h",
41
+ "39": "m",
42
+ "40": "o",
43
+ "41": "p",
44
+ "42": "s",
45
+ "43": "t",
46
+ "44": "x",
47
+ "45": "{",
48
+ "46": "|",
49
+ "47": "}",
50
+ "48": "×",
51
+ "49": "÷",
52
+ "50": "،",
53
+ "51": "؛",
54
+ "52": "؟",
55
+ "53": "ء",
56
+ "54": "أ",
57
+ "55": "ؤ",
58
+ "56": "ئ",
59
+ "57": "ا",
60
+ "58": "ب",
61
+ "59": "ة",
62
+ "60": "ت",
63
+ "61": "ث",
64
+ "62": "ج",
65
+ "63": "ح",
66
+ "64": "خ",
67
+ "65": "د",
68
+ "66": "ذ",
69
+ "67": "ر",
70
+ "68": "ز",
71
+ "69": "س",
72
+ "70": "ش",
73
+ "71": "ص",
74
+ "72": "ط",
75
+ "73": "ع",
76
+ "74": "غ",
77
+ "75": "ـ",
78
+ "76": "ف",
79
+ "77": "ق",
80
+ "78": "ك",
81
+ "79": "ل",
82
+ "80": "م",
83
+ "81": "ن",
84
+ "82": "ه",
85
+ "83": "و",
86
+ "84": "وو",
87
+ "85": "ى",
88
+ "86": "ي",
89
+ "87": "٠",
90
+ "88": "١",
91
+ "89": "٢",
92
+ "90": "٣",
93
+ "91": "٤",
94
+ "92": "٥",
95
+ "93": "٦",
96
+ "94": "٧",
97
+ "95": "٨",
98
+ "96": "٩",
99
+ "97": "٪",
100
+ "98": "پ",
101
+ "99": "چ",
102
+ "100": "ڕ",
103
+ "101": "ژ",
104
+ "102": "ڤ",
105
+ "103": "ک",
106
+ "104": "گ",
107
+ "105": "ڵ",
108
+ "106": "ھ",
109
+ "107": "ۆ",
110
+ "108": "ی",
111
+ "109": "ێ",
112
+ "110": "۔",
113
+ "111": "ە",
114
+ "112": "‌",
115
+ "113": "‎",
116
+ "114": "‏",
117
+ "115": "–"
118
+ }
DASNUS-Kurdish-ParagraphHTR/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f528c5e2053930284579e6663c0e945b1ab982f15bccb0b30750c2326e7993f
3
+ size 16012184
DASNUS-Kurdish-ParagraphHTR/vocab.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "<PAD>": 0,
3
+ "<SOS>": 1,
4
+ "<EOS>": 2,
5
+ "\n": 3,
6
+ " ": 4,
7
+ "!": 5,
8
+ "\"": 6,
9
+ "#": 7,
10
+ "%": 8,
11
+ "&": 9,
12
+ "'": 10,
13
+ "(": 11,
14
+ ")": 12,
15
+ "*": 13,
16
+ "+": 14,
17
+ "-": 15,
18
+ ".": 16,
19
+ "/": 17,
20
+ "0": 18,
21
+ "1": 19,
22
+ "2": 20,
23
+ "4": 21,
24
+ ":": 22,
25
+ ";": 23,
26
+ "=": 24,
27
+ "@": 25,
28
+ "C": 26,
29
+ "D": 27,
30
+ "F": 28,
31
+ "H": 29,
32
+ "P": 30,
33
+ "[": 31,
34
+ "]": 32,
35
+ "_": 33,
36
+ "a": 34,
37
+ "c": 35,
38
+ "d": 36,
39
+ "e": 37,
40
+ "h": 38,
41
+ "m": 39,
42
+ "o": 40,
43
+ "p": 41,
44
+ "s": 42,
45
+ "t": 43,
46
+ "x": 44,
47
+ "{": 45,
48
+ "|": 46,
49
+ "}": 47,
50
+ "×": 48,
51
+ "÷": 49,
52
+ "،": 50,
53
+ "؛": 51,
54
+ "؟": 52,
55
+ "ء": 53,
56
+ "أ": 54,
57
+ "ؤ": 55,
58
+ "ئ": 56,
59
+ "ا": 57,
60
+ "ب": 58,
61
+ "ة": 59,
62
+ "ت": 60,
63
+ "ث": 61,
64
+ "ج": 62,
65
+ "ح": 63,
66
+ "خ": 64,
67
+ "د": 65,
68
+ "ذ": 66,
69
+ "ر": 67,
70
+ "ز": 68,
71
+ "س": 69,
72
+ "ش": 70,
73
+ "ص": 71,
74
+ "ط": 72,
75
+ "ع": 73,
76
+ "غ": 74,
77
+ "ـ": 75,
78
+ "ف": 76,
79
+ "ق": 77,
80
+ "ك": 78,
81
+ "ل": 79,
82
+ "م": 80,
83
+ "ن": 81,
84
+ "ه": 82,
85
+ "و": 83,
86
+ "وو": 84,
87
+ "ى": 85,
88
+ "ي": 86,
89
+ "٠": 87,
90
+ "١": 88,
91
+ "٢": 89,
92
+ "٣": 90,
93
+ "٤": 91,
94
+ "٥": 92,
95
+ "٦": 93,
96
+ "٧": 94,
97
+ "٨": 95,
98
+ "٩": 96,
99
+ "٪": 97,
100
+ "پ": 98,
101
+ "چ": 99,
102
+ "ڕ": 100,
103
+ "ژ": 101,
104
+ "ڤ": 102,
105
+ "ک": 103,
106
+ "گ": 104,
107
+ "ڵ": 105,
108
+ "ھ": 106,
109
+ "ۆ": 107,
110
+ "ی": 108,
111
+ "ێ": 109,
112
+ "۔": 110,
113
+ "ە": 111,
114
+ "‌": 112,
115
+ "‎": 113,
116
+ "‏": 114,
117
+ "–": 115
118
+ }
DASTNUS-Kurdish-ParagraphHTR/README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ckb
4
+ license: cc-by-nc-4.0
5
+ tags:
6
+ - handwritten-text-recognition
7
+ - paragraph-recognition
8
+ - ckb
9
+ - densenet
10
+ - transformer
11
+ - pytorch
12
+ - safetensors
13
+ datasets:
14
+ - DASTNUS
15
+ metrics:
16
+ - cer
17
+ - wer
18
+ pipeline_tag: image-to-text
19
+ ---
20
+
21
+ # DASTNUS-Kurdish: DenseNet121-Transformer Paragraph HTR
22
+
23
+ ## Model Description
24
+ End-to-end Kurdish handwritten paragraph recognition model. Pre-trained on 12,000 synthetic paragraph images generated from the DASTNUS dataset, then fine-tuned on 710 real unique handwritten paragraphs. Achieves CER of 0.0721 with greedy decoding and 0.0676 with 8-gram language model rescoring.
25
+
26
+ ## Architecture
27
+ - **CNN Backbone:** DenseNet-121 (pretrained on ImageNet)
28
+ - **Horizontal Upsample:** Yes
29
+ - **Encoder:** 3 Transformer encoder layers
30
+ - **Decoder:** 6 Transformer decoder layers
31
+ - **Attention Heads:** 8
32
+ - **Hidden Size:** 256
33
+ - **Feed-Forward Dim:** 2048
34
+ - **Vocabulary Size:** 116
35
+ - **Parameters:** 22,746,927
36
+
37
+ ## Performance on DASTNUS
38
+ | Metric | Value |
39
+ |--------|-------|
40
+ | CER (greedy) | 0.0721 |
41
+ | WER (greedy) | 0.3624 |
42
+ | CER (with 8-gram LM) | 0.0676 |
43
+
44
+ ## Input Format
45
+ - **Image size:** 600 x 1235 pixels
46
+ - **Preprocessing:** Aspect-ratio-preserving resize, right-aligned on white canvas (RTL)
47
+ - **Normalization:** ImageNet mean/std
48
+
49
+ ## Training
50
+ - **Pre-training:** 12,000 synthetic paragraph images with curriculum learning
51
+ - **Fine-tuning:** Real handwritten paragraphs from DASTNUS
52
+ - **Two-stage strategy:** Encoder frozen for first 10 epochs during fine-tuning
53
+
54
+ ## Usage
55
+ ```python
56
+ from safetensors.torch import load_file
57
+ import json
58
+
59
+ # Load model weights
60
+ state_dict = load_file("model.safetensors")
61
+
62
+ # Load config
63
+ with open("config.json", "r") as f:
64
+ config = json.load(f)
65
+
66
+ # Load vocabulary
67
+ with open("vocab.json", "r") as f:
68
+ vocab = json.load(f)
69
+
70
+ # Load reverse mapping
71
+ with open("idx_to_char.json", "r") as f:
72
+ idx_to_char = json.load(f)
73
+ ```
74
+
75
+ ## Citation
76
+ ```
77
+ [Citation to be added upon publication]
78
+ ```
79
+
80
+ ## License
81
+ This model is released under CC-BY-NC-4.0 for non-commercial research purposes only.
DASTNUS-Kurdish-ParagraphHTR/config.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "DenseNet121-Transformer",
3
+ "model_type": "custom",
4
+ "task": "handwritten-text-recognition",
5
+ "language": "Kurdish (Central / Sorani)",
6
+ "language_code": "ckb",
7
+ "script": "Kurdish",
8
+ "dataset": "DASTNUS",
9
+ "cnn_backbone": "densenet121",
10
+ "use_upsample": true,
11
+ "hidden_size": 256,
12
+ "num_encoder_layers": 3,
13
+ "num_decoder_layers": 6,
14
+ "num_attention_heads": 8,
15
+ "feed_forward_dim": 2048,
16
+ "dropout_pretrain": 0.3,
17
+ "dropout_finetune": 0.2,
18
+ "vocab_size": 116,
19
+ "max_sequence_length": 555,
20
+ "image_height": 600,
21
+ "image_width": 1235,
22
+ "total_parameters": 22746927,
23
+ "training": {
24
+ "best_epoch": 33,
25
+ "best_val_cer": 0.07456984543598717,
26
+ "best_val_loss": null,
27
+ "pretrain_optimizer": "AdamW",
28
+ "pretrain_lr": 0.0001,
29
+ "finetune_optimizer": "AdamW",
30
+ "finetune_lr": 5e-05,
31
+ "pretrain_scheduler": "StepLR (step=15, gamma=0.5)",
32
+ "finetune_scheduler": "ReduceLROnPlateau (patience=5, factor=0.5)",
33
+ "pretrain_batch_size": 16,
34
+ "finetune_batch_size": 16,
35
+ "pretrain_epochs": 80,
36
+ "finetune_epochs": 80,
37
+ "curriculum_learning": true,
38
+ "teacher_forcing_noise_pretrain": 0.15,
39
+ "teacher_forcing_noise_finetune": 0.05,
40
+ "encoder_freeze_epochs": 10,
41
+ "encoder_lr_multiplier": 0.1
42
+ },
43
+ "performance": {
44
+ "test_cer": 0.0721,
45
+ "test_wer": 0.3624,
46
+ "test_cer_with_lm": 0.0676
47
+ }
48
+ }
DASTNUS-Kurdish-ParagraphHTR/idx_to_char.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "0": "<PAD>",
3
+ "1": "<SOS>",
4
+ "2": "<EOS>",
5
+ "3": "\n",
6
+ "4": " ",
7
+ "5": "!",
8
+ "6": "\"",
9
+ "7": "#",
10
+ "8": "%",
11
+ "9": "&",
12
+ "10": "'",
13
+ "11": "(",
14
+ "12": ")",
15
+ "13": "*",
16
+ "14": "+",
17
+ "15": "-",
18
+ "16": ".",
19
+ "17": "/",
20
+ "18": "0",
21
+ "19": "1",
22
+ "20": "2",
23
+ "21": "4",
24
+ "22": ":",
25
+ "23": ";",
26
+ "24": "=",
27
+ "25": "@",
28
+ "26": "C",
29
+ "27": "D",
30
+ "28": "F",
31
+ "29": "H",
32
+ "30": "P",
33
+ "31": "[",
34
+ "32": "]",
35
+ "33": "_",
36
+ "34": "a",
37
+ "35": "c",
38
+ "36": "d",
39
+ "37": "e",
40
+ "38": "h",
41
+ "39": "m",
42
+ "40": "o",
43
+ "41": "p",
44
+ "42": "s",
45
+ "43": "t",
46
+ "44": "x",
47
+ "45": "{",
48
+ "46": "|",
49
+ "47": "}",
50
+ "48": "×",
51
+ "49": "÷",
52
+ "50": "،",
53
+ "51": "؛",
54
+ "52": "؟",
55
+ "53": "ء",
56
+ "54": "أ",
57
+ "55": "ؤ",
58
+ "56": "ئ",
59
+ "57": "ا",
60
+ "58": "ب",
61
+ "59": "ة",
62
+ "60": "ت",
63
+ "61": "ث",
64
+ "62": "ج",
65
+ "63": "ح",
66
+ "64": "خ",
67
+ "65": "د",
68
+ "66": "ذ",
69
+ "67": "ر",
70
+ "68": "ز",
71
+ "69": "س",
72
+ "70": "ش",
73
+ "71": "ص",
74
+ "72": "ط",
75
+ "73": "ع",
76
+ "74": "غ",
77
+ "75": "ـ",
78
+ "76": "ف",
79
+ "77": "ق",
80
+ "78": "ك",
81
+ "79": "ل",
82
+ "80": "م",
83
+ "81": "ن",
84
+ "82": "ه",
85
+ "83": "و",
86
+ "84": "وو",
87
+ "85": "ى",
88
+ "86": "ي",
89
+ "87": "٠",
90
+ "88": "١",
91
+ "89": "٢",
92
+ "90": "٣",
93
+ "91": "٤",
94
+ "92": "٥",
95
+ "93": "٦",
96
+ "94": "٧",
97
+ "95": "٨",
98
+ "96": "٩",
99
+ "97": "٪",
100
+ "98": "پ",
101
+ "99": "چ",
102
+ "100": "ڕ",
103
+ "101": "ژ",
104
+ "102": "ڤ",
105
+ "103": "ک",
106
+ "104": "گ",
107
+ "105": "ڵ",
108
+ "106": "ھ",
109
+ "107": "ۆ",
110
+ "108": "ی",
111
+ "109": "ێ",
112
+ "110": "۔",
113
+ "111": "ە",
114
+ "112": "‌",
115
+ "113": "‎",
116
+ "114": "‏",
117
+ "115": "–"
118
+ }
DASTNUS-Kurdish-ParagraphHTR/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f528c5e2053930284579e6663c0e945b1ab982f15bccb0b30750c2326e7993f
3
+ size 16012184
DASTNUS-Kurdish-ParagraphHTR/vocab.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "<PAD>": 0,
3
+ "<SOS>": 1,
4
+ "<EOS>": 2,
5
+ "\n": 3,
6
+ " ": 4,
7
+ "!": 5,
8
+ "\"": 6,
9
+ "#": 7,
10
+ "%": 8,
11
+ "&": 9,
12
+ "'": 10,
13
+ "(": 11,
14
+ ")": 12,
15
+ "*": 13,
16
+ "+": 14,
17
+ "-": 15,
18
+ ".": 16,
19
+ "/": 17,
20
+ "0": 18,
21
+ "1": 19,
22
+ "2": 20,
23
+ "4": 21,
24
+ ":": 22,
25
+ ";": 23,
26
+ "=": 24,
27
+ "@": 25,
28
+ "C": 26,
29
+ "D": 27,
30
+ "F": 28,
31
+ "H": 29,
32
+ "P": 30,
33
+ "[": 31,
34
+ "]": 32,
35
+ "_": 33,
36
+ "a": 34,
37
+ "c": 35,
38
+ "d": 36,
39
+ "e": 37,
40
+ "h": 38,
41
+ "m": 39,
42
+ "o": 40,
43
+ "p": 41,
44
+ "s": 42,
45
+ "t": 43,
46
+ "x": 44,
47
+ "{": 45,
48
+ "|": 46,
49
+ "}": 47,
50
+ "×": 48,
51
+ "÷": 49,
52
+ "،": 50,
53
+ "؛": 51,
54
+ "؟": 52,
55
+ "ء": 53,
56
+ "أ": 54,
57
+ "ؤ": 55,
58
+ "ئ": 56,
59
+ "ا": 57,
60
+ "ب": 58,
61
+ "ة": 59,
62
+ "ت": 60,
63
+ "ث": 61,
64
+ "ج": 62,
65
+ "ح": 63,
66
+ "خ": 64,
67
+ "د": 65,
68
+ "ذ": 66,
69
+ "ر": 67,
70
+ "ز": 68,
71
+ "س": 69,
72
+ "ش": 70,
73
+ "ص": 71,
74
+ "ط": 72,
75
+ "ع": 73,
76
+ "غ": 74,
77
+ "ـ": 75,
78
+ "ف": 76,
79
+ "ق": 77,
80
+ "ك": 78,
81
+ "ل": 79,
82
+ "م": 80,
83
+ "ن": 81,
84
+ "ه": 82,
85
+ "و": 83,
86
+ "وو": 84,
87
+ "ى": 85,
88
+ "ي": 86,
89
+ "٠": 87,
90
+ "١": 88,
91
+ "٢": 89,
92
+ "٣": 90,
93
+ "٤": 91,
94
+ "٥": 92,
95
+ "٦": 93,
96
+ "٧": 94,
97
+ "٨": 95,
98
+ "٩": 96,
99
+ "٪": 97,
100
+ "پ": 98,
101
+ "چ": 99,
102
+ "ڕ": 100,
103
+ "ژ": 101,
104
+ "ڤ": 102,
105
+ "ک": 103,
106
+ "گ": 104,
107
+ "ڵ": 105,
108
+ "ھ": 106,
109
+ "ۆ": 107,
110
+ "ی": 108,
111
+ "ێ": 109,
112
+ "۔": 110,
113
+ "ە": 111,
114
+ "‌": 112,
115
+ "‎": 113,
116
+ "‏": 114,
117
+ "–": 115
118
+ }
KHATT-Arabic-ParagraphHTR/README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - ar
4
+ license: cc-by-nc-4.0
5
+ tags:
6
+ - handwritten-text-recognition
7
+ - paragraph-recognition
8
+ - ar
9
+ - densenet
10
+ - transformer
11
+ - pytorch
12
+ - safetensors
13
+ datasets:
14
+ - KHATT
15
+ metrics:
16
+ - cer
17
+ - wer
18
+ pipeline_tag: image-to-text
19
+ ---
20
+
21
+ # KHATT-Arabic: DenseNet121-Transformer Paragraph HTR
22
+
23
+ ## Model Description
24
+ Arabic handwritten paragraph recognition model evaluated on the KHATT dataset for cross-script generalisation accessed through: https://www.kaggle.com/datasets/iraqyomar/khatt-arabic-hand-written-lines/code. Pre-trained on 12,000 synthetic paragraphs combining KHATT Arabic lines with Kurdish lines from DASTNUS, then fine-tuned on 1,193 reconstructed KHATT paragraphs. Achieves CER of 0.1394, surpassing a reimplemented state-of-the-art baseline under identical conditions.
25
+
26
+ ## Architecture
27
+ - **CNN Backbone:** DenseNet-121 (pretrained on ImageNet)
28
+ - **Horizontal Upsample:** Yes
29
+ - **Encoder:** 3 Transformer encoder layers
30
+ - **Decoder:** 6 Transformer decoder layers
31
+ - **Attention Heads:** 8
32
+ - **Hidden Size:** 256
33
+ - **Feed-Forward Dim:** 2048
34
+ - **Vocabulary Size:** 143
35
+ - **Parameters:** 22,760,778
36
+
37
+ ## Performance on KHATT
38
+ | Metric | Value |
39
+ |--------|-------|
40
+ | CER (greedy) | 0.1394 |
41
+ | WER (greedy) | 0.5075 |
42
+
43
+ ## Input Format
44
+ - **Image size:** 600 x 1235 pixels
45
+ - **Preprocessing:** Aspect-ratio-preserving resize, right-aligned on white canvas (RTL)
46
+ - **Normalization:** ImageNet mean/std
47
+
48
+ ## Training
49
+ - **Pre-training:** 12,000 synthetic paragraph images with curriculum learning
50
+ - **Fine-tuning:** Real handwritten paragraphs from KHATT
51
+ - **Two-stage strategy:** Encoder frozen for first 10 epochs during fine-tuning
52
+
53
+ ## Usage
54
+ ```python
55
+ from safetensors.torch import load_file
56
+ import json
57
+
58
+ # Load model weights
59
+ state_dict = load_file("model.safetensors")
60
+
61
+ # Load config
62
+ with open("config.json", "r") as f:
63
+ config = json.load(f)
64
+
65
+ # Load vocabulary
66
+ with open("vocab.json", "r") as f:
67
+ vocab = json.load(f)
68
+
69
+ # Load reverse mapping
70
+ with open("idx_to_char.json", "r") as f:
71
+ idx_to_char = json.load(f)
72
+ ```
73
+
74
+ ## Citation
75
+ ```
76
+ [Citation to be added upon publication]
77
+ ```
78
+
79
+ ## License
80
+ This model is released under CC-BY-NC-4.0 for non-commercial research purposes only.
KHATT-Arabic-ParagraphHTR/config.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "DenseNet121-Transformer",
3
+ "model_type": "custom",
4
+ "task": "handwritten-text-recognition",
5
+ "language": "Arabic",
6
+ "language_code": "ar",
7
+ "script": "Arabic",
8
+ "dataset": "KHATT",
9
+ "cnn_backbone": "densenet121",
10
+ "use_upsample": true,
11
+ "hidden_size": 256,
12
+ "num_encoder_layers": 3,
13
+ "num_decoder_layers": 6,
14
+ "num_attention_heads": 8,
15
+ "feed_forward_dim": 2048,
16
+ "dropout_pretrain": 0.3,
17
+ "dropout_finetune": 0.2,
18
+ "vocab_size": 143,
19
+ "max_sequence_length": 555,
20
+ "image_height": 600,
21
+ "image_width": 1235,
22
+ "total_parameters": 22760778,
23
+ "training": {
24
+ "best_epoch": 65,
25
+ "best_val_cer": 0.11814673883916608,
26
+ "best_val_loss": null,
27
+ "pretrain_optimizer": "AdamW",
28
+ "pretrain_lr": 0.0001,
29
+ "finetune_optimizer": "AdamW",
30
+ "finetune_lr": 5e-05,
31
+ "pretrain_scheduler": "StepLR (step=15, gamma=0.5)",
32
+ "finetune_scheduler": "ReduceLROnPlateau (patience=5, factor=0.5)",
33
+ "pretrain_batch_size": 16,
34
+ "finetune_batch_size": 16,
35
+ "pretrain_epochs": 80,
36
+ "finetune_epochs": 80,
37
+ "curriculum_learning": true,
38
+ "teacher_forcing_noise_pretrain": 0.15,
39
+ "teacher_forcing_noise_finetune": 0.05,
40
+ "encoder_freeze_epochs": 10,
41
+ "encoder_lr_multiplier": 0.1
42
+ },
43
+ "performance": {
44
+ "test_cer": 0.1394,
45
+ "test_wer": 0.5075,
46
+ "test_cer_with_lm": null
47
+ }
48
+ }
KHATT-Arabic-ParagraphHTR/idx_to_char.json ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "0": "<PAD>",
3
+ "1": "<SOS>",
4
+ "2": "<EOS>",
5
+ "3": "\n",
6
+ "4": " ",
7
+ "5": "!",
8
+ "6": "\"",
9
+ "7": "#",
10
+ "8": "$",
11
+ "9": "%",
12
+ "10": "&",
13
+ "11": "'",
14
+ "12": "(",
15
+ "13": ")",
16
+ "14": "*",
17
+ "15": "+",
18
+ "16": ",",
19
+ "17": "-",
20
+ "18": ".",
21
+ "19": "/",
22
+ "20": "0",
23
+ "21": "1",
24
+ "22": "2",
25
+ "23": "3",
26
+ "24": "4",
27
+ "25": "5",
28
+ "26": "6",
29
+ "27": "7",
30
+ "28": "8",
31
+ "29": "9",
32
+ "30": ":",
33
+ "31": ";",
34
+ "32": "=",
35
+ "33": ">",
36
+ "34": "?",
37
+ "35": "@",
38
+ "36": "A",
39
+ "37": "C",
40
+ "38": "D",
41
+ "39": "F",
42
+ "40": "H",
43
+ "41": "I",
44
+ "42": "M",
45
+ "43": "P",
46
+ "44": "X",
47
+ "45": "[",
48
+ "46": "\\",
49
+ "47": "]",
50
+ "48": "_",
51
+ "49": "a",
52
+ "50": "c",
53
+ "51": "d",
54
+ "52": "e",
55
+ "53": "h",
56
+ "54": "m",
57
+ "55": "n",
58
+ "56": "o",
59
+ "57": "p",
60
+ "58": "s",
61
+ "59": "t",
62
+ "60": "x",
63
+ "61": "}",
64
+ "62": " ",
65
+ "63": "×",
66
+ "64": "÷",
67
+ "65": "،",
68
+ "66": "؛",
69
+ "67": "؟",
70
+ "68": "ء",
71
+ "69": "آ",
72
+ "70": "أ",
73
+ "71": "ؤ",
74
+ "72": "إ",
75
+ "73": "ئ",
76
+ "74": "ا",
77
+ "75": "ب",
78
+ "76": "ة",
79
+ "77": "ت",
80
+ "78": "ث",
81
+ "79": "ج",
82
+ "80": "ح",
83
+ "81": "خ",
84
+ "82": "د",
85
+ "83": "ذ",
86
+ "84": "ر",
87
+ "85": "ز",
88
+ "86": "س",
89
+ "87": "ش",
90
+ "88": "ص",
91
+ "89": "ض",
92
+ "90": "ط",
93
+ "91": "ظ",
94
+ "92": "ع",
95
+ "93": "غ",
96
+ "94": "ـ",
97
+ "95": "ف",
98
+ "96": "ق",
99
+ "97": "ك",
100
+ "98": "ل",
101
+ "99": "م",
102
+ "100": "ن",
103
+ "101": "ه",
104
+ "102": "و",
105
+ "103": "ى",
106
+ "104": "ي",
107
+ "105": "ً",
108
+ "106": "ٌ",
109
+ "107": "ٍ",
110
+ "108": "َ",
111
+ "109": "ُ",
112
+ "110": "ِ",
113
+ "111": "ّ",
114
+ "112": "ْ",
115
+ "113": "٠",
116
+ "114": "١",
117
+ "115": "٢",
118
+ "116": "٣",
119
+ "117": "٤",
120
+ "118": "٥",
121
+ "119": "٦",
122
+ "120": "٧",
123
+ "121": "٨",
124
+ "122": "٩",
125
+ "123": "٪",
126
+ "124": "پ",
127
+ "125": "چ",
128
+ "126": "ڕ",
129
+ "127": "ژ",
130
+ "128": "ڤ",
131
+ "129": "ک",
132
+ "130": "گ",
133
+ "131": "ڵ",
134
+ "132": "ھ",
135
+ "133": "ۆ",
136
+ "134": "ی",
137
+ "135": "ێ",
138
+ "136": "۔",
139
+ "137": "ە",
140
+ "138": "‌",
141
+ "139": "‎",
142
+ "140": "‏",
143
+ "141": "–",
144
+ "142": "‘"
145
+ }
KHATT-Arabic-ParagraphHTR/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f528c5e2053930284579e6663c0e945b1ab982f15bccb0b30750c2326e7993f
3
+ size 16012184
KHATT-Arabic-ParagraphHTR/vocab.json ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "<PAD>": 0,
3
+ "<SOS>": 1,
4
+ "<EOS>": 2,
5
+ "\n": 3,
6
+ " ": 4,
7
+ "!": 5,
8
+ "\"": 6,
9
+ "#": 7,
10
+ "$": 8,
11
+ "%": 9,
12
+ "&": 10,
13
+ "'": 11,
14
+ "(": 12,
15
+ ")": 13,
16
+ "*": 14,
17
+ "+": 15,
18
+ ",": 16,
19
+ "-": 17,
20
+ ".": 18,
21
+ "/": 19,
22
+ "0": 20,
23
+ "1": 21,
24
+ "2": 22,
25
+ "3": 23,
26
+ "4": 24,
27
+ "5": 25,
28
+ "6": 26,
29
+ "7": 27,
30
+ "8": 28,
31
+ "9": 29,
32
+ ":": 30,
33
+ ";": 31,
34
+ "=": 32,
35
+ ">": 33,
36
+ "?": 34,
37
+ "@": 35,
38
+ "A": 36,
39
+ "C": 37,
40
+ "D": 38,
41
+ "F": 39,
42
+ "H": 40,
43
+ "I": 41,
44
+ "M": 42,
45
+ "P": 43,
46
+ "X": 44,
47
+ "[": 45,
48
+ "\\": 46,
49
+ "]": 47,
50
+ "_": 48,
51
+ "a": 49,
52
+ "c": 50,
53
+ "d": 51,
54
+ "e": 52,
55
+ "h": 53,
56
+ "m": 54,
57
+ "n": 55,
58
+ "o": 56,
59
+ "p": 57,
60
+ "s": 58,
61
+ "t": 59,
62
+ "x": 60,
63
+ "}": 61,
64
+ " ": 62,
65
+ "×": 63,
66
+ "÷": 64,
67
+ "،": 65,
68
+ "؛": 66,
69
+ "؟": 67,
70
+ "ء": 68,
71
+ "آ": 69,
72
+ "أ": 70,
73
+ "ؤ": 71,
74
+ "إ": 72,
75
+ "ئ": 73,
76
+ "ا": 74,
77
+ "ب": 75,
78
+ "ة": 76,
79
+ "ت": 77,
80
+ "ث": 78,
81
+ "ج": 79,
82
+ "ح": 80,
83
+ "خ": 81,
84
+ "د": 82,
85
+ "ذ": 83,
86
+ "ر": 84,
87
+ "ز": 85,
88
+ "س": 86,
89
+ "ش": 87,
90
+ "ص": 88,
91
+ "ض": 89,
92
+ "ط": 90,
93
+ "ظ": 91,
94
+ "ع": 92,
95
+ "غ": 93,
96
+ "ـ": 94,
97
+ "ف": 95,
98
+ "ق": 96,
99
+ "ك": 97,
100
+ "ل": 98,
101
+ "م": 99,
102
+ "ن": 100,
103
+ "ه": 101,
104
+ "و": 102,
105
+ "ى": 103,
106
+ "ي": 104,
107
+ "ً": 105,
108
+ "ٌ": 106,
109
+ "ٍ": 107,
110
+ "َ": 108,
111
+ "ُ": 109,
112
+ "ِ": 110,
113
+ "ّ": 111,
114
+ "ْ": 112,
115
+ "٠": 113,
116
+ "١": 114,
117
+ "٢": 115,
118
+ "٣": 116,
119
+ "٤": 117,
120
+ "٥": 118,
121
+ "٦": 119,
122
+ "٧": 120,
123
+ "٨": 121,
124
+ "٩": 122,
125
+ "٪": 123,
126
+ "پ": 124,
127
+ "چ": 125,
128
+ "ڕ": 126,
129
+ "ژ": 127,
130
+ "ڤ": 128,
131
+ "ک": 129,
132
+ "گ": 130,
133
+ "ڵ": 131,
134
+ "ھ": 132,
135
+ "ۆ": 133,
136
+ "ی": 134,
137
+ "ێ": 135,
138
+ "۔": 136,
139
+ "ە": 137,
140
+ "‌": 138,
141
+ "‎": 139,
142
+ "‏": 140,
143
+ "–": 141,
144
+ "‘": 142
145
+ }
README.md ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ language:
4
+ - ckb
5
+ - ar
6
+ tags:
7
+ - handwritten-text-recognition
8
+ - paragraph-recognition
9
+ - kurdish
10
+ - arabic
11
+ - densenet
12
+ - transformer
13
+ - pytorch
14
+ - safetensors
15
+ pipeline_tag: image-to-text
16
+ ---
17
+ # ETE-KHPR: End-to-End Kurdish Handwritten Paragraph Recognition
18
+ ### A DenseNet121-Transformer Architecture with Synthetic Paragraph Generation
19
+
20
+ This repository contains the source code, trained models, and vocabularies for end-to-end Kurdish handwritten paragraph recognition without explicit line segmentation, with cross-script evaluation on Arabic (KHATT) and cross-dataset transfer to an external Kurdish dataset (DASNUS).
21
+
22
+ ---
23
+
24
+ ## Repository Structure
25
+
26
+ ```
27
+ KHPR/
28
+ ├── DASTNUS-Kurdish-ParagraphHTR/ # Best Kurdish paragraph model
29
+ │ ├── model.safetensors # Model weights
30
+ │ ├── config.json # Architecture configuration
31
+ │ ├── vocab.json # Character vocabulary (char → index)
32
+ │ ├── idx_to_char.json # Reverse vocabulary (index → char)
33
+ │ └── README.md # Model card
34
+
35
+ ├── DASNUS-Kurdish-ParagraphHTR/ # Model fine-tuned on external Kurdish dataset
36
+ │ ├── model.safetensors
37
+ │ ├── config.json
38
+ │ ├── vocab.json
39
+ │ ├── idx_to_char.json
40
+ │ └── README.md
41
+
42
+ ├── KHATT-Arabic-ParagraphHTR/ # Model fine-tuned on KHATT Arabic dataset
43
+ │ ├── model.safetensors
44
+ │ ├── config.json
45
+ │ ├── vocab.json # KHATT Arabic vocabulary (143 tokens)
46
+ │ ├── idx_to_char.json
47
+ │ └── README.md
48
+
49
+ ├── Scripts/
50
+ │ ├── pretrain.py # Pre-training on synthetic paragraphs
51
+ │ ├── finetune.py # Fine-tuning on real handwritten paragraphs
52
+ │ ├── inference.py # Single image and batch inference
53
+ │ └── generate_paragraphs.py # Synthetic paragraph generation
54
+
55
+ ├── Sample/
56
+ │ ├── sample_paragraph.tif # Example Kurdish handwritten paragraph
57
+ │ └── sample_paragraph.txt # Corresponding ground truth
58
+
59
+ ├── requirements.txt
60
+ └── README.md
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Architecture
66
+
67
+ | Component | Details |
68
+ |-----------|---------|
69
+ | CNN Backbone | DenseNet-121 (ImageNet pre-trained) |
70
+ | Encoder | 3 Transformer encoder layers |
71
+ | Decoder | 6 Transformer decoder layers |
72
+ | Attention Heads | 8 |
73
+ | Hidden Size | 256 |
74
+ | Feed-Forward Dim | 2048 |
75
+ | Positional Encoding | 2D sinusoidal (encoder) + 1D sinusoidal (decoder) |
76
+ | Total Parameters | 22.7M |
77
+
78
+ The model processes full paragraph images end-to-end and outputs the complete multi-line text, including line break positions, without any explicit line segmentation.
79
+
80
+ ---
81
+
82
+ ## Performance
83
+
84
+ ### Kurdish — DASTNUS Unique Handwritten Paragraphs
85
+
86
+ | Decoding Strategy | CER | WER | CRR (%) | WRR (%) |
87
+ |---|---|---|---|---|
88
+ | Greedy | 0.0721 | 0.3624 | 92.79 | 63.76 |
89
+ | Beam-10 | 0.0706 | 0.3580 | 92.94 | 64.20 |
90
+ | Beam-10 + 8-gram LM (w=0.6) | 0.0676 | 0.3422 | 93.24 | 65.78 |
91
+ | Beam-10 + RoBERTa (w=0.1) | 0.0680 | 0.3484 | 93.20 | 65.16 |
92
+
93
+ ### Cross-Script Evaluation — KHATT Arabic Handwritten Paragraphs
94
+
95
+ | Model | CER | WER | CRR (%) |
96
+ |---|---|---|---|
97
+ | Proposed | 0.1394 | 0.5075 | 86.06 |
98
+ | MSdocTr-Lite (reimplemented, same conditions) | 0.1622 | 0.5227 | 83.78 |
99
+
100
+ ### Cross-Dataset Transfer — DASNUS External Kurdish Dataset
101
+
102
+ | Setting | Training Samples | CER | WER | CRR (%) |
103
+ |---|---|---|---|---|
104
+ | Zero-shot | 0 | 0.2257 | 0.6206 | 77.43 |
105
+ | Few-shot 10% | 184 | 0.1535 | 0.4757 | 84.65 |
106
+ | Few-shot 50% | 922 | 0.1034 | 0.3609 | 89.66 |
107
+ | Full fine-tune | 1,843 | 0.0856 | 0.3148 | 91.44 |
108
+
109
+ ---
110
+
111
+ ## Installation
112
+
113
+ ```bash
114
+ git clone https://huggingface.co/karez/KHPR
115
+ cd KHPR
116
+ pip install -r requirements.txt
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Quick Start
122
+
123
+ ### Inference
124
+
125
+ ```bash
126
+ # Single paragraph image (with config auto-load)
127
+ python Scripts/inference.py \
128
+ --image Sample/sample_paragraph.tif \
129
+ --model_path DASTNUS-Kurdish-ParagraphHTR/model.safetensors \
130
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
131
+ --config_path DASTNUS-Kurdish-ParagraphHTR/config.json
132
+
133
+ # Directory of images with timing
134
+ python Scripts/inference.py \
135
+ --image_dir ./test_paragraphs \
136
+ --model_path DASTNUS-Kurdish-ParagraphHTR/model.safetensors \
137
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
138
+ --config_path DASTNUS-Kurdish-ParagraphHTR/config.json \
139
+ --show_timing \
140
+ --output_file predictions.txt
141
+
142
+ # Arabic model (KHATT)
143
+ python Scripts/inference.py \
144
+ --image Sample/arabic_paragraph.tif \
145
+ --model_path KHATT-Arabic-ParagraphHTR/model.safetensors \
146
+ --vocab_path KHATT-Arabic-ParagraphHTR/vocab.json \
147
+ --config_path KHATT-Arabic-ParagraphHTR/config.json
148
+ ```
149
+
150
+ ### Synthetic Paragraph Generation
151
+
152
+ ```bash
153
+ # Full three-source generation (best configuration)
154
+ python Scripts/generate_paragraphs.py \
155
+ --unique_train_dir ./data/UniqueLines/Training \
156
+ --fixed_train_dir ./data/FixedLines/Training \
157
+ --synthetic_train_dir ./data/SyntheticLines/Training \
158
+ --unique_val_dir ./data/UniqueLines/Validation \
159
+ --fixed_val_dir ./data/FixedLines/Validation \
160
+ --synthetic_val_dir ./data/SyntheticLines/Validation \
161
+ --output_dir ./SyntheticParagraphs_12000 \
162
+ --dataset_size 12000
163
+ ```
164
+
165
+ ### Pre-training
166
+
167
+ ```bash
168
+ # Pre-train on synthetic paragraphs (Kurdish, default settings)
169
+ python Scripts/pretrain.py \
170
+ --data_dir ./SyntheticParagraphs_12000 \
171
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
172
+ --output_dir ./output \
173
+ --model_name pretrained_kurdish
174
+
175
+ # Pre-train without curriculum learning
176
+ python Scripts/pretrain.py \
177
+ --data_dir ./SyntheticParagraphs_12000 \
178
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
179
+ --no_curriculum
180
+ ```
181
+
182
+ ### Fine-tuning
183
+
184
+ ```bash
185
+ # Fine-tune on DASTNUS unique handwritten paragraphs
186
+ python Scripts/finetune.py \
187
+ --data_dir ./data/UniqueHandwrittenParagraphs \
188
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
189
+ --pretrained_path ./output/pretrained_kurdish.pth \
190
+ --output_dir ./output \
191
+ --model_name finetuned_dastnus
192
+
193
+ # Fine-tune on DASNUS external Kurdish dataset
194
+ python Scripts/finetune.py \
195
+ --data_dir ./data/DASNUS-Paragraphs \
196
+ --vocab_path DASTNUS-Kurdish-ParagraphHTR/vocab.json \
197
+ --pretrained_path ./output/pretrained_kurdish.pth \
198
+ --output_dir ./output \
199
+ --model_name finetuned_dasnus
200
+
201
+ # Fine-tune on KHATT Arabic dataset
202
+ python Scripts/finetune.py \
203
+ --data_dir ./data/KHATT-Paragraphs \
204
+ --vocab_path KHATT-Arabic-ParagraphHTR/vocab.json \
205
+ --pretrained_path ./output/pretrained_khatt.pth \
206
+ --output_dir ./output \
207
+ --model_name finetuned_khatt
208
+ ```
209
+ ---
210
+
211
+ ## Training Data
212
+
213
+ ### DASTNUS and DASNUS Models
214
+
215
+ | Data Source | Training | Validation | Testing |
216
+ |---|---|---|---|
217
+ | Unique handwritten paragraphs | 710 | 144 | 144 |
218
+ | Synthetic paragraphs (pre-training) | 10,200 | 1,800 | — |
219
+
220
+ Synthetic paragraphs were generated from DASTNUS line sources using the `generate_paragraphs.py` script, combining unique handwritten lines, Fixed handwrwritten lines and recipe-based synthetic handwritten lines with single-writer consistency, zero duplicate text orderings, and source-level isolation between splits.
221
+
222
+ ### KHATT Model
223
+
224
+ | Data Source | Training | Validation | Testing |
225
+ |---|---|---|---|
226
+ | Reconstructed KHATT paragraphs | 1,193 | 144 | 150 |
227
+ | Synthetic paragraphs (pre-training) | 10,201 | 1,199 | — |
228
+
229
+ Synthetic paragraphs for KHATT pre-training were generated by combining KHATT handwritten lines with Kurdish line sources from DASTNUS to provide richer visual diversity across handwriting styles within the same Arabic script family.
230
+
231
+ ---
232
+
233
+ ## Hardware
234
+
235
+ Experiments were conducted on a workstation equipped with an Intel Core i9-14900K processor, 128 GB RAM, and an NVIDIA GeForce RTX 5090 GPU with 32 GB VRAM.
236
+
237
+ ---
238
+
239
+ ## Citation
240
+
241
+ ```bibtex
242
+ []
243
+ ```
244
+
245
+ ---
246
+
247
+ ## License
248
+
249
+ This repository is released for non-commercial scientific research purposes only under the CC-BY-NC-4.0 license. The data used in this research is available upon request for non-commercial scientific research purposes only.
Sample/sample_paragraph.tif ADDED

Git LFS Details

  • SHA256: 1bdde89b4360311ec5537edaede2ba57a254667e150bbff6729decf178f8d4d7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.2 MB
Sample/sample_paragraph.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ قالبى ئامادەكراو :. بریتییە لە كۆمەلە ڤالبێكى ئامادەكراو كە كۆمپانیاى مایكرۆسۆفت
2
+ ئاماد.ى كردوو. ، بر كارئاسانى بو بەكارهێنەر بەكاردێت بۆ كردارى ژمیریارى
3
+ ئامار و چارت هەرو.ها بۆ كۆمەڵێك كارى بازرگانى بەمەبەستی دابەزاندز
4
+ قالبى تر دەبێت . كۆمپیوتەرەكە بەسترابێت بەهێلى ئەنترزێتەوە . .
Scripts/finetune.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Kurdish Handwritten Paragraph Recognition - Fine-tuning Script
3
+ DenseNet121-Transformer Architecture
4
+
5
+ Fine-tunes a pre-trained model on real handwritten paragraph images.
6
+ Loads weights from pretrain.py output checkpoint.
7
+
8
+ Usage:
9
+ python finetune.py --data_dir ./data/UniqueHandwrittenParagraphs \
10
+ --vocab_path ./vocab.json \
11
+ --pretrained_path ./output/pretrained_model.pth
12
+
13
+ python finetune.py --data_dir ./data/DASNUS-Paragraphs \
14
+ --vocab_path ./vocab.json \
15
+ --pretrained_path ./output/pretrained_model.pth \
16
+ --freeze_epochs 10
17
+ """
18
+
19
+ import os
20
+ import glob
21
+ import time
22
+ import argparse
23
+ import json
24
+ import math
25
+ import random
26
+ import re
27
+ import numpy as np
28
+ from PIL import Image
29
+ from datetime import datetime
30
+
31
+ import torch
32
+ import torch.nn as nn
33
+ import torch.optim as optim
34
+ import torch.utils.data as data
35
+ import torchvision.transforms as transforms
36
+ import torchvision.models as models
37
+ from torchvision.transforms import InterpolationMode
38
+ from torch.nn import functional as F
39
+ from torch.amp import autocast, GradScaler
40
+ from tqdm import tqdm
41
+ import gc
42
+
43
+
44
+ # ===============================
45
+ # Argument Parser
46
+ # ===============================
47
+
48
+ def parse_args():
49
+ parser = argparse.ArgumentParser(
50
+ description="Kurdish Handwritten Paragraph Recognition - Fine-tuning")
51
+
52
+ # Data paths
53
+ parser.add_argument("--data_dir", type=str, required=True,
54
+ help="Root directory with Training/, Validation/, Testing/ subfolders")
55
+ parser.add_argument("--vocab_path", type=str, required=True,
56
+ help="Path to vocabulary JSON file (vocab.json)")
57
+ parser.add_argument("--pretrained_path", type=str, required=True,
58
+ help="Path to pre-trained model checkpoint (.pth)")
59
+
60
+ # Image dimensions
61
+ parser.add_argument("--img_height", type=int, default=600)
62
+ parser.add_argument("--img_width", type=int, default=1235)
63
+ parser.add_argument("--max_seq_len", type=int, default=555)
64
+
65
+ # Training hyperparameters
66
+ parser.add_argument("--batch_size", type=int, default=16)
67
+ parser.add_argument("--num_epochs", type=int, default=80)
68
+ parser.add_argument("--learning_rate", type=float, default=5e-5)
69
+ parser.add_argument("--grad_clip", type=float, default=5.0)
70
+ parser.add_argument("--weight_decay", type=float, default=1e-4)
71
+ parser.add_argument("--seed", type=int, default=42)
72
+
73
+ # Model architecture (must match pre-trained model)
74
+ parser.add_argument("--hidden_size", type=int, default=256)
75
+ parser.add_argument("--encoder_layers", type=int, default=3)
76
+ parser.add_argument("--decoder_layers", type=int, default=6)
77
+ parser.add_argument("--num_heads", type=int, default=8)
78
+ parser.add_argument("--ff_dim", type=int, default=2048)
79
+ parser.add_argument("--dropout", type=float, default=0.2)
80
+ parser.add_argument("--use_upsample", action="store_true", default=True,
81
+ help="Enable horizontal upsampling layer (default: True)")
82
+ parser.add_argument("--no_upsample", action="store_true",
83
+ help="Disable horizontal upsampling layer")
84
+
85
+ # Teacher forcing
86
+ parser.add_argument("--tf_noise_rate", type=float, default=0.05,
87
+ help="Teacher forcing noise rate (default: 0.05)")
88
+
89
+ # Encoder freezing
90
+ parser.add_argument("--freeze_epochs", type=int, default=10,
91
+ help="Number of epochs to freeze CNN encoder (default: 10)")
92
+ parser.add_argument("--encoder_lr_mult", type=float, default=0.1,
93
+ help="Learning rate multiplier for encoder (default: 0.1)")
94
+
95
+ # LR scheduler
96
+ parser.add_argument("--lr_patience", type=int, default=5,
97
+ help="ReduceLROnPlateau patience")
98
+ parser.add_argument("--lr_factor", type=float, default=0.5,
99
+ help="ReduceLROnPlateau factor")
100
+
101
+ # Early stopping
102
+ parser.add_argument("--patience", type=int, default=15)
103
+
104
+ # Training options
105
+ parser.add_argument("--mixed_precision", action="store_true", default=True)
106
+ parser.add_argument("--no_mixed_precision", action="store_true")
107
+ parser.add_argument("--no_aug", action="store_true",
108
+ help="Disable data augmentation")
109
+ parser.add_argument("--clean_text", action="store_true", default=True,
110
+ help="Clean invisible Unicode characters from labels")
111
+ parser.add_argument("--no_clean_text", action="store_true")
112
+
113
+ # CER computation
114
+ parser.add_argument("--cer_every", type=int, default=5,
115
+ help="Compute train CER every N epochs (0 to disable)")
116
+ parser.add_argument("--cer_max_samples", type=int, default=256,
117
+ help="Max samples for train CER computation")
118
+
119
+ # Output
120
+ parser.add_argument("--output_dir", type=str, default="./output",
121
+ help="Directory to save model and logs")
122
+ parser.add_argument("--model_name", type=str, default="finetuned_model",
123
+ help="Base name for saved model file")
124
+
125
+ return parser.parse_args()
126
+
127
+
128
+ # ===============================
129
+ # Vocabulary Loader
130
+ # ===============================
131
+
132
+ def load_vocabulary(vocab_path):
133
+ """Load vocabulary from JSON file."""
134
+ with open(vocab_path, "r", encoding="utf-8") as f:
135
+ vocab_data = json.load(f)
136
+
137
+ if "vocab_list" in vocab_data:
138
+ char_list = vocab_data["vocab_list"]
139
+ elif "char_to_idx" in vocab_data:
140
+ mapping = vocab_data["char_to_idx"]
141
+ char_list = [None] * len(mapping)
142
+ for char, idx in mapping.items():
143
+ char_list[idx] = char
144
+ else:
145
+ raise ValueError("Vocabulary JSON must contain 'vocab_list' or 'char_to_idx'")
146
+
147
+ char_to_idx = {char: idx for idx, char in enumerate(char_list)}
148
+ idx_to_char = {idx: char for idx, char in enumerate(char_list)}
149
+
150
+ return char_list, char_to_idx, idx_to_char
151
+
152
+
153
+ # Special token indices
154
+ PAD_TOKEN = 0
155
+ SOS_TOKEN = 1
156
+ EOS_TOKEN = 2
157
+
158
+
159
+ # ===============================
160
+ # Text Cleaning
161
+ # ===============================
162
+
163
+ INVISIBLE_CHARS = [
164
+ '\u200e', '\u200f', '\u200b', '\u200d', '\ufeff', '\u00ad',
165
+ '\u2060', '\u2061', '\u2062', '\u2063', '\u2064',
166
+ '\u206a', '\u206b', '\u206c', '\u206d', '\u206e', '\u206f',
167
+ '\u2028', '\u2029',
168
+ ]
169
+
170
+
171
+ def clean_text(text):
172
+ """Remove invisible Unicode characters that inflate CER.
173
+ Preserves ZWNJ (U+200C) which is used in Kurdish."""
174
+ for char in INVISIBLE_CHARS:
175
+ if char != '\u200c': # Keep ZWNJ
176
+ text = text.replace(char, '')
177
+ text = re.sub(r' +', ' ', text)
178
+ lines = text.split('\n')
179
+ lines = [line.strip() for line in lines]
180
+ return '\n'.join(lines)
181
+
182
+
183
+ # ===============================
184
+ # Helper Functions
185
+ # ===============================
186
+
187
+ def tensor_to_text(tensor, idx_to_char):
188
+ """Convert a tensor of character indices to text."""
189
+ if isinstance(tensor, torch.Tensor):
190
+ tensor = tensor.cpu().tolist()
191
+ text = ""
192
+ for idx in tensor:
193
+ if idx == PAD_TOKEN or idx == SOS_TOKEN:
194
+ continue
195
+ if idx == EOS_TOKEN:
196
+ break
197
+ if idx in idx_to_char:
198
+ text += idx_to_char[idx]
199
+ return text
200
+
201
+
202
+ # ===============================
203
+ # Dataset
204
+ # ===============================
205
+
206
+ class KurdishParagraphDataset(data.Dataset):
207
+ """Dataset for Kurdish handwritten paragraph images."""
208
+
209
+ def __init__(self, root_dir, transform=None, max_seq_len=555,
210
+ img_height=600, img_width=1235, char_to_idx=None,
211
+ clean_text_enabled=True):
212
+ self.transform = transform
213
+ self.max_seq_len = max_seq_len
214
+ self.img_height = img_height
215
+ self.img_width = img_width
216
+ self.char_to_idx = char_to_idx
217
+ self.clean_text_enabled = clean_text_enabled
218
+
219
+ self.data = []
220
+ image_files = []
221
+ for ext in ["*.tif", "*.tiff", "*.png", "*.jpg", "*.jpeg"]:
222
+ image_files.extend(glob.glob(os.path.join(root_dir, ext)))
223
+ image_files.extend(glob.glob(os.path.join(root_dir, ext.upper())))
224
+ image_files = sorted(list(set(image_files)))
225
+
226
+ for img_path in image_files:
227
+ label_path = os.path.splitext(img_path)[0] + ".txt"
228
+ if not os.path.exists(label_path):
229
+ continue
230
+ try:
231
+ with open(label_path, "r", encoding="utf-8") as f:
232
+ text = f.read().strip()
233
+ except Exception:
234
+ try:
235
+ with open(label_path, "r", encoding="utf-8-sig") as f:
236
+ text = f.read().strip()
237
+ except Exception:
238
+ continue
239
+
240
+ if self.clean_text_enabled:
241
+ text = clean_text(text)
242
+ if len(text) > 0:
243
+ self.data.append((img_path, text))
244
+
245
+ print(f" Loaded {len(self.data)} paragraph images from {root_dir}")
246
+
247
+ def __len__(self):
248
+ return len(self.data)
249
+
250
+ def __getitem__(self, idx):
251
+ img_path, text = self.data[idx]
252
+
253
+ image = Image.open(img_path).convert("RGB")
254
+ orig_width, orig_height = image.size
255
+
256
+ scale = min(self.img_width / orig_width, self.img_height / orig_height)
257
+ new_width = int(orig_width * scale)
258
+ new_height = int(orig_height * scale)
259
+ image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
260
+
261
+ canvas = Image.new('RGB', (self.img_width, self.img_height), (255, 255, 255))
262
+ x_offset = self.img_width - new_width # Right-align for RTL
263
+ canvas.paste(image, (x_offset, 0))
264
+
265
+ if self.transform:
266
+ canvas = self.transform(canvas)
267
+
268
+ indices = ([SOS_TOKEN] +
269
+ [self.char_to_idx.get(c, self.char_to_idx.get(" ", 0)) for c in text] +
270
+ [EOS_TOKEN])
271
+ if len(indices) > self.max_seq_len:
272
+ indices = indices[:self.max_seq_len - 1] + [EOS_TOKEN]
273
+
274
+ target = torch.LongTensor(indices)
275
+ return canvas, target, len(indices), text
276
+
277
+
278
+ def collate_fn(batch):
279
+ """Collate function with padding for variable-length targets."""
280
+ batch.sort(key=lambda x: x[2], reverse=True)
281
+ images, targets, lengths, texts = zip(*batch)
282
+
283
+ images = torch.stack(images, 0)
284
+ max_length = max(lengths)
285
+
286
+ padded = torch.ones(len(targets), max_length).long() * PAD_TOKEN
287
+ for i, target in enumerate(targets):
288
+ padded[i, :lengths[i]] = target[:lengths[i]]
289
+
290
+ return images, padded, torch.LongTensor(lengths), texts
291
+
292
+
293
+ # ===============================
294
+ # Augmentation
295
+ # ===============================
296
+
297
+ def build_train_transform():
298
+ """Standard augmentation for fine-tuning."""
299
+ class FinetuneTransform:
300
+ def __call__(self, img):
301
+ if random.random() < 0.5:
302
+ img = transforms.ColorJitter(
303
+ brightness=0.15, contrast=0.15,
304
+ saturation=0.05, hue=0.01)(img)
305
+
306
+ if random.random() < 0.4:
307
+ img = transforms.RandomAffine(
308
+ degrees=2, translate=(0.02, 0.02),
309
+ scale=(0.97, 1.03), shear=(-2, 2),
310
+ interpolation=InterpolationMode.BILINEAR, fill=255)(img)
311
+
312
+ if random.random() < 0.15:
313
+ img = transforms.GaussianBlur(
314
+ kernel_size=3, sigma=(0.1, 0.5))(img)
315
+
316
+ img = transforms.ToTensor()(img)
317
+
318
+ if random.random() < 0.2:
319
+ noise = torch.randn_like(img) * 0.01
320
+ img = torch.clamp(img + noise, 0.0, 1.0)
321
+
322
+ img = transforms.Normalize(
323
+ (0.485, 0.456, 0.406), (0.229, 0.224, 0.225))(img)
324
+ return img
325
+
326
+ return FinetuneTransform()
327
+
328
+
329
+ def build_eval_transform():
330
+ """Evaluation transform (normalisation only)."""
331
+ return transforms.Compose([
332
+ transforms.ToTensor(),
333
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))
334
+ ])
335
+
336
+
337
+ # ===============================
338
+ # Positional Encodings
339
+ # ===============================
340
+
341
+ class PositionalEncoding2D(nn.Module):
342
+ """2D sinusoidal positional encoding for visual feature maps."""
343
+
344
+ def __init__(self, d_model, max_h=100, max_w=300):
345
+ super().__init__()
346
+ pe = torch.zeros(max_h, max_w, d_model)
347
+ d_half = d_model // 2
348
+
349
+ pos_h = torch.arange(0, max_h, dtype=torch.float).unsqueeze(1)
350
+ div_h = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
351
+ pe_h = torch.zeros(max_h, d_half)
352
+ pe_h[:, 0::2] = torch.sin(pos_h * div_h)
353
+ pe_h[:, 1::2] = torch.cos(pos_h * div_h)
354
+
355
+ pos_w = torch.arange(0, max_w, dtype=torch.float).unsqueeze(1)
356
+ div_w = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
357
+ pe_w = torch.zeros(max_w, d_half)
358
+ pe_w[:, 0::2] = torch.sin(pos_w * div_w)
359
+ pe_w[:, 1::2] = torch.cos(pos_w * div_w)
360
+
361
+ for h in range(max_h):
362
+ for w in range(max_w):
363
+ pe[h, w, :d_half] = pe_h[h]
364
+ pe[h, w, d_half:] = pe_w[w]
365
+
366
+ self.register_buffer('pe', pe)
367
+
368
+ def forward(self, x, height, width):
369
+ _, seq_len, d_model = x.shape
370
+ pe_2d = self.pe[:height, :width, :].reshape(height * width, d_model)
371
+ if seq_len <= pe_2d.size(0):
372
+ pe_2d = pe_2d[:seq_len]
373
+ else:
374
+ pad = torch.zeros(seq_len - pe_2d.size(0), d_model, device=x.device)
375
+ pe_2d = torch.cat([pe_2d, pad], dim=0)
376
+ return x + pe_2d.unsqueeze(0)
377
+
378
+
379
+ class PositionalEncoding1D(nn.Module):
380
+ """1D sinusoidal positional encoding for decoder sequences."""
381
+
382
+ def __init__(self, d_model, max_len=1000):
383
+ super().__init__()
384
+ pe = torch.zeros(max_len, d_model)
385
+ position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
386
+ div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
387
+ pe[:, 0::2] = torch.sin(position * div_term)
388
+ pe[:, 1::2] = torch.cos(position * div_term)
389
+ self.register_buffer('pe', pe.unsqueeze(0))
390
+
391
+ def forward(self, x):
392
+ return x + self.pe[:, :x.size(1), :]
393
+
394
+
395
+ # ===============================
396
+ # CNN Feature Extractor
397
+ # ===============================
398
+
399
+ class CNNFeatureExtractor(nn.Module):
400
+ """DenseNet-121 backbone with optional horizontal upsampling."""
401
+
402
+ def __init__(self, output_dim=256, use_upsample=True):
403
+ super().__init__()
404
+ densenet = models.densenet121(weights=models.DenseNet121_Weights.DEFAULT)
405
+ self.features = densenet.features
406
+ backbone_channels = 1024
407
+
408
+ if use_upsample:
409
+ self.upsample = nn.Sequential(
410
+ nn.ConvTranspose2d(backbone_channels, 512,
411
+ kernel_size=(1, 4), stride=(1, 2), padding=(0, 1)),
412
+ nn.BatchNorm2d(512),
413
+ nn.ReLU(inplace=True))
414
+ adapt_in = 512
415
+ else:
416
+ self.upsample = None
417
+ adapt_in = backbone_channels
418
+
419
+ self.adaptation = nn.Sequential(
420
+ nn.Conv2d(adapt_in, output_dim, kernel_size=1),
421
+ nn.BatchNorm2d(output_dim),
422
+ nn.ReLU(inplace=True))
423
+
424
+ def forward(self, x):
425
+ features = F.relu(self.features(x), inplace=True)
426
+ if self.upsample is not None:
427
+ features = self.upsample(features)
428
+ features = self.adaptation(features)
429
+ b, c, h, w = features.shape
430
+ return features.view(b, c, h * w).permute(0, 2, 1), h, w
431
+
432
+
433
+ # ===============================
434
+ # Transformer OCR Model
435
+ # ===============================
436
+
437
+ class TransformerOCRParagraphModel(nn.Module):
438
+ """DenseNet121-Transformer for end-to-end paragraph recognition."""
439
+
440
+ def __init__(self, vocab_size, hidden_size=256, nhead=8,
441
+ num_encoder_layers=3, num_decoder_layers=6,
442
+ dim_feedforward=2048, dropout=0.2,
443
+ use_upsample=True, max_seq_len=555,
444
+ tf_noise_rate=0.05):
445
+ super().__init__()
446
+
447
+ self.max_seq_len = max_seq_len
448
+ self.vocab_size = vocab_size
449
+ self.tf_noise_rate = tf_noise_rate
450
+
451
+ self.feature_extractor = CNNFeatureExtractor(
452
+ output_dim=hidden_size, use_upsample=use_upsample)
453
+
454
+ self.pos_encoder_2d = PositionalEncoding2D(hidden_size)
455
+ self.pos_decoder_1d = PositionalEncoding1D(hidden_size, max_len=max_seq_len)
456
+
457
+ encoder_layer = nn.TransformerEncoderLayer(
458
+ d_model=hidden_size, nhead=nhead,
459
+ dim_feedforward=dim_feedforward, dropout=dropout,
460
+ batch_first=True)
461
+ self.transformer_encoder = nn.TransformerEncoder(
462
+ encoder_layer, num_layers=num_encoder_layers)
463
+
464
+ decoder_layer = nn.TransformerDecoderLayer(
465
+ d_model=hidden_size, nhead=nhead,
466
+ dim_feedforward=dim_feedforward, dropout=dropout,
467
+ batch_first=True)
468
+ self.transformer_decoder = nn.TransformerDecoder(
469
+ decoder_layer, num_layers=num_decoder_layers)
470
+
471
+ self.token_embedding = nn.Embedding(vocab_size, hidden_size)
472
+ self.output_projection = nn.Linear(hidden_size, vocab_size)
473
+ self.hidden_size = hidden_size
474
+
475
+ nn.init.xavier_uniform_(self.token_embedding.weight)
476
+ nn.init.xavier_uniform_(self.output_projection.weight)
477
+
478
+ def _generate_square_subsequent_mask(self, sz):
479
+ mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
480
+ return mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, 0.0)
481
+
482
+ def _add_teacher_forcing_noise(self, tgt_input):
483
+ if self.tf_noise_rate <= 0 or not self.training:
484
+ return tgt_input
485
+ noise_mask = (torch.rand_like(tgt_input.float()) < self.tf_noise_rate)
486
+ noise_mask = noise_mask & (tgt_input != PAD_TOKEN) & (tgt_input != SOS_TOKEN)
487
+ random_tokens = torch.randint(3, self.vocab_size, tgt_input.shape, device=tgt_input.device)
488
+ return torch.where(noise_mask, random_tokens, tgt_input)
489
+
490
+ def forward(self, src, tgt, tgt_key_padding_mask=None):
491
+ memory, feat_h, feat_w = self.feature_extractor(src)
492
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
493
+ memory = self.transformer_encoder(memory)
494
+
495
+ tgt_input = self._add_teacher_forcing_noise(tgt[:, :-1])
496
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(tgt_input))
497
+
498
+ tgt_mask = self._generate_square_subsequent_mask(tgt_embedded.size(1)).to(src.device)
499
+ tgt_pad_mask = tgt_key_padding_mask[:, :-1] if tgt_key_padding_mask is not None else None
500
+
501
+ output = self.transformer_decoder(
502
+ tgt_embedded, memory,
503
+ tgt_mask=tgt_mask, tgt_key_padding_mask=tgt_pad_mask)
504
+
505
+ return self.output_projection(output)
506
+
507
+ def generate_batch(self, imgs, max_length=None):
508
+ """Auto-regressive greedy batch generation."""
509
+ if max_length is None:
510
+ max_length = self.max_seq_len
511
+ self.eval()
512
+ batch_size = imgs.size(0)
513
+
514
+ with torch.no_grad():
515
+ memory, feat_h, feat_w = self.feature_extractor(imgs)
516
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
517
+ memory = self.transformer_encoder(memory)
518
+
519
+ ys = torch.ones(batch_size, 1).fill_(SOS_TOKEN).long().to(imgs.device)
520
+ finished = torch.zeros(batch_size, dtype=torch.bool, device=imgs.device)
521
+
522
+ for _ in range(max_length - 1):
523
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(ys))
524
+ tgt_mask = self._generate_square_subsequent_mask(ys.size(1)).to(imgs.device)
525
+ out = self.transformer_decoder(tgt_embedded, memory, tgt_mask=tgt_mask)
526
+ out = self.output_projection(out)
527
+
528
+ next_tokens = out[:, -1].argmax(dim=-1)
529
+ next_tokens[finished] = PAD_TOKEN
530
+ ys = torch.cat([ys, next_tokens.unsqueeze(1)], dim=1)
531
+ finished = finished | (next_tokens == EOS_TOKEN)
532
+ if finished.all():
533
+ break
534
+
535
+ return ys
536
+
537
+ def freeze_encoder(self):
538
+ """Freeze CNN backbone parameters."""
539
+ for param in self.feature_extractor.parameters():
540
+ param.requires_grad = False
541
+ print(" Encoder (CNN) frozen")
542
+
543
+ def unfreeze_encoder(self):
544
+ """Unfreeze CNN backbone parameters."""
545
+ for param in self.feature_extractor.parameters():
546
+ param.requires_grad = True
547
+ print(" Encoder (CNN) unfrozen")
548
+
549
+
550
+ # ===============================
551
+ # Weight Loading
552
+ # ===============================
553
+
554
+ def load_pretrained_weights(model, pretrained_path, device):
555
+ """Load pre-trained weights, handling PE size mismatches gracefully."""
556
+ print(f"\n Loading pre-trained model: {pretrained_path}")
557
+
558
+ if not os.path.exists(pretrained_path):
559
+ raise FileNotFoundError(f"Checkpoint not found: {pretrained_path}")
560
+
561
+ ckpt = torch.load(pretrained_path, map_location=device)
562
+
563
+ if 'epoch' in ckpt:
564
+ print(f" Pre-trained epoch: {ckpt['epoch']}")
565
+ if 'val_cer' in ckpt:
566
+ print(f" Pre-trained Val CER: {ckpt['val_cer']:.4f}")
567
+
568
+ state_dict = ckpt.get('model_state_dict', ckpt)
569
+ model_state = model.state_dict()
570
+
571
+ loaded, skipped = {}, []
572
+ for key, value in state_dict.items():
573
+ if key in model_state:
574
+ if value.shape == model_state[key].shape:
575
+ loaded[key] = value
576
+ else:
577
+ skipped.append((key, f"{value.shape} vs {model_state[key].shape}"))
578
+ else:
579
+ skipped.append((key, "not in model"))
580
+
581
+ model.load_state_dict(loaded, strict=False)
582
+
583
+ print(f" Loaded: {len(loaded)}/{len(model_state)} parameters")
584
+ if skipped:
585
+ print(f" Skipped: {len(skipped)} (PE buffers regenerated)")
586
+
587
+ return model
588
+
589
+
590
+ # ===============================
591
+ # Metrics
592
+ # ===============================
593
+
594
+ def levenshtein_distance(s1, s2):
595
+ if len(s1) < len(s2):
596
+ return levenshtein_distance(s2, s1)
597
+ if len(s2) == 0:
598
+ return len(s1)
599
+ prev = range(len(s2) + 1)
600
+ for c1 in s1:
601
+ curr = [prev[0] + 1]
602
+ for j, c2 in enumerate(s2):
603
+ curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (c1 != c2)))
604
+ prev = curr
605
+ return prev[-1]
606
+
607
+
608
+ def calculate_cer(preds, targets):
609
+ total_dist = sum(levenshtein_distance(p, t) for p, t in zip(preds, targets))
610
+ total_chars = sum(len(t) for t in targets)
611
+ return total_dist / max(1, total_chars)
612
+
613
+
614
+ def calculate_wer(preds, targets):
615
+ total_dist = sum(levenshtein_distance(p.split(), t.split()) for p, t in zip(preds, targets))
616
+ total_words = sum(len(t.split()) for t in targets)
617
+ return total_dist / max(1, total_words)
618
+
619
+
620
+ def calculate_line_accuracy(preds, targets):
621
+ total, correct = 0, 0
622
+ for pred, true in zip(preds, targets):
623
+ pred_lines = pred.split('\n')
624
+ true_lines = true.split('\n')
625
+ total += len(true_lines)
626
+ for pl, tl in zip(pred_lines, true_lines):
627
+ if pl.strip() == tl.strip():
628
+ correct += 1
629
+ return correct / max(1, total)
630
+
631
+
632
+ def evaluate_cer_batch(model, dataloader, device, idx_to_char, max_samples=None):
633
+ """Compute CER using batch generation."""
634
+ model.eval()
635
+ all_preds, all_targets = [], []
636
+ count = 0
637
+
638
+ with torch.no_grad():
639
+ for images, _, _, texts in dataloader:
640
+ images = images.to(device)
641
+ if max_samples and count + images.size(0) > max_samples:
642
+ images = images[:max_samples - count]
643
+ texts = texts[:max_samples - count]
644
+
645
+ batch_output = model.generate_batch(images)
646
+ preds = [tensor_to_text(seq, idx_to_char) for seq in batch_output]
647
+ all_preds.extend(preds)
648
+ all_targets.extend(texts)
649
+ count += len(preds)
650
+
651
+ if max_samples and count >= max_samples:
652
+ break
653
+
654
+ return calculate_cer(all_preds, all_targets)
655
+
656
+
657
+ # ===============================
658
+ # Comprehensive Test Evaluation
659
+ # ===============================
660
+
661
+ def comprehensive_evaluation(model, dataloader, device, idx_to_char):
662
+ """Full evaluation with CER, WER, line accuracy, and timing."""
663
+ model.eval()
664
+ all_preds, all_targets = [], []
665
+ inference_times = []
666
+
667
+ # Warmup
668
+ with torch.no_grad():
669
+ for images, _, _, _ in dataloader:
670
+ images = images.to(device)
671
+ _ = model.generate_batch(images[:min(3, images.size(0))])
672
+ break
673
+
674
+ if torch.cuda.is_available():
675
+ torch.cuda.synchronize()
676
+
677
+ with torch.no_grad():
678
+ for images, _, _, texts in tqdm(dataloader, desc="Evaluating"):
679
+ images = images.to(device)
680
+ batch_size = images.size(0)
681
+
682
+ if torch.cuda.is_available():
683
+ torch.cuda.synchronize()
684
+ start = time.perf_counter()
685
+
686
+ batch_output = model.generate_batch(images)
687
+
688
+ if torch.cuda.is_available():
689
+ torch.cuda.synchronize()
690
+ elapsed = time.perf_counter() - start
691
+
692
+ per_sample = elapsed / batch_size
693
+ inference_times.extend([per_sample] * batch_size)
694
+
695
+ preds = [tensor_to_text(seq, idx_to_char) for seq in batch_output]
696
+ all_preds.extend(preds)
697
+ all_targets.extend(texts)
698
+
699
+ cer = calculate_cer(all_preds, all_targets)
700
+ wer = calculate_wer(all_preds, all_targets)
701
+ line_acc = calculate_line_accuracy(all_preds, all_targets)
702
+
703
+ total_params = sum(p.numel() for p in model.parameters())
704
+
705
+ return {
706
+ 'cer': cer, 'wer': wer, 'line_accuracy': line_acc,
707
+ 'avg_inference_ms': np.mean(inference_times) * 1000,
708
+ 'std_inference_ms': np.std(inference_times) * 1000,
709
+ 'fps': len(inference_times) / sum(inference_times),
710
+ 'total_params': total_params,
711
+ 'predictions': all_preds, 'targets': all_targets,
712
+ }
713
+
714
+
715
+ # ===============================
716
+ # Early Stopping
717
+ # ===============================
718
+
719
+ class EarlyStopping:
720
+ def __init__(self, patience=15):
721
+ self.patience = patience
722
+ self.counter = 0
723
+ self.best_cer = float('inf')
724
+ self.early_stop = False
725
+
726
+ def __call__(self, val_cer, model, epoch, path):
727
+ if val_cer < self.best_cer:
728
+ self.best_cer = val_cer
729
+ self.counter = 0
730
+ torch.save({
731
+ 'epoch': epoch,
732
+ 'model_state_dict': model.state_dict(),
733
+ 'val_cer': val_cer
734
+ }, path)
735
+ print(f" Model saved (Val CER: {val_cer:.4f})")
736
+ else:
737
+ self.counter += 1
738
+ print(f" Early stopping: {self.counter}/{self.patience}")
739
+ if self.counter >= self.patience:
740
+ self.early_stop = True
741
+ print(" Early stopping triggered.")
742
+
743
+
744
+ # ===============================
745
+ # Training Functions
746
+ # ===============================
747
+
748
+ def train_epoch(model, dataloader, optimizer, criterion, device, scaler,
749
+ use_mixed_precision=True, grad_clip=5.0):
750
+ """Train for one epoch."""
751
+ model.train()
752
+ epoch_loss = 0
753
+
754
+ for images, targets, _, _ in tqdm(dataloader, desc="Training"):
755
+ images, targets = images.to(device), targets.to(device)
756
+ tgt_pad_mask = (targets == PAD_TOKEN).to(device)
757
+
758
+ optimizer.zero_grad()
759
+
760
+ if use_mixed_precision:
761
+ with autocast(device_type='cuda'):
762
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
763
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
764
+ targets[:, 1:].reshape(-1))
765
+ scaler.scale(loss).backward()
766
+ scaler.unscale_(optimizer)
767
+ torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
768
+ scaler.step(optimizer)
769
+ scaler.update()
770
+ else:
771
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
772
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
773
+ targets[:, 1:].reshape(-1))
774
+ loss.backward()
775
+ torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
776
+ optimizer.step()
777
+
778
+ epoch_loss += loss.item()
779
+
780
+ return epoch_loss / len(dataloader)
781
+
782
+
783
+ def evaluate_loss(model, dataloader, criterion, device, use_mixed_precision=True):
784
+ """Evaluate model loss."""
785
+ model.eval()
786
+ epoch_loss = 0
787
+
788
+ with torch.no_grad():
789
+ for images, targets, _, _ in dataloader:
790
+ images, targets = images.to(device), targets.to(device)
791
+ tgt_pad_mask = (targets == PAD_TOKEN).to(device)
792
+
793
+ if use_mixed_precision:
794
+ with autocast(device_type='cuda'):
795
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
796
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
797
+ targets[:, 1:].reshape(-1))
798
+ else:
799
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
800
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
801
+ targets[:, 1:].reshape(-1))
802
+ epoch_loss += loss.item()
803
+
804
+ return epoch_loss / len(dataloader)
805
+
806
+
807
+ # ===============================
808
+ # Main
809
+ # ===============================
810
+
811
+ def main():
812
+ args = parse_args()
813
+
814
+ # Handle flag conflicts
815
+ use_upsample = args.use_upsample and not args.no_upsample
816
+ use_mixed_precision = args.mixed_precision and not args.no_mixed_precision
817
+ use_clean_text = args.clean_text and not args.no_clean_text
818
+
819
+ # Seeds
820
+ torch.manual_seed(args.seed)
821
+ random.seed(args.seed)
822
+ np.random.seed(args.seed)
823
+
824
+ # Device
825
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
826
+ print(f"Device: {device}")
827
+ if torch.cuda.is_available():
828
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
829
+
830
+ # Output directory
831
+ os.makedirs(args.output_dir, exist_ok=True)
832
+
833
+ # Vocabulary
834
+ char_list, char_to_idx, idx_to_char = load_vocabulary(args.vocab_path)
835
+ vocab_size = len(char_list)
836
+ print(f"Vocabulary size: {vocab_size}")
837
+
838
+ # Transforms
839
+ train_transform = build_eval_transform() if args.no_aug else build_train_transform()
840
+ eval_transform = build_eval_transform()
841
+
842
+ # Dataset kwargs
843
+ ds_kwargs = dict(
844
+ max_seq_len=args.max_seq_len,
845
+ img_height=args.img_height,
846
+ img_width=args.img_width,
847
+ char_to_idx=char_to_idx,
848
+ clean_text_enabled=use_clean_text)
849
+
850
+ # Datasets
851
+ train_dir = os.path.join(args.data_dir, "Training")
852
+ val_dir = os.path.join(args.data_dir, "Validation")
853
+ test_dir = os.path.join(args.data_dir, "Testing")
854
+
855
+ train_dataset = KurdishParagraphDataset(train_dir, transform=train_transform, **ds_kwargs)
856
+ val_dataset = KurdishParagraphDataset(val_dir, transform=eval_transform, **ds_kwargs)
857
+ test_dataset = KurdishParagraphDataset(test_dir, transform=eval_transform, **ds_kwargs)
858
+
859
+ loader_kwargs = dict(num_workers=0, pin_memory=True, collate_fn=collate_fn)
860
+ train_loader = data.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, **loader_kwargs)
861
+ val_loader = data.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
862
+ test_loader = data.DataLoader(test_dataset, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
863
+
864
+ print(f"\n Training: {len(train_dataset)} | Validation: {len(val_dataset)} | Testing: {len(test_dataset)}")
865
+
866
+ # Model
867
+ print("\nInitializing model...")
868
+ model = TransformerOCRParagraphModel(
869
+ vocab_size=vocab_size,
870
+ hidden_size=args.hidden_size,
871
+ nhead=args.num_heads,
872
+ num_encoder_layers=args.encoder_layers,
873
+ num_decoder_layers=args.decoder_layers,
874
+ dim_feedforward=args.ff_dim,
875
+ dropout=args.dropout,
876
+ use_upsample=use_upsample,
877
+ max_seq_len=args.max_seq_len,
878
+ tf_noise_rate=args.tf_noise_rate
879
+ ).to(device)
880
+
881
+ # Load pre-trained weights
882
+ model = load_pretrained_weights(model, args.pretrained_path, device)
883
+
884
+ # Freeze encoder
885
+ if args.freeze_epochs > 0:
886
+ model.freeze_encoder()
887
+
888
+ total_params = sum(p.numel() for p in model.parameters())
889
+ trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
890
+ print(f" Total parameters: {total_params:,}")
891
+ print(f" Trainable parameters: {trainable_params:,}")
892
+
893
+ # Optimizer with differential learning rates
894
+ encoder_params = list(model.feature_extractor.parameters())
895
+ other_params = [p for n, p in model.named_parameters() if 'feature_extractor' not in n]
896
+
897
+ optimizer = optim.AdamW([
898
+ {'params': encoder_params, 'lr': args.learning_rate * args.encoder_lr_mult},
899
+ {'params': other_params, 'lr': args.learning_rate}
900
+ ], weight_decay=args.weight_decay)
901
+
902
+ scheduler = optim.lr_scheduler.ReduceLROnPlateau(
903
+ optimizer, mode='min', factor=args.lr_factor,
904
+ patience=args.lr_patience, min_lr=1e-7)
905
+
906
+ criterion = nn.CrossEntropyLoss(ignore_index=PAD_TOKEN)
907
+ scaler = GradScaler('cuda') if use_mixed_precision else None
908
+ early_stopping = EarlyStopping(patience=args.patience)
909
+
910
+ best_model_path = os.path.join(args.output_dir, f"{args.model_name}.pth")
911
+
912
+ # Log file
913
+ log_path = os.path.join(args.output_dir,
914
+ f"{args.model_name}_LOG_{datetime.now():%Y%m%d_%H%M%S}.txt")
915
+ log_file = open(log_path, 'w', encoding='utf-8')
916
+
917
+ def log(msg):
918
+ print(msg)
919
+ log_file.write(msg + '\n')
920
+ log_file.flush()
921
+
922
+ log(f"\nFine-tuning started: {datetime.now():%Y-%m-%d %H:%M:%S}")
923
+ log(f"Pre-trained model: {args.pretrained_path}")
924
+ log(f"Config: {vars(args)}")
925
+
926
+ # Initial evaluation
927
+ initial_cer = evaluate_cer_batch(model, val_loader, device, idx_to_char)
928
+ log(f"\n Initial Val CER (pre-trained): {initial_cer:.4f}")
929
+
930
+ # Fine-tuning loop
931
+ best_val_cer = float('inf')
932
+
933
+ for epoch in range(1, args.num_epochs + 1):
934
+ start_time = time.time()
935
+
936
+ # Unfreeze encoder after freeze period
937
+ if epoch == args.freeze_epochs + 1 and args.freeze_epochs > 0:
938
+ model.unfreeze_encoder()
939
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
940
+ log(f"\n Epoch {epoch}: Encoder unfrozen ({trainable:,} trainable params)")
941
+
942
+ # Train
943
+ train_loss = train_epoch(model, train_loader, optimizer, criterion,
944
+ device, scaler, use_mixed_precision, args.grad_clip)
945
+
946
+ # Train CER (periodic)
947
+ train_cer = None
948
+ if args.cer_every > 0 and epoch % args.cer_every == 0:
949
+ train_cer = evaluate_cer_batch(model, train_loader, device,
950
+ idx_to_char, args.cer_max_samples)
951
+
952
+ # Validation
953
+ val_loss = evaluate_loss(model, val_loader, criterion, device, use_mixed_precision)
954
+ val_cer = evaluate_cer_batch(model, val_loader, device, idx_to_char)
955
+
956
+ scheduler.step(val_cer)
957
+ elapsed = time.time() - start_time
958
+ mins, secs = divmod(elapsed, 60)
959
+
960
+ lr_enc = optimizer.param_groups[0]['lr']
961
+ lr_dec = optimizer.param_groups[1]['lr']
962
+
963
+ cer_str = f", Train CER: {train_cer:.4f}" if train_cer is not None else ""
964
+ log(f"Epoch {epoch}/{args.num_epochs} ({mins:.0f}m {secs:.0f}s) | "
965
+ f"Train Loss: {train_loss:.4f}{cer_str} | "
966
+ f"Val Loss: {val_loss:.4f} | Val CER: {val_cer:.4f} | "
967
+ f"LR: Enc={lr_enc:.2e}, Dec={lr_dec:.2e}")
968
+
969
+ if val_cer < best_val_cer:
970
+ best_val_cer = val_cer
971
+
972
+ early_stopping(val_cer, model, epoch, best_model_path)
973
+ if early_stopping.early_stop:
974
+ break
975
+
976
+ gc.collect()
977
+ if torch.cuda.is_available():
978
+ torch.cuda.empty_cache()
979
+
980
+ # Final comprehensive evaluation
981
+ log(f"\nLoading best model for final evaluation...")
982
+ ckpt = torch.load(best_model_path, map_location=device)
983
+ model.load_state_dict(ckpt['model_state_dict'])
984
+ log(f" Best epoch: {ckpt['epoch']}, Best Val CER: {ckpt['val_cer']:.4f}")
985
+
986
+ # Validation results
987
+ log(f"\n--- Validation Set ---")
988
+ val_results = comprehensive_evaluation(model, val_loader, device, idx_to_char)
989
+ log(f" CER: {val_results['cer']:.4f} | WER: {val_results['wer']:.4f} | "
990
+ f"Line Acc: {val_results['line_accuracy']:.4f}")
991
+ log(f" Inference: {val_results['avg_inference_ms']:.2f} ms | FPS: {val_results['fps']:.2f}")
992
+
993
+ # Test results
994
+ log(f"\n--- Test Set ---")
995
+ test_results = comprehensive_evaluation(model, test_loader, device, idx_to_char)
996
+ log(f" CER: {test_results['cer']:.4f} ({(1-test_results['cer'])*100:.2f}% accuracy)")
997
+ log(f" WER: {test_results['wer']:.4f} ({(1-test_results['wer'])*100:.2f}% accuracy)")
998
+ log(f" Line Accuracy: {test_results['line_accuracy']:.4f}")
999
+ log(f" Inference: {test_results['avg_inference_ms']:.2f} ± {test_results['std_inference_ms']:.2f} ms")
1000
+ log(f" FPS: {test_results['fps']:.2f}")
1001
+ log(f" Parameters: {test_results['total_params']:,}")
1002
+
1003
+ # Sample predictions
1004
+ log(f"\n--- Sample Predictions ---")
1005
+ for i in range(min(5, len(test_results['predictions']))):
1006
+ log(f"\nSample {i + 1}:")
1007
+ pred_preview = test_results['predictions'][i][:200]
1008
+ true_preview = test_results['targets'][i][:200]
1009
+ log(f" Predicted: {pred_preview}")
1010
+ log(f" Actual: {true_preview}")
1011
+
1012
+ log(f"\nFine-tuning complete: {datetime.now():%Y-%m-%d %H:%M:%S}")
1013
+ log(f"Best model: {best_model_path}")
1014
+ log(f"Improvement: {initial_cer:.4f} -> {best_val_cer:.4f} "
1015
+ f"({(initial_cer - best_val_cer)*100:.2f}% absolute)")
1016
+
1017
+ log_file.close()
1018
+ print(f"Log saved to: {log_path}")
1019
+
1020
+
1021
+ if __name__ == "__main__":
1022
+ main()
Scripts/generate_paragraphs.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic Handwritten Paragraph Generator
3
+ Single-Writer Consistency | Cross-Source Mixing | Zero Duplicate Orders
4
+
5
+ Generates synthetic paragraph images from handwritten line sources for
6
+ pre-training paragraph recognition models. Supports RTL scripts.
7
+
8
+ Guarantees:
9
+ 1. Single-writer consistency: all lines in each paragraph from one writer
10
+ 2. Cross-source mixing: multi-line paragraphs use lines from 2+ sources
11
+ 3. Zero duplicate text orderings across the entire dataset
12
+ 4. Source-level isolation: training and validation use separate line pools
13
+ 5. Configurable reuse caps per source to control line repetition
14
+
15
+ Usage:
16
+ python generate_paragraphs.py \
17
+ --unique_train_dir ./data/UniqueLines/Training \
18
+ --fixed_train_dir ./data/FixedLines/Training \
19
+ --synthetic_train_dir ./data/SyntheticLines/Training \
20
+ --unique_val_dir ./data/UniqueLines/Validation \
21
+ --fixed_val_dir ./data/FixedLines/Validation \
22
+ --synthetic_val_dir ./data/SyntheticLines/Validation \
23
+ --output_dir ./SyntheticParagraphs_12000 \
24
+ --dataset_size 12000
25
+ """
26
+
27
+ import os, glob, random, argparse, gc
28
+ import numpy as np
29
+ from PIL import Image
30
+ from tqdm import tqdm
31
+ from datetime import datetime
32
+ from collections import defaultdict
33
+
34
+
35
+ def parse_args():
36
+ p = argparse.ArgumentParser(description="Synthetic Paragraph Generator")
37
+ p.add_argument("--unique_train_dir", type=str, required=True)
38
+ p.add_argument("--fixed_train_dir", type=str, default=None)
39
+ p.add_argument("--synthetic_train_dir", type=str, default=None)
40
+ p.add_argument("--unique_val_dir", type=str, required=True)
41
+ p.add_argument("--fixed_val_dir", type=str, default=None)
42
+ p.add_argument("--synthetic_val_dir", type=str, default=None)
43
+ p.add_argument("--output_dir", type=str, required=True)
44
+ p.add_argument("--output_format", type=str, default="TIFF", choices=["TIFF","PNG","JPEG"])
45
+ p.add_argument("--dataset_size", type=int, default=12000)
46
+ p.add_argument("--train_ratio", type=float, default=0.85)
47
+ p.add_argument("--min_lines", type=int, default=1)
48
+ p.add_argument("--max_lines", type=int, default=7)
49
+ p.add_argument("--spacing_min", type=int, default=15)
50
+ p.add_argument("--spacing_max", type=int, default=35)
51
+ p.add_argument("--canvas_width", type=int, default=2470)
52
+ p.add_argument("--canvas_height", type=int, default=1200)
53
+ p.add_argument("--padding", type=int, default=40)
54
+ p.add_argument("--train_fixed_cap", type=float, default=1.5)
55
+ p.add_argument("--train_synthetic_cap", type=float, default=2.5)
56
+ p.add_argument("--val_fixed_cap", type=float, default=1.0)
57
+ p.add_argument("--val_synthetic_cap", type=float, default=2.0)
58
+ p.add_argument("--crop_whitespace", action="store_true", default=True)
59
+ p.add_argument("--no_crop_whitespace", action="store_true")
60
+ p.add_argument("--clean_left_edge", action="store_true", default=True)
61
+ p.add_argument("--no_clean_left_edge", action="store_true")
62
+ p.add_argument("--whitespace_threshold", type=int, default=250)
63
+ p.add_argument("--edge_pixels", type=int, default=8)
64
+ p.add_argument("--max_attempts", type=int, default=500)
65
+ p.add_argument("--seed", type=int, default=42)
66
+ p.add_argument("--gc_interval", type=int, default=100)
67
+ return p.parse_args()
68
+
69
+
70
+ def extract_writer_id(filename):
71
+ basename = os.path.splitext(os.path.basename(filename))[0]
72
+ parts = basename.split('_')
73
+ return parts[0] if parts else basename
74
+
75
+
76
+ def load_line_dataset(directory, name="Dataset"):
77
+ if not directory or not os.path.exists(directory):
78
+ return []
79
+ files = []
80
+ for ext in ["*.tif","*.tiff","*.png","*.jpg","*.jpeg","*.bmp"]:
81
+ files.extend(glob.glob(os.path.join(directory, ext)))
82
+ files.extend(glob.glob(os.path.join(directory, ext.upper())))
83
+ files = sorted(list(set(files)))
84
+ data, skipped = [], 0
85
+ for p in files:
86
+ lp = os.path.splitext(p)[0] + ".txt"
87
+ if not os.path.exists(lp):
88
+ skipped += 1; continue
89
+ try:
90
+ with open(lp, "r", encoding="utf-8") as f: label = f.readline().strip()
91
+ except:
92
+ try:
93
+ with open(lp, "r", encoding="utf-8-sig") as f: label = f.readline().strip()
94
+ except: skipped += 1; continue
95
+ if label: data.append((p, label))
96
+ print(f" {name}: {len(data)} lines, {skipped} skipped")
97
+ return data
98
+
99
+
100
+ def merge_lines_by_writer(source_pairs):
101
+ merged = defaultdict(list)
102
+ counts = defaultdict(lambda: defaultdict(int))
103
+ for src, lines in source_pairs:
104
+ for path, label in lines:
105
+ wid = extract_writer_id(path)
106
+ merged[wid].append((path, label, src))
107
+ counts[wid][src] += 1
108
+ return dict(merged), {k: dict(v) for k, v in counts.items()}
109
+
110
+
111
+ class SingleWriterParagraphGenerator:
112
+ def __init__(self, merged, totals, fixed_cap, synth_cap, min_l, max_l, max_att):
113
+ self.merged = merged
114
+ self.used = set()
115
+ self.totals = totals
116
+ self.usage = defaultdict(int)
117
+ self.fcap, self.scap = fixed_cap, synth_cap
118
+ self.min_l, self.max_l, self.max_att = min_l, max_l, max_att
119
+ self.line_usage = defaultdict(int)
120
+ self.line_src = {}
121
+ for lines in merged.values():
122
+ for p, _, s in lines: self.line_src[p] = s
123
+ self.valid = {w: l for w, l in merged.items() if len(l) >= min_l}
124
+ self.wlist = list(self.valid.keys())
125
+ self.writers_used = set()
126
+ self.src_para = defaultdict(int)
127
+ self.n_paras = 0
128
+ self.n_lines = 0
129
+ self.n_dups = 0
130
+
131
+ def _capped(self, s):
132
+ if s == "unique": return False
133
+ c = self.fcap if s == "fixed" else self.scap
134
+ return self.usage[s] >= int(c * self.totals.get(s, 0))
135
+
136
+ def _both_capped(self):
137
+ return self._capped("fixed") and self._capped("synthetic")
138
+
139
+ def _avail(self, wlines):
140
+ return [x for x in wlines if x[2] == "unique" or not self._capped(x[2])]
141
+
142
+ def get_paragraph_lines(self):
143
+ if not self.wlist: return None
144
+ bc = self._both_capped()
145
+ for _ in range(self.max_att):
146
+ wid = random.choice(self.wlist)
147
+ av = self._avail(self.valid[wid])
148
+ if len(av) < self.min_l: continue
149
+ nl = random.randint(self.min_l, min(self.max_l, len(av)))
150
+ sel = None
151
+ if nl >= 2 and not bc:
152
+ srcs = set(s for _, _, s in av)
153
+ if len(srcs) < 2:
154
+ if self.min_l <= 1: nl = 1; sel = random.sample(av, 1)
155
+ else: continue
156
+ else:
157
+ for _ in range(30):
158
+ c = random.sample(av, nl)
159
+ if len(set(s for _, _, s in c)) >= 2: sel = c; break
160
+ if sel is None: continue
161
+ else:
162
+ sel = random.sample(av, nl)
163
+ if sel is None: continue
164
+ key = tuple(l for _, l, _ in sel)
165
+ if key in self.used: self.n_dups += 1; continue
166
+ tmp = defaultdict(int)
167
+ for _, _, s in sel:
168
+ if s in ("fixed", "synthetic"): tmp[s] += 1
169
+ ok = True
170
+ for s in ("fixed", "synthetic"):
171
+ if tmp[s] > 0:
172
+ c = self.fcap if s == "fixed" else self.scap
173
+ if self.usage[s] + tmp[s] > int(c * self.totals.get(s, 0)): ok = False; break
174
+ if not ok: continue
175
+ self.used.add(key); self.n_paras += 1; self.n_lines += nl
176
+ self.writers_used.add(wid)
177
+ si = set()
178
+ for p, _, s in sel:
179
+ self.usage[s] += 1; self.line_usage[p] += 1; si.add(s)
180
+ for s in si: self.src_para[s] += 1
181
+ return sel, wid
182
+ return None
183
+
184
+ def get_stats(self):
185
+ st = {}
186
+ for s in ["unique", "fixed", "synthetic"]:
187
+ t = self.totals.get(s, 0); u = self.usage.get(s, 0)
188
+ uu = sum(1 for p, c in self.line_usage.items() if c > 0 and self.line_src.get(p) == s)
189
+ st[s] = {'available': t, 'used': u, 'unique_used': uu,
190
+ 'ratio': u / max(t, 1), 'utilisation': uu / max(t, 1) * 100}
191
+ return st
192
+
193
+
194
+ def load_image(path):
195
+ try: return Image.open(path).convert("RGB")
196
+ except: return None
197
+
198
+
199
+ def crop_whitespace(image, threshold=250, margin=5):
200
+ g = np.array(image.convert('L'))
201
+ m = g < threshold
202
+ r, c = np.any(m, axis=1), np.any(m, axis=0)
203
+ if not np.any(r) or not np.any(c): return image
204
+ ri, ci = np.where(r)[0], np.where(c)[0]
205
+ return image.crop((max(0, ci[0]-margin), max(0, ri[0]-margin),
206
+ min(image.width, ci[-1]+margin+1), min(image.height, ri[-1]+margin+1)))
207
+
208
+
209
+ def clean_left_edge(image, edge_px=8, wt=240, rs=10, vt=500):
210
+ a = np.array(image, dtype=np.float32)
211
+ _, w = a.shape[:2]
212
+ if w <= edge_px: return image
213
+ for col in range(min(edge_px, w)):
214
+ cd = a[:, col, :]
215
+ rm, gm, bm = np.mean(cd[:,0]), np.mean(cd[:,1]), np.mean(cd[:,2])
216
+ ov = (rm+gm+bm)/3; v = np.var(cd)
217
+ if (ov > wt or (rm > gm+rs and rm > bm+rs) or
218
+ (v < vt and ov > 180) or (rm > 200 and rm > gm and rm > bm and ov > 180)):
219
+ a[:, col, :] = 255.0
220
+ return Image.fromarray(a.astype(np.uint8))
221
+
222
+
223
+ def process_line(img, cw, do_crop, do_clean, wst, epx):
224
+ if do_crop: img = crop_whitespace(img, threshold=wst, margin=3)
225
+ if do_clean: img = clean_left_edge(img, edge_px=epx)
226
+ if img.width > cw:
227
+ s = cw / img.width
228
+ img = img.resize((cw, max(int(img.height * s), 20)), Image.Resampling.LANCZOS)
229
+ return img
230
+
231
+
232
+ def create_paragraph(imgs, sp, cw, ch, pad, content_w, do_crop, do_clean, wst, epx):
233
+ proc = [process_line(i, content_w, do_crop, do_clean, wst, epx)
234
+ for i in imgs if i.width > 0 and i.height > 0]
235
+ if not proc: return None, 0
236
+ th = pad*2 + sum(p.height for p in proc) + sp*(len(proc)-1)
237
+ ah = min(th, ch)
238
+ canvas = Image.new('RGB', (cw + pad*2, ah), (255, 255, 255))
239
+ y, used = pad, 0
240
+ for p in proc:
241
+ if y + p.height > ah - pad: break
242
+ x = max(cw + pad - p.width, pad)
243
+ canvas.paste(p, (x, y)); y += p.height + sp; used += 1
244
+ return canvas, used
245
+
246
+
247
+ def generate_split(gen, n, out_dir, name, cw, ch, pad, smin, smax,
248
+ do_crop, do_clean, wst, epx, fmt, gc_int):
249
+ os.makedirs(out_dir, exist_ok=True)
250
+ content_w = cw - pad*2
251
+ wc = defaultdict(int); ld = defaultdict(int)
252
+ cnt, err = 0, 0
253
+ pbar = tqdm(range(n), desc=f"Generating {name}")
254
+ for i in pbar:
255
+ try:
256
+ r = gen.get_paragraph_lines()
257
+ if r is None: err += 1; continue
258
+ sel, wid = r
259
+ imgs = [(load_image(p), l) for p, l, _ in sel]
260
+ imgs = [(im, l) for im, l in imgs if im is not None]
261
+ if not imgs: err += 1; continue
262
+ sp = random.randint(smin, smax)
263
+ pi, lu = create_paragraph([im for im, _ in imgs], sp, cw, ch, pad,
264
+ content_w, do_crop, do_clean, wst, epx)
265
+ if pi is None or lu == 0: err += 1; continue
266
+ cnt += 1; wc[wid] += 1; ld[lu] += 1
267
+ ext = {"TIFF": "tif", "PNG": "png", "JPEG": "jpg"}[fmt]
268
+ pi.save(os.path.join(out_dir, f"{wid}_para_{wc[wid]:04d}.{ext}"), fmt)
269
+ with open(os.path.join(out_dir, f"{wid}_para_{wc[wid]:04d}.txt"), "w", encoding="utf-8") as f:
270
+ f.write("\n".join(l for _, l in imgs[:lu]))
271
+ del pi
272
+ if (i+1) % gc_int == 0: gc.collect()
273
+ pbar.set_postfix({"saved": cnt, "err": err})
274
+ except Exception as e:
275
+ err += 1
276
+ if err < 10: print(f"\nError: {e}")
277
+ gc.collect()
278
+ gc.collect()
279
+ print(f" {name}: {cnt:,} saved, {err:,} errors")
280
+ return cnt, err, dict(ld)
281
+
282
+
283
+ def print_stats(name, gen, count, ld):
284
+ st = gen.get_stats()
285
+ tl = sum(k*v for k, v in ld.items())
286
+ tp = sum(ld.values())
287
+ print(f"\n {name}:")
288
+ print(f" Paragraphs: {count:,}, Writers: {len(gen.writers_used)}")
289
+ if tp > 0: print(f" Avg lines/para: {tl/tp:.2f}")
290
+ for s in ["unique", "fixed", "synthetic"]:
291
+ d = st[s]
292
+ if d['available'] > 0:
293
+ print(f" {s.capitalize():12s}: {d['ratio']:.2f}x reuse, "
294
+ f"{d['unique_used']:,}/{d['available']:,} ({d['utilisation']:.1f}%)")
295
+ print(f" Duplicates rejected: {gen.n_dups:,}")
296
+
297
+
298
+ def main():
299
+ args = parse_args()
300
+ random.seed(args.seed); np.random.seed(args.seed)
301
+ do_crop = args.crop_whitespace and not args.no_crop_whitespace
302
+ do_clean = args.clean_left_edge and not args.no_clean_left_edge
303
+ ts = int(args.dataset_size * args.train_ratio); vs = args.dataset_size - ts
304
+
305
+ print("\n" + "="*70)
306
+ print("SYNTHETIC PARAGRAPH GENERATOR")
307
+ print("="*70)
308
+ print(f"Size: {args.dataset_size:,} (train={ts:,}, val={vs:,})")
309
+
310
+ print("\n[1] Loading lines...")
311
+ ut = load_line_dataset(args.unique_train_dir, "Unique Train")
312
+ ft = load_line_dataset(args.fixed_train_dir, "Fixed Train")
313
+ st = load_line_dataset(args.synthetic_train_dir, "Synth Train")
314
+ uv = load_line_dataset(args.unique_val_dir, "Unique Val")
315
+ fv = load_line_dataset(args.fixed_val_dir, "Fixed Val")
316
+ sv = load_line_dataset(args.synthetic_val_dir, "Synth Val")
317
+
318
+ tt = {"unique": len(ut), "fixed": len(ft), "synthetic": len(st)}
319
+ vt = {"unique": len(uv), "fixed": len(fv), "synthetic": len(sv)}
320
+
321
+ print("\n[2] Verifying isolation...")
322
+ tp = set(os.path.abspath(p) for p, _ in ut+ft+st)
323
+ vp = set(os.path.abspath(p) for p, _ in uv+fv+sv)
324
+ ov = tp & vp
325
+ print(f" {'WARNING: '+str(len(ov))+' overlap!' if ov else 'Zero overlap confirmed'}")
326
+ del tp, vp
327
+
328
+ print("\n[3] Merging by writer...")
329
+ src_t = [("unique", ut)] + ([("fixed", ft)] if ft else []) + ([("synthetic", st)] if st else [])
330
+ src_v = [("unique", uv)] + ([("fixed", fv)] if fv else []) + ([("synthetic", sv)] if sv else [])
331
+ tm, _ = merge_lines_by_writer(src_t)
332
+ vm, _ = merge_lines_by_writer(src_v)
333
+ print(f" Train: {len(tm)} writers | Val: {len(vm)} writers")
334
+
335
+ tg = SingleWriterParagraphGenerator(tm, tt, args.train_fixed_cap, args.train_synthetic_cap,
336
+ args.min_lines, args.max_lines, args.max_attempts)
337
+ vg = SingleWriterParagraphGenerator(vm, vt, args.val_fixed_cap, args.val_synthetic_cap,
338
+ args.min_lines, args.max_lines, args.max_attempts)
339
+
340
+ td = os.path.join(args.output_dir, "Training")
341
+ vd = os.path.join(args.output_dir, "Validation")
342
+
343
+ print(f"\n[4] Generating training ({ts:,})...")
344
+ tc, te, tld = generate_split(tg, ts, td, "Training", args.canvas_width, args.canvas_height,
345
+ args.padding, args.spacing_min, args.spacing_max,
346
+ do_crop, do_clean, args.whitespace_threshold, args.edge_pixels,
347
+ args.output_format, args.gc_interval)
348
+
349
+ print(f"\n[5] Generating validation ({vs:,})...")
350
+ vc, ve, vld = generate_split(vg, vs, vd, "Validation", args.canvas_width, args.canvas_height,
351
+ args.padding, args.spacing_min, args.spacing_max,
352
+ do_crop, do_clean, args.whitespace_threshold, args.edge_pixels,
353
+ args.output_format, args.gc_interval)
354
+
355
+ print("\n" + "="*70)
356
+ print("COMPLETE")
357
+ print("="*70)
358
+ print(f" Total: {tc+vc:,} (train={tc:,}, val={vc:,}, errors={te+ve:,})")
359
+ print_stats("Training", tg, tc, tld)
360
+ print_stats("Validation", vg, vc, vld)
361
+ print(f"\n Output: {args.output_dir}")
362
+ print(f" Finished: {datetime.now():%Y-%m-%d %H:%M:%S}")
363
+
364
+ info = os.path.join(args.output_dir, "generation_info.txt")
365
+ with open(info, "w", encoding="utf-8") as f:
366
+ f.write(f"Generated: {datetime.now():%Y-%m-%d %H:%M:%S}\n")
367
+ f.write(f"Size: {args.dataset_size}, Train: {tc}, Val: {vc}\n")
368
+ f.write(f"Config: {vars(args)}\n")
369
+ for nm, g in [("Training", tg), ("Validation", vg)]:
370
+ s = g.get_stats(); f.write(f"\n{nm}:\n")
371
+ for src in ["unique","fixed","synthetic"]:
372
+ d = s[src]
373
+ if d['available'] > 0:
374
+ f.write(f" {src}: {d['used']:,}/{d['available']:,} ({d['ratio']:.2f}x)\n")
375
+ print(f" Info: {info}")
376
+ gc.collect()
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()
Scripts/inference.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Kurdish Handwritten Paragraph Recognition - Inference Script
3
+
4
+ Usage:
5
+ # Single image
6
+ python inference.py --image sample.tif --model_path model.safetensors --vocab_path vocab.json
7
+
8
+ # Directory of images
9
+ python inference.py --image_dir ./test_images --model_path model.safetensors --vocab_path vocab.json
10
+
11
+ # With .pth checkpoint
12
+ python inference.py --image sample.tif --model_path finetuned_model.pth --vocab_path vocab.json
13
+
14
+ # KHATT Arabic model (different vocab)
15
+ python inference.py --image arabic_sample.tif --model_path khatt_model.safetensors \
16
+ --vocab_path khatt_vocab.json
17
+ """
18
+
19
+ import os
20
+ import glob
21
+ import json
22
+ import math
23
+ import time
24
+ import argparse
25
+ from PIL import Image
26
+
27
+ import torch
28
+ import torch.nn as nn
29
+ import torch.nn.functional as F
30
+ import torchvision.transforms as transforms
31
+ import torchvision.models as models
32
+
33
+
34
+ # ===============================
35
+ # Argument Parser
36
+ # ===============================
37
+
38
+ def parse_args():
39
+ parser = argparse.ArgumentParser(
40
+ description="Kurdish Handwritten Paragraph Recognition - Inference")
41
+
42
+ # Input
43
+ parser.add_argument("--image", type=str, default=None,
44
+ help="Path to a single paragraph image")
45
+ parser.add_argument("--image_dir", type=str, default=None,
46
+ help="Directory of paragraph images to process")
47
+
48
+ # Model and vocabulary
49
+ parser.add_argument("--model_path", type=str, required=True,
50
+ help="Path to model weights (.pth or .safetensors)")
51
+ parser.add_argument("--vocab_path", type=str, required=True,
52
+ help="Path to vocabulary JSON file (vocab.json)")
53
+ parser.add_argument("--config_path", type=str, default=None,
54
+ help="Path to config.json (auto-loads architecture settings)")
55
+
56
+ # Image dimensions
57
+ parser.add_argument("--img_height", type=int, default=600)
58
+ parser.add_argument("--img_width", type=int, default=1235)
59
+
60
+ # Model architecture (overridden by config.json if provided)
61
+ parser.add_argument("--hidden_size", type=int, default=256)
62
+ parser.add_argument("--encoder_layers", type=int, default=3)
63
+ parser.add_argument("--decoder_layers", type=int, default=6)
64
+ parser.add_argument("--num_heads", type=int, default=8)
65
+ parser.add_argument("--ff_dim", type=int, default=2048)
66
+ parser.add_argument("--max_seq_len", type=int, default=555)
67
+ parser.add_argument("--use_upsample", action="store_true", default=True)
68
+ parser.add_argument("--no_upsample", action="store_true")
69
+
70
+ # Output
71
+ parser.add_argument("--output_file", type=str, default=None,
72
+ help="Save predictions to text file")
73
+ parser.add_argument("--show_timing", action="store_true",
74
+ help="Show per-image inference time")
75
+
76
+ # Device
77
+ parser.add_argument("--device", type=str, default=None,
78
+ help="Device (cuda/cpu, auto-detected if not set)")
79
+
80
+ return parser.parse_args()
81
+
82
+
83
+ # ===============================
84
+ # Vocabulary
85
+ # ===============================
86
+
87
+ PAD_TOKEN = 0
88
+ SOS_TOKEN = 1
89
+ EOS_TOKEN = 2
90
+
91
+
92
+ def load_vocabulary(vocab_path):
93
+ """Load vocabulary from JSON file."""
94
+ with open(vocab_path, "r", encoding="utf-8") as f:
95
+ vocab_data = json.load(f)
96
+
97
+ if "vocab_list" in vocab_data:
98
+ char_list = vocab_data["vocab_list"]
99
+ elif "char_to_idx" in vocab_data:
100
+ mapping = vocab_data["char_to_idx"]
101
+ char_list = [None] * len(mapping)
102
+ for char, idx in mapping.items():
103
+ char_list[idx] = char
104
+ else:
105
+ raise ValueError("Vocabulary JSON must contain 'vocab_list' or 'char_to_idx'")
106
+
107
+ idx_to_char = {idx: char for idx, char in enumerate(char_list)}
108
+ return char_list, idx_to_char
109
+
110
+
111
+ def decode_output(tensor, idx_to_char):
112
+ """Convert tensor of character indices to text."""
113
+ if isinstance(tensor, torch.Tensor):
114
+ tensor = tensor.cpu().tolist()
115
+ text = ""
116
+ for idx in tensor:
117
+ if idx == PAD_TOKEN or idx == SOS_TOKEN:
118
+ continue
119
+ if idx == EOS_TOKEN:
120
+ break
121
+ if idx in idx_to_char:
122
+ text += idx_to_char[idx]
123
+ return text
124
+
125
+
126
+ # ===============================
127
+ # Positional Encodings
128
+ # ===============================
129
+
130
+ class PositionalEncoding2D(nn.Module):
131
+ """2D sinusoidal positional encoding for visual feature maps."""
132
+
133
+ def __init__(self, d_model, max_h=100, max_w=300):
134
+ super().__init__()
135
+ pe = torch.zeros(max_h, max_w, d_model)
136
+ d_half = d_model // 2
137
+
138
+ pos_h = torch.arange(0, max_h, dtype=torch.float).unsqueeze(1)
139
+ div_h = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
140
+ pe_h = torch.zeros(max_h, d_half)
141
+ pe_h[:, 0::2] = torch.sin(pos_h * div_h)
142
+ pe_h[:, 1::2] = torch.cos(pos_h * div_h)
143
+
144
+ pos_w = torch.arange(0, max_w, dtype=torch.float).unsqueeze(1)
145
+ div_w = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
146
+ pe_w = torch.zeros(max_w, d_half)
147
+ pe_w[:, 0::2] = torch.sin(pos_w * div_w)
148
+ pe_w[:, 1::2] = torch.cos(pos_w * div_w)
149
+
150
+ for h in range(max_h):
151
+ for w in range(max_w):
152
+ pe[h, w, :d_half] = pe_h[h]
153
+ pe[h, w, d_half:] = pe_w[w]
154
+
155
+ self.register_buffer('pe', pe)
156
+
157
+ def forward(self, x, height, width):
158
+ _, seq_len, d_model = x.shape
159
+ pe_2d = self.pe[:height, :width, :].reshape(height * width, d_model)
160
+ if seq_len <= pe_2d.size(0):
161
+ pe_2d = pe_2d[:seq_len]
162
+ else:
163
+ pad = torch.zeros(seq_len - pe_2d.size(0), d_model, device=x.device)
164
+ pe_2d = torch.cat([pe_2d, pad], dim=0)
165
+ return x + pe_2d.unsqueeze(0)
166
+
167
+
168
+ class PositionalEncoding1D(nn.Module):
169
+ """1D sinusoidal positional encoding for decoder sequences."""
170
+
171
+ def __init__(self, d_model, max_len=1000):
172
+ super().__init__()
173
+ pe = torch.zeros(max_len, d_model)
174
+ position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
175
+ div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
176
+ pe[:, 0::2] = torch.sin(position * div_term)
177
+ pe[:, 1::2] = torch.cos(position * div_term)
178
+ self.register_buffer('pe', pe.unsqueeze(0))
179
+
180
+ def forward(self, x):
181
+ return x + self.pe[:, :x.size(1), :]
182
+
183
+
184
+ # ===============================
185
+ # CNN Feature Extractor
186
+ # ===============================
187
+
188
+ class CNNFeatureExtractor(nn.Module):
189
+ """DenseNet-121 backbone with optional horizontal upsampling."""
190
+
191
+ def __init__(self, output_dim=256, use_upsample=True):
192
+ super().__init__()
193
+ densenet = models.densenet121(weights=models.DenseNet121_Weights.DEFAULT)
194
+ self.features = densenet.features
195
+ backbone_channels = 1024
196
+
197
+ if use_upsample:
198
+ self.upsample = nn.Sequential(
199
+ nn.ConvTranspose2d(backbone_channels, 512,
200
+ kernel_size=(1, 4), stride=(1, 2), padding=(0, 1)),
201
+ nn.BatchNorm2d(512),
202
+ nn.ReLU(inplace=True))
203
+ adapt_in = 512
204
+ else:
205
+ self.upsample = None
206
+ adapt_in = backbone_channels
207
+
208
+ self.adaptation = nn.Sequential(
209
+ nn.Conv2d(adapt_in, output_dim, kernel_size=1),
210
+ nn.BatchNorm2d(output_dim),
211
+ nn.ReLU(inplace=True))
212
+
213
+ def forward(self, x):
214
+ features = F.relu(self.features(x), inplace=True)
215
+ if self.upsample is not None:
216
+ features = self.upsample(features)
217
+ features = self.adaptation(features)
218
+ b, c, h, w = features.shape
219
+ return features.view(b, c, h * w).permute(0, 2, 1), h, w
220
+
221
+
222
+ # ===============================
223
+ # Transformer OCR Model
224
+ # ===============================
225
+
226
+ class TransformerOCRParagraphModel(nn.Module):
227
+ """DenseNet121-Transformer for end-to-end paragraph recognition."""
228
+
229
+ def __init__(self, vocab_size, hidden_size=256, nhead=8,
230
+ num_encoder_layers=3, num_decoder_layers=6,
231
+ dim_feedforward=2048, dropout=0.0,
232
+ use_upsample=True, max_seq_len=555):
233
+ super().__init__()
234
+
235
+ self.max_seq_len = max_seq_len
236
+ self.vocab_size = vocab_size
237
+
238
+ self.feature_extractor = CNNFeatureExtractor(
239
+ output_dim=hidden_size, use_upsample=use_upsample)
240
+
241
+ self.pos_encoder_2d = PositionalEncoding2D(hidden_size)
242
+ self.pos_decoder_1d = PositionalEncoding1D(hidden_size, max_len=max_seq_len)
243
+
244
+ encoder_layer = nn.TransformerEncoderLayer(
245
+ d_model=hidden_size, nhead=nhead,
246
+ dim_feedforward=dim_feedforward, dropout=dropout,
247
+ batch_first=True)
248
+ self.transformer_encoder = nn.TransformerEncoder(
249
+ encoder_layer, num_layers=num_encoder_layers)
250
+
251
+ decoder_layer = nn.TransformerDecoderLayer(
252
+ d_model=hidden_size, nhead=nhead,
253
+ dim_feedforward=dim_feedforward, dropout=dropout,
254
+ batch_first=True)
255
+ self.transformer_decoder = nn.TransformerDecoder(
256
+ decoder_layer, num_layers=num_decoder_layers)
257
+
258
+ self.token_embedding = nn.Embedding(vocab_size, hidden_size)
259
+ self.output_projection = nn.Linear(hidden_size, vocab_size)
260
+
261
+ def _generate_square_subsequent_mask(self, sz):
262
+ mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
263
+ return mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, 0.0)
264
+
265
+ def generate(self, img, max_length=None):
266
+ """Auto-regressive greedy generation for a single image."""
267
+ if max_length is None:
268
+ max_length = self.max_seq_len
269
+
270
+ self.eval()
271
+ with torch.no_grad():
272
+ if img.dim() == 3:
273
+ img = img.unsqueeze(0)
274
+
275
+ memory, feat_h, feat_w = self.feature_extractor(img)
276
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
277
+ memory = self.transformer_encoder(memory)
278
+
279
+ ys = torch.ones(1, 1).fill_(SOS_TOKEN).long().to(img.device)
280
+
281
+ for _ in range(max_length - 1):
282
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(ys))
283
+ tgt_mask = self._generate_square_subsequent_mask(ys.size(1)).to(img.device)
284
+ out = self.transformer_decoder(tgt_embedded, memory, tgt_mask=tgt_mask)
285
+ out = self.output_projection(out)
286
+
287
+ next_word = out[0, -1].argmax().item()
288
+ ys = torch.cat([ys, torch.ones(1, 1).long().fill_(next_word).to(img.device)], dim=1)
289
+
290
+ if next_word == EOS_TOKEN:
291
+ break
292
+
293
+ return ys[0]
294
+
295
+ def generate_batch(self, imgs, max_length=None):
296
+ """Auto-regressive greedy batch generation."""
297
+ if max_length is None:
298
+ max_length = self.max_seq_len
299
+
300
+ self.eval()
301
+ batch_size = imgs.size(0)
302
+
303
+ with torch.no_grad():
304
+ memory, feat_h, feat_w = self.feature_extractor(imgs)
305
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
306
+ memory = self.transformer_encoder(memory)
307
+
308
+ ys = torch.ones(batch_size, 1).fill_(SOS_TOKEN).long().to(imgs.device)
309
+ finished = torch.zeros(batch_size, dtype=torch.bool, device=imgs.device)
310
+
311
+ for _ in range(max_length - 1):
312
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(ys))
313
+ tgt_mask = self._generate_square_subsequent_mask(ys.size(1)).to(imgs.device)
314
+ out = self.transformer_decoder(tgt_embedded, memory, tgt_mask=tgt_mask)
315
+ out = self.output_projection(out)
316
+
317
+ next_tokens = out[:, -1].argmax(dim=-1)
318
+ next_tokens[finished] = PAD_TOKEN
319
+ ys = torch.cat([ys, next_tokens.unsqueeze(1)], dim=1)
320
+ finished = finished | (next_tokens == EOS_TOKEN)
321
+ if finished.all():
322
+ break
323
+
324
+ return ys
325
+
326
+
327
+ # ===============================
328
+ # Image Preprocessing
329
+ # ===============================
330
+
331
+ def preprocess_image(image_path, img_height, img_width):
332
+ """Load and preprocess a paragraph image.
333
+ Aspect-ratio-preserving resize, right-aligned on white canvas for RTL."""
334
+ image = Image.open(image_path).convert("RGB")
335
+ orig_w, orig_h = image.size
336
+
337
+ scale = min(img_width / orig_w, img_height / orig_h)
338
+ new_w = int(orig_w * scale)
339
+ new_h = int(orig_h * scale)
340
+ image = image.resize((new_w, new_h), Image.Resampling.LANCZOS)
341
+
342
+ canvas = Image.new("RGB", (img_width, img_height), color=(255, 255, 255))
343
+ x_offset = img_width - new_w # Right-align for RTL
344
+ canvas.paste(image, (x_offset, 0))
345
+
346
+ transform = transforms.Compose([
347
+ transforms.ToTensor(),
348
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))
349
+ ])
350
+ return transform(canvas)
351
+
352
+
353
+ # ===============================
354
+ # Config Loader
355
+ # ===============================
356
+
357
+ def load_config(config_path):
358
+ """Load architecture settings from config.json."""
359
+ with open(config_path, "r", encoding="utf-8") as f:
360
+ return json.load(f)
361
+
362
+
363
+ # ===============================
364
+ # Main
365
+ # ===============================
366
+
367
+ def main():
368
+ args = parse_args()
369
+
370
+ if args.image is None and args.image_dir is None:
371
+ print("Error: Provide --image or --image_dir")
372
+ return
373
+
374
+ # Device
375
+ if args.device:
376
+ device = torch.device(args.device)
377
+ else:
378
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
379
+ print(f"Device: {device}")
380
+
381
+ # Load config if provided (overrides CLI args)
382
+ if args.config_path and os.path.exists(args.config_path):
383
+ config = load_config(args.config_path)
384
+ print(f"Loaded config from: {args.config_path}")
385
+ args.hidden_size = config.get("hidden_size", args.hidden_size)
386
+ args.encoder_layers = config.get("num_encoder_layers", args.encoder_layers)
387
+ args.decoder_layers = config.get("num_decoder_layers", args.decoder_layers)
388
+ args.num_heads = config.get("num_attention_heads", args.num_heads)
389
+ args.ff_dim = config.get("feed_forward_dim", args.ff_dim)
390
+ args.max_seq_len = config.get("max_sequence_length", args.max_seq_len)
391
+ args.img_height = config.get("image_height", args.img_height)
392
+ args.img_width = config.get("image_width", args.img_width)
393
+ if "use_upsample" in config:
394
+ args.use_upsample = config["use_upsample"]
395
+ args.no_upsample = not config["use_upsample"]
396
+
397
+ use_upsample = args.use_upsample and not args.no_upsample
398
+
399
+ # Vocabulary
400
+ char_list, idx_to_char = load_vocabulary(args.vocab_path)
401
+ vocab_size = len(char_list)
402
+ print(f"Vocabulary: {vocab_size} tokens")
403
+
404
+ # Model
405
+ model = TransformerOCRParagraphModel(
406
+ vocab_size=vocab_size,
407
+ hidden_size=args.hidden_size,
408
+ nhead=args.num_heads,
409
+ num_encoder_layers=args.encoder_layers,
410
+ num_decoder_layers=args.decoder_layers,
411
+ dim_feedforward=args.ff_dim,
412
+ use_upsample=use_upsample,
413
+ max_seq_len=args.max_seq_len
414
+ ).to(device)
415
+
416
+ # Load weights
417
+ print(f"Loading weights: {args.model_path}")
418
+ if args.model_path.endswith(".safetensors"):
419
+ from safetensors.torch import load_file
420
+ state_dict = load_file(args.model_path)
421
+ else:
422
+ checkpoint = torch.load(args.model_path, map_location=device)
423
+ state_dict = checkpoint.get("model_state_dict", checkpoint)
424
+
425
+ # Handle PE size mismatches
426
+ model_state = model.state_dict()
427
+ filtered = {}
428
+ for key, value in state_dict.items():
429
+ if key in model_state:
430
+ if value.shape == model_state[key].shape:
431
+ filtered[key] = value
432
+ model.load_state_dict(filtered, strict=False)
433
+
434
+ model.eval()
435
+ total_params = sum(p.numel() for n, p in model.named_parameters() if '.pe' not in n)
436
+ print(f"Model loaded: {total_params:,} parameters")
437
+ print(f"Upsample: {'ON' if use_upsample else 'OFF'}")
438
+ print(f"Image size: {args.img_height} x {args.img_width}")
439
+ print(f"Max sequence length: {args.max_seq_len}")
440
+
441
+ # Collect images
442
+ image_paths = []
443
+ if args.image:
444
+ image_paths = [args.image]
445
+ elif args.image_dir:
446
+ for ext in ("*.tif", "*.tiff", "*.png", "*.jpg", "*.jpeg", "*.bmp"):
447
+ image_paths.extend(glob.glob(os.path.join(args.image_dir, ext)))
448
+ image_paths.extend(glob.glob(os.path.join(args.image_dir, ext.upper())))
449
+ image_paths = sorted(list(set(image_paths)))
450
+
451
+ if not image_paths:
452
+ print("No images found.")
453
+ return
454
+
455
+ print(f"\nProcessing {len(image_paths)} image(s)...\n")
456
+
457
+ # Output file
458
+ out_file = None
459
+ if args.output_file:
460
+ out_file = open(args.output_file, "w", encoding="utf-8")
461
+
462
+ total_time = 0
463
+
464
+ for img_path in image_paths:
465
+ filename = os.path.basename(img_path)
466
+
467
+ # Preprocess
468
+ tensor = preprocess_image(img_path, args.img_height, args.img_width).to(device)
469
+
470
+ # Inference with timing
471
+ if torch.cuda.is_available():
472
+ torch.cuda.synchronize()
473
+ start = time.perf_counter()
474
+
475
+ output = model.generate(tensor)
476
+
477
+ if torch.cuda.is_available():
478
+ torch.cuda.synchronize()
479
+ elapsed = time.perf_counter() - start
480
+ total_time += elapsed
481
+
482
+ # Decode
483
+ text = decode_output(output, idx_to_char)
484
+ lines = text.split('\n')
485
+
486
+ # Display
487
+ print(f"{'='*60}")
488
+ print(f"File: {filename}")
489
+ if args.show_timing:
490
+ print(f"Time: {elapsed*1000:.1f} ms")
491
+ print(f"Lines detected: {len(lines)}")
492
+ print(f"{'─'*60}")
493
+ for i, line in enumerate(lines):
494
+ print(f" Line {i+1}: {line}")
495
+ print()
496
+
497
+ # Save to file
498
+ if out_file:
499
+ out_file.write(f"# {filename}\n")
500
+ out_file.write(text + "\n\n")
501
+
502
+ # Summary
503
+ print(f"{'='*60}")
504
+ print(f"Done. {len(image_paths)} image(s) processed.")
505
+ if args.show_timing:
506
+ avg_ms = (total_time / len(image_paths)) * 1000
507
+ print(f"Average inference: {avg_ms:.1f} ms/image")
508
+ print(f"Total time: {total_time:.2f} s")
509
+
510
+ if out_file:
511
+ out_file.close()
512
+ print(f"Predictions saved to: {args.output_file}")
513
+
514
+
515
+ if __name__ == "__main__":
516
+ main()
Scripts/pretrain.py ADDED
@@ -0,0 +1,987 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Kurdish Handwritten Paragraph Recognition - Pre-training Script
3
+ DenseNet121-Transformer Architecture with Curriculum Learning
4
+
5
+ Pre-trains the model on synthetic paragraph images before fine-tuning
6
+ on real handwritten paragraphs.
7
+
8
+ Usage:
9
+ python pretrain.py --data_dir ./data/SyntheticParagraphs_12000 --vocab_path ./vocab.json
10
+ python pretrain.py --data_dir ./data/SyntheticParagraphs_12000 --vocab_path ./vocab.json --no_curriculum
11
+ """
12
+
13
+ import os
14
+ import glob
15
+ import time
16
+ import argparse
17
+ import json
18
+ import math
19
+ import random
20
+ import numpy as np
21
+ from PIL import Image
22
+ from datetime import datetime
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.optim as optim
27
+ import torch.utils.data as data
28
+ import torchvision.transforms as transforms
29
+ import torchvision.models as models
30
+ from torchvision.transforms import InterpolationMode
31
+ from torch.nn import functional as F
32
+ from torch.amp import autocast, GradScaler
33
+ from tqdm import tqdm
34
+ import gc
35
+
36
+
37
+ # ===============================
38
+ # Argument Parser
39
+ # ===============================
40
+
41
+ def parse_args():
42
+ parser = argparse.ArgumentParser(
43
+ description="Kurdish Handwritten Paragraph Recognition - Pre-training")
44
+
45
+ # Data paths
46
+ parser.add_argument("--data_dir", type=str, required=True,
47
+ help="Root directory with Training/ and Validation/ subfolders")
48
+ parser.add_argument("--vocab_path", type=str, required=True,
49
+ help="Path to vocabulary JSON file (vocab.json)")
50
+
51
+ # Image dimensions
52
+ parser.add_argument("--img_height", type=int, default=600)
53
+ parser.add_argument("--img_width", type=int, default=1235)
54
+ parser.add_argument("--max_seq_len", type=int, default=555)
55
+
56
+ # Training hyperparameters
57
+ parser.add_argument("--batch_size", type=int, default=16)
58
+ parser.add_argument("--num_epochs", type=int, default=80)
59
+ parser.add_argument("--learning_rate", type=float, default=1e-4)
60
+ parser.add_argument("--grad_clip", type=float, default=5.0)
61
+ parser.add_argument("--weight_decay", type=float, default=1e-4)
62
+ parser.add_argument("--seed", type=int, default=42)
63
+
64
+ # Model architecture
65
+ parser.add_argument("--hidden_size", type=int, default=256)
66
+ parser.add_argument("--encoder_layers", type=int, default=3)
67
+ parser.add_argument("--decoder_layers", type=int, default=6)
68
+ parser.add_argument("--num_heads", type=int, default=8)
69
+ parser.add_argument("--ff_dim", type=int, default=2048)
70
+ parser.add_argument("--dropout", type=float, default=0.3)
71
+ parser.add_argument("--use_upsample", action="store_true", default=True,
72
+ help="Enable horizontal upsampling layer (default: True)")
73
+ parser.add_argument("--no_upsample", action="store_true",
74
+ help="Disable horizontal upsampling layer")
75
+
76
+ # Teacher forcing
77
+ parser.add_argument("--tf_noise_rate", type=float, default=0.15,
78
+ help="Teacher forcing noise rate (default: 0.15)")
79
+
80
+ # Curriculum learning
81
+ parser.add_argument("--no_curriculum", action="store_true",
82
+ help="Disable curriculum learning (train on all data from start)")
83
+
84
+ # LR scheduler
85
+ parser.add_argument("--lr_step_size", type=int, default=15,
86
+ help="StepLR step size in epochs")
87
+ parser.add_argument("--lr_gamma", type=float, default=0.5,
88
+ help="StepLR decay factor")
89
+
90
+ # Early stopping
91
+ parser.add_argument("--patience", type=int, default=15)
92
+
93
+ # Training options
94
+ parser.add_argument("--mixed_precision", action="store_true", default=True)
95
+ parser.add_argument("--no_mixed_precision", action="store_true")
96
+ parser.add_argument("--no_aug", action="store_true",
97
+ help="Disable data augmentation")
98
+
99
+ # CER computation
100
+ parser.add_argument("--cer_every", type=int, default=5,
101
+ help="Compute train CER every N epochs (0 to disable)")
102
+ parser.add_argument("--cer_max_samples", type=int, default=256,
103
+ help="Max samples for train CER computation")
104
+
105
+ # Output
106
+ parser.add_argument("--output_dir", type=str, default="./output",
107
+ help="Directory to save model and logs")
108
+ parser.add_argument("--model_name", type=str, default="pretrained_model",
109
+ help="Base name for saved model file")
110
+
111
+ return parser.parse_args()
112
+
113
+
114
+ # ===============================
115
+ # Vocabulary Loader
116
+ # ===============================
117
+
118
+ def load_vocabulary(vocab_path):
119
+ """Load vocabulary from JSON file."""
120
+ with open(vocab_path, "r", encoding="utf-8") as f:
121
+ vocab_data = json.load(f)
122
+
123
+ if "vocab_list" in vocab_data:
124
+ char_list = vocab_data["vocab_list"]
125
+ elif "char_to_idx" in vocab_data:
126
+ mapping = vocab_data["char_to_idx"]
127
+ char_list = [None] * len(mapping)
128
+ for char, idx in mapping.items():
129
+ char_list[idx] = char
130
+ else:
131
+ raise ValueError("Vocabulary JSON must contain 'vocab_list' or 'char_to_idx'")
132
+
133
+ char_to_idx = {char: idx for idx, char in enumerate(char_list)}
134
+ idx_to_char = {idx: char for idx, char in enumerate(char_list)}
135
+
136
+ return char_list, char_to_idx, idx_to_char
137
+
138
+
139
+ # Special token indices (fixed by convention)
140
+ PAD_TOKEN = 0
141
+ SOS_TOKEN = 1
142
+ EOS_TOKEN = 2
143
+
144
+
145
+ # ===============================
146
+ # Helper Functions
147
+ # ===============================
148
+
149
+ def tensor_to_text(tensor, idx_to_char):
150
+ """Convert a tensor of character indices to text."""
151
+ if isinstance(tensor, torch.Tensor):
152
+ tensor = tensor.cpu().tolist()
153
+ text = ""
154
+ for idx in tensor:
155
+ if idx == PAD_TOKEN or idx == SOS_TOKEN:
156
+ continue
157
+ if idx == EOS_TOKEN:
158
+ break
159
+ if idx in idx_to_char:
160
+ text += idx_to_char[idx]
161
+ return text
162
+
163
+
164
+ def count_lines_in_text(text):
165
+ """Count the number of lines in a paragraph text."""
166
+ if not text:
167
+ return 0
168
+ return text.count('\n') + 1
169
+
170
+
171
+ # ===============================
172
+ # Curriculum Learning
173
+ # ===============================
174
+
175
+ # Default schedule: progressive difficulty over 80 epochs
176
+ DEFAULT_CURRICULUM = [
177
+ (1, 8, 1, 1), # Epochs 1-8: 1 line only
178
+ (9, 16, 1, 2), # Epochs 9-16: 1-2 lines
179
+ (17, 28, 2, 3), # Epochs 17-28: 2-3 lines
180
+ (29, 40, 2, 4), # Epochs 29-40: 2-4 lines
181
+ (41, 52, 3, 5), # Epochs 41-52: 3-5 lines
182
+ (53, 64, 3, 6), # Epochs 53-64: 3-6 lines
183
+ (65, 80, 4, 7), # Epochs 65-80: 4-7 lines (full complexity)
184
+ ]
185
+
186
+
187
+ def categorize_paragraphs_by_lines(data_dir):
188
+ """Group paragraph samples by their line count."""
189
+ categories = {}
190
+
191
+ image_files = []
192
+ for ext in ["*.tif", "*.tiff", "*.png", "*.jpg", "*.jpeg"]:
193
+ image_files.extend(glob.glob(os.path.join(data_dir, ext)))
194
+ image_files.extend(glob.glob(os.path.join(data_dir, ext.upper())))
195
+ image_files = sorted(list(set(image_files)))
196
+
197
+ for img_path in image_files:
198
+ label_path = os.path.splitext(img_path)[0] + ".txt"
199
+ if not os.path.exists(label_path):
200
+ continue
201
+
202
+ try:
203
+ with open(label_path, "r", encoding="utf-8") as f:
204
+ text = f.read().strip()
205
+ except Exception:
206
+ try:
207
+ with open(label_path, "r", encoding="utf-8-sig") as f:
208
+ text = f.read().strip()
209
+ except Exception:
210
+ continue
211
+
212
+ num_lines = count_lines_in_text(text)
213
+ if num_lines not in categories:
214
+ categories[num_lines] = []
215
+ categories[num_lines].append((img_path, text))
216
+
217
+ return categories
218
+
219
+
220
+ def get_curriculum_stage(epoch, schedule):
221
+ """Get the min/max line range for the current epoch."""
222
+ for start_epoch, end_epoch, min_lines, max_lines in schedule:
223
+ if start_epoch <= epoch <= end_epoch:
224
+ return min_lines, max_lines
225
+ return 1, 7
226
+
227
+
228
+ def filter_paragraphs_by_lines(categories, min_lines, max_lines):
229
+ """Filter paragraphs to include only those within the line range."""
230
+ filtered = []
231
+ for num_lines, paragraphs in categories.items():
232
+ if min_lines <= num_lines <= max_lines:
233
+ filtered.extend(paragraphs)
234
+ return filtered
235
+
236
+
237
+ # ===============================
238
+ # Dataset
239
+ # ===============================
240
+
241
+ class KurdishParagraphDataset(data.Dataset):
242
+ """Dataset for Kurdish handwritten paragraph images."""
243
+
244
+ def __init__(self, root_dir=None, transform=None, max_samples=None,
245
+ max_seq_len=555, filtered_data=None, img_height=600,
246
+ img_width=1235, char_to_idx=None):
247
+ self.transform = transform
248
+ self.max_seq_len = max_seq_len
249
+ self.img_height = img_height
250
+ self.img_width = img_width
251
+ self.char_to_idx = char_to_idx
252
+
253
+ if filtered_data is not None:
254
+ self.data = filtered_data
255
+ else:
256
+ self.data = []
257
+ image_files = []
258
+ for ext in ["*.tif", "*.tiff", "*.png", "*.jpg", "*.jpeg"]:
259
+ image_files.extend(glob.glob(os.path.join(root_dir, ext)))
260
+ image_files.extend(glob.glob(os.path.join(root_dir, ext.upper())))
261
+ image_files = sorted(list(set(image_files)))
262
+
263
+ for img_path in image_files:
264
+ label_path = os.path.splitext(img_path)[0] + ".txt"
265
+ if not os.path.exists(label_path):
266
+ continue
267
+ try:
268
+ with open(label_path, "r", encoding="utf-8") as f:
269
+ text = f.read().strip()
270
+ except Exception:
271
+ try:
272
+ with open(label_path, "r", encoding="utf-8-sig") as f:
273
+ text = f.read().strip()
274
+ except Exception:
275
+ continue
276
+ if len(text) > 0:
277
+ self.data.append((img_path, text))
278
+
279
+ if max_samples and max_samples < len(self.data):
280
+ random.shuffle(self.data)
281
+ self.data = self.data[:max_samples]
282
+
283
+ label = "filtered" if filtered_data else root_dir
284
+ print(f" Loaded {len(self.data)} paragraph images ({label})")
285
+
286
+ def __len__(self):
287
+ return len(self.data)
288
+
289
+ def __getitem__(self, idx):
290
+ img_path, text = self.data[idx]
291
+
292
+ image = Image.open(img_path).convert("RGB")
293
+ orig_width, orig_height = image.size
294
+
295
+ # Aspect-ratio-preserving resize
296
+ scale = min(self.img_width / orig_width, self.img_height / orig_height)
297
+ new_width = int(orig_width * scale)
298
+ new_height = int(orig_height * scale)
299
+ image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
300
+
301
+ # Right-aligned on white canvas (RTL script)
302
+ canvas = Image.new('RGB', (self.img_width, self.img_height), (255, 255, 255))
303
+ x_offset = self.img_width - new_width
304
+ canvas.paste(image, (x_offset, 0))
305
+
306
+ if self.transform:
307
+ canvas = self.transform(canvas)
308
+
309
+ # Encode text to indices
310
+ indices = ([SOS_TOKEN] +
311
+ [self.char_to_idx.get(c, self.char_to_idx.get(" ", 0)) for c in text] +
312
+ [EOS_TOKEN])
313
+ if len(indices) > self.max_seq_len:
314
+ indices = indices[:self.max_seq_len - 1] + [EOS_TOKEN]
315
+
316
+ target = torch.LongTensor(indices)
317
+ return canvas, target, len(indices), text
318
+
319
+
320
+ def collate_fn(batch):
321
+ """Collate function with padding for variable-length targets."""
322
+ batch.sort(key=lambda x: x[2], reverse=True)
323
+ images, targets, lengths, texts = zip(*batch)
324
+
325
+ images = torch.stack(images, 0)
326
+ max_length = max(lengths)
327
+
328
+ padded = torch.ones(len(targets), max_length).long() * PAD_TOKEN
329
+ for i, target in enumerate(targets):
330
+ padded[i, :lengths[i]] = target[:lengths[i]]
331
+
332
+ return images, padded, torch.LongTensor(lengths), texts
333
+
334
+
335
+ # ===============================
336
+ # Augmentation
337
+ # ===============================
338
+
339
+ def build_train_transform():
340
+ """Training augmentation pipeline for paragraph images."""
341
+ class ParagraphTransform:
342
+ def __call__(self, img):
343
+ if random.random() < 0.5:
344
+ img = transforms.ColorJitter(brightness=0.15, contrast=0.15)(img)
345
+
346
+ if random.random() < 0.4:
347
+ img = transforms.RandomAffine(
348
+ degrees=2, translate=(0.02, 0.02),
349
+ scale=(0.98, 1.02), shear=(-3, 3),
350
+ interpolation=InterpolationMode.BILINEAR, fill=255)(img)
351
+
352
+ if random.random() < 0.2:
353
+ img = transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 0.5))(img)
354
+
355
+ img = transforms.ToTensor()(img)
356
+
357
+ if random.random() < 0.3:
358
+ noise = torch.randn_like(img) * 0.01
359
+ img = torch.clamp(img + noise, 0.0, 1.0)
360
+
361
+ img = transforms.Normalize(
362
+ (0.485, 0.456, 0.406), (0.229, 0.224, 0.225))(img)
363
+ return img
364
+
365
+ return ParagraphTransform()
366
+
367
+
368
+ def build_eval_transform():
369
+ """Evaluation transform (normalisation only)."""
370
+ return transforms.Compose([
371
+ transforms.ToTensor(),
372
+ transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))
373
+ ])
374
+
375
+
376
+ # ===============================
377
+ # Positional Encodings
378
+ # ===============================
379
+
380
+ class PositionalEncoding2D(nn.Module):
381
+ """2D sinusoidal positional encoding for visual feature maps."""
382
+
383
+ def __init__(self, d_model, max_h=100, max_w=300):
384
+ super().__init__()
385
+ pe = torch.zeros(max_h, max_w, d_model)
386
+ d_half = d_model // 2
387
+
388
+ pos_h = torch.arange(0, max_h, dtype=torch.float).unsqueeze(1)
389
+ div_h = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
390
+ pe_h = torch.zeros(max_h, d_half)
391
+ pe_h[:, 0::2] = torch.sin(pos_h * div_h)
392
+ pe_h[:, 1::2] = torch.cos(pos_h * div_h)
393
+
394
+ pos_w = torch.arange(0, max_w, dtype=torch.float).unsqueeze(1)
395
+ div_w = torch.exp(torch.arange(0, d_half, 2).float() * (-math.log(10000.0) / d_half))
396
+ pe_w = torch.zeros(max_w, d_half)
397
+ pe_w[:, 0::2] = torch.sin(pos_w * div_w)
398
+ pe_w[:, 1::2] = torch.cos(pos_w * div_w)
399
+
400
+ for h in range(max_h):
401
+ for w in range(max_w):
402
+ pe[h, w, :d_half] = pe_h[h]
403
+ pe[h, w, d_half:] = pe_w[w]
404
+
405
+ self.register_buffer('pe', pe)
406
+
407
+ def forward(self, x, height, width):
408
+ _, seq_len, d_model = x.shape
409
+ pe_2d = self.pe[:height, :width, :].reshape(height * width, d_model)
410
+ if seq_len <= pe_2d.size(0):
411
+ pe_2d = pe_2d[:seq_len]
412
+ else:
413
+ pad = torch.zeros(seq_len - pe_2d.size(0), d_model, device=x.device)
414
+ pe_2d = torch.cat([pe_2d, pad], dim=0)
415
+ return x + pe_2d.unsqueeze(0)
416
+
417
+
418
+ class PositionalEncoding1D(nn.Module):
419
+ """1D sinusoidal positional encoding for decoder sequences."""
420
+
421
+ def __init__(self, d_model, max_len=1000):
422
+ super().__init__()
423
+ pe = torch.zeros(max_len, d_model)
424
+ position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
425
+ div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
426
+ pe[:, 0::2] = torch.sin(position * div_term)
427
+ pe[:, 1::2] = torch.cos(position * div_term)
428
+ self.register_buffer('pe', pe.unsqueeze(0))
429
+
430
+ def forward(self, x):
431
+ return x + self.pe[:, :x.size(1), :]
432
+
433
+
434
+ # ===============================
435
+ # CNN Feature Extractor
436
+ # ===============================
437
+
438
+ class CNNFeatureExtractor(nn.Module):
439
+ """DenseNet-121 backbone with optional horizontal upsampling."""
440
+
441
+ def __init__(self, output_dim=256, use_upsample=True):
442
+ super().__init__()
443
+ densenet = models.densenet121(weights=models.DenseNet121_Weights.DEFAULT)
444
+ self.features = densenet.features
445
+ backbone_channels = 1024
446
+
447
+ if use_upsample:
448
+ self.upsample = nn.Sequential(
449
+ nn.ConvTranspose2d(backbone_channels, 512,
450
+ kernel_size=(1, 4), stride=(1, 2), padding=(0, 1)),
451
+ nn.BatchNorm2d(512),
452
+ nn.ReLU(inplace=True))
453
+ adapt_in = 512
454
+ else:
455
+ self.upsample = None
456
+ adapt_in = backbone_channels
457
+
458
+ self.adaptation = nn.Sequential(
459
+ nn.Conv2d(adapt_in, output_dim, kernel_size=1),
460
+ nn.BatchNorm2d(output_dim),
461
+ nn.ReLU(inplace=True))
462
+
463
+ def forward(self, x):
464
+ features = F.relu(self.features(x), inplace=True)
465
+ if self.upsample is not None:
466
+ features = self.upsample(features)
467
+ features = self.adaptation(features)
468
+ b, c, h, w = features.shape
469
+ return features.view(b, c, h * w).permute(0, 2, 1), h, w
470
+
471
+
472
+ # ===============================
473
+ # Transformer OCR Model
474
+ # ===============================
475
+
476
+ class TransformerOCRParagraphModel(nn.Module):
477
+ """
478
+ DenseNet121-Transformer for end-to-end paragraph recognition.
479
+
480
+ Architecture:
481
+ 1. DenseNet-121 CNN + optional horizontal upsample
482
+ 2. 2D positional encoding + Transformer encoder
483
+ 3. Transformer decoder with 1D positional encoding
484
+ 4. Linear output projection
485
+ """
486
+
487
+ def __init__(self, vocab_size, hidden_size=256, nhead=8,
488
+ num_encoder_layers=3, num_decoder_layers=6,
489
+ dim_feedforward=2048, dropout=0.3,
490
+ use_upsample=True, max_seq_len=555,
491
+ tf_noise_rate=0.15):
492
+ super().__init__()
493
+
494
+ self.max_seq_len = max_seq_len
495
+ self.vocab_size = vocab_size
496
+ self.tf_noise_rate = tf_noise_rate
497
+
498
+ self.feature_extractor = CNNFeatureExtractor(
499
+ output_dim=hidden_size, use_upsample=use_upsample)
500
+
501
+ self.pos_encoder_2d = PositionalEncoding2D(hidden_size)
502
+ self.pos_decoder_1d = PositionalEncoding1D(hidden_size, max_len=max_seq_len)
503
+
504
+ encoder_layer = nn.TransformerEncoderLayer(
505
+ d_model=hidden_size, nhead=nhead,
506
+ dim_feedforward=dim_feedforward, dropout=dropout,
507
+ batch_first=True)
508
+ self.transformer_encoder = nn.TransformerEncoder(
509
+ encoder_layer, num_layers=num_encoder_layers)
510
+
511
+ decoder_layer = nn.TransformerDecoderLayer(
512
+ d_model=hidden_size, nhead=nhead,
513
+ dim_feedforward=dim_feedforward, dropout=dropout,
514
+ batch_first=True)
515
+ self.transformer_decoder = nn.TransformerDecoder(
516
+ decoder_layer, num_layers=num_decoder_layers)
517
+
518
+ self.token_embedding = nn.Embedding(vocab_size, hidden_size)
519
+ self.output_projection = nn.Linear(hidden_size, vocab_size)
520
+ self.hidden_size = hidden_size
521
+
522
+ nn.init.xavier_uniform_(self.token_embedding.weight)
523
+ nn.init.xavier_uniform_(self.output_projection.weight)
524
+
525
+ def _generate_square_subsequent_mask(self, sz):
526
+ mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
527
+ return mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, 0.0)
528
+
529
+ def _add_teacher_forcing_noise(self, tgt_input):
530
+ """Replace random tokens to build decoder robustness."""
531
+ if self.tf_noise_rate <= 0 or not self.training:
532
+ return tgt_input
533
+ noise_mask = (torch.rand_like(tgt_input.float()) < self.tf_noise_rate)
534
+ noise_mask = noise_mask & (tgt_input != PAD_TOKEN) & (tgt_input != SOS_TOKEN)
535
+ random_tokens = torch.randint(3, self.vocab_size, tgt_input.shape, device=tgt_input.device)
536
+ return torch.where(noise_mask, random_tokens, tgt_input)
537
+
538
+ def forward(self, src, tgt, tgt_key_padding_mask=None):
539
+ # Encode
540
+ memory, feat_h, feat_w = self.feature_extractor(src)
541
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
542
+ memory = self.transformer_encoder(memory)
543
+
544
+ # Decode with teacher forcing
545
+ tgt_input = self._add_teacher_forcing_noise(tgt[:, :-1])
546
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(tgt_input))
547
+
548
+ tgt_mask = self._generate_square_subsequent_mask(tgt_embedded.size(1)).to(src.device)
549
+ tgt_pad_mask = tgt_key_padding_mask[:, :-1] if tgt_key_padding_mask is not None else None
550
+
551
+ output = self.transformer_decoder(
552
+ tgt_embedded, memory,
553
+ tgt_mask=tgt_mask, tgt_key_padding_mask=tgt_pad_mask)
554
+
555
+ return self.output_projection(output)
556
+
557
+ def generate_batch(self, imgs, max_length=None):
558
+ """Auto-regressive greedy batch generation."""
559
+ if max_length is None:
560
+ max_length = self.max_seq_len
561
+ self.eval()
562
+ batch_size = imgs.size(0)
563
+
564
+ with torch.no_grad():
565
+ memory, feat_h, feat_w = self.feature_extractor(imgs)
566
+ memory = self.pos_encoder_2d(memory, feat_h, feat_w)
567
+ memory = self.transformer_encoder(memory)
568
+
569
+ ys = torch.ones(batch_size, 1).fill_(SOS_TOKEN).long().to(imgs.device)
570
+ finished = torch.zeros(batch_size, dtype=torch.bool, device=imgs.device)
571
+
572
+ for _ in range(max_length - 1):
573
+ tgt_embedded = self.pos_decoder_1d(self.token_embedding(ys))
574
+ tgt_mask = self._generate_square_subsequent_mask(ys.size(1)).to(imgs.device)
575
+ out = self.transformer_decoder(tgt_embedded, memory, tgt_mask=tgt_mask)
576
+ out = self.output_projection(out)
577
+
578
+ next_tokens = out[:, -1].argmax(dim=-1)
579
+ next_tokens[finished] = PAD_TOKEN
580
+ ys = torch.cat([ys, next_tokens.unsqueeze(1)], dim=1)
581
+ finished = finished | (next_tokens == EOS_TOKEN)
582
+ if finished.all():
583
+ break
584
+
585
+ return [tensor_to_text(seq, idx_to_char) for seq in ys]
586
+
587
+
588
+ # ===============================
589
+ # Metrics
590
+ # ===============================
591
+
592
+ def levenshtein_distance(s1, s2):
593
+ if len(s1) < len(s2):
594
+ return levenshtein_distance(s2, s1)
595
+ if len(s2) == 0:
596
+ return len(s1)
597
+ prev = range(len(s2) + 1)
598
+ for c1 in s1:
599
+ curr = [prev[0] + 1]
600
+ for j, c2 in enumerate(s2):
601
+ curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (c1 != c2)))
602
+ prev = curr
603
+ return prev[-1]
604
+
605
+
606
+ def calculate_cer(preds, targets):
607
+ total_dist = sum(levenshtein_distance(p, t) for p, t in zip(preds, targets))
608
+ total_chars = sum(len(t) for t in targets)
609
+ return total_dist / max(1, total_chars)
610
+
611
+
612
+ def calculate_wer(preds, targets):
613
+ total_dist = sum(levenshtein_distance(p.split(), t.split()) for p, t in zip(preds, targets))
614
+ total_words = sum(len(t.split()) for t in targets)
615
+ return total_dist / max(1, total_words)
616
+
617
+
618
+ def evaluate_cer_batch(model, dataloader, device, idx_to_char, max_samples=None):
619
+ """Compute CER using batch generation."""
620
+ model.eval()
621
+ all_preds, all_targets = [], []
622
+ count = 0
623
+
624
+ with torch.no_grad():
625
+ for images, _, _, texts in tqdm(dataloader, desc="Computing CER"):
626
+ images = images.to(device)
627
+ if max_samples and count + images.size(0) > max_samples:
628
+ images = images[:max_samples - count]
629
+ texts = texts[:max_samples - count]
630
+
631
+ preds = model.generate_batch(images)
632
+ all_preds.extend(preds)
633
+ all_targets.extend(texts)
634
+ count += len(preds)
635
+
636
+ if max_samples and count >= max_samples:
637
+ break
638
+
639
+ return calculate_cer(all_preds, all_targets)
640
+
641
+
642
+ def evaluate_full(model, dataloader, device, idx_to_char):
643
+ """Full evaluation returning CER, WER, predictions, and targets."""
644
+ model.eval()
645
+ all_preds, all_targets = [], []
646
+
647
+ with torch.no_grad():
648
+ for images, _, _, texts in tqdm(dataloader, desc="Evaluating"):
649
+ images = images.to(device)
650
+ preds = model.generate_batch(images)
651
+ all_preds.extend(preds)
652
+ all_targets.extend(texts)
653
+
654
+ cer = calculate_cer(all_preds, all_targets)
655
+ wer = calculate_wer(all_preds, all_targets)
656
+ return cer, wer, all_preds, all_targets
657
+
658
+
659
+ # ===============================
660
+ # Early Stopping
661
+ # ===============================
662
+
663
+ class EarlyStopping:
664
+ def __init__(self, patience=15):
665
+ self.patience = patience
666
+ self.counter = 0
667
+ self.best_cer = float('inf')
668
+ self.early_stop = False
669
+
670
+ def __call__(self, val_cer, model, epoch, path):
671
+ if val_cer < self.best_cer:
672
+ self.best_cer = val_cer
673
+ self.counter = 0
674
+ torch.save({
675
+ 'epoch': epoch,
676
+ 'model_state_dict': model.state_dict(),
677
+ 'val_cer': val_cer
678
+ }, path)
679
+ print(f" Model saved (Val CER: {val_cer:.4f})")
680
+ else:
681
+ self.counter += 1
682
+ print(f" Early stopping: {self.counter}/{self.patience}")
683
+ if self.counter >= self.patience:
684
+ self.early_stop = True
685
+ print(" Early stopping triggered.")
686
+
687
+ def reset(self):
688
+ self.counter = 0
689
+
690
+
691
+ # ===============================
692
+ # Training Functions
693
+ # ===============================
694
+
695
+ def train_epoch(model, dataloader, optimizer, criterion, device, scaler,
696
+ use_mixed_precision=True, grad_clip=5.0):
697
+ """Train for one epoch."""
698
+ model.train()
699
+ epoch_loss = 0
700
+
701
+ for images, targets, _, _ in tqdm(dataloader, desc="Training"):
702
+ images, targets = images.to(device), targets.to(device)
703
+ tgt_pad_mask = (targets == PAD_TOKEN).to(device)
704
+
705
+ optimizer.zero_grad()
706
+
707
+ if use_mixed_precision:
708
+ with autocast(device_type='cuda'):
709
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
710
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
711
+ targets[:, 1:].reshape(-1))
712
+ scaler.scale(loss).backward()
713
+ scaler.unscale_(optimizer)
714
+ torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
715
+ scaler.step(optimizer)
716
+ scaler.update()
717
+ else:
718
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
719
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
720
+ targets[:, 1:].reshape(-1))
721
+ loss.backward()
722
+ torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
723
+ optimizer.step()
724
+
725
+ epoch_loss += loss.item()
726
+
727
+ return epoch_loss / len(dataloader)
728
+
729
+
730
+ def evaluate_loss(model, dataloader, criterion, device, use_mixed_precision=True):
731
+ """Evaluate model loss."""
732
+ model.eval()
733
+ epoch_loss = 0
734
+
735
+ with torch.no_grad():
736
+ for images, targets, _, _ in dataloader:
737
+ images, targets = images.to(device), targets.to(device)
738
+ tgt_pad_mask = (targets == PAD_TOKEN).to(device)
739
+
740
+ if use_mixed_precision:
741
+ with autocast(device_type='cuda'):
742
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
743
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
744
+ targets[:, 1:].reshape(-1))
745
+ else:
746
+ outputs = model(images, targets, tgt_key_padding_mask=tgt_pad_mask)
747
+ loss = criterion(outputs.reshape(-1, outputs.shape[-1]),
748
+ targets[:, 1:].reshape(-1))
749
+
750
+ epoch_loss += loss.item()
751
+
752
+ return epoch_loss / len(dataloader)
753
+
754
+
755
+ # ===============================
756
+ # Main
757
+ # ===============================
758
+
759
+ def main():
760
+ global idx_to_char # Used by generate_batch -> tensor_to_text
761
+
762
+ args = parse_args()
763
+
764
+ # Handle flag conflicts
765
+ use_upsample = args.use_upsample and not args.no_upsample
766
+ use_mixed_precision = args.mixed_precision and not args.no_mixed_precision
767
+ use_curriculum = not args.no_curriculum
768
+
769
+ # Seeds
770
+ torch.manual_seed(args.seed)
771
+ random.seed(args.seed)
772
+ np.random.seed(args.seed)
773
+
774
+ # Device
775
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
776
+ print(f"Device: {device}")
777
+ if torch.cuda.is_available():
778
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
779
+
780
+ # Output directory
781
+ os.makedirs(args.output_dir, exist_ok=True)
782
+
783
+ # Vocabulary
784
+ char_list, char_to_idx, idx_to_char = load_vocabulary(args.vocab_path)
785
+ vocab_size = len(char_list)
786
+ print(f"Vocabulary size: {vocab_size}")
787
+
788
+ # Data directories
789
+ train_dir = os.path.join(args.data_dir, "Training")
790
+ val_dir = os.path.join(args.data_dir, "Validation")
791
+
792
+ # Categorize paragraphs by line count
793
+ print("\nCategorizing paragraphs by line count...")
794
+ train_categories = categorize_paragraphs_by_lines(train_dir)
795
+ val_categories = categorize_paragraphs_by_lines(val_dir)
796
+
797
+ total_train = sum(len(v) for v in train_categories.values())
798
+ total_val = sum(len(v) for v in val_categories.values())
799
+ print(f" Training: {total_train} paragraphs")
800
+ print(f" Validation: {total_val} paragraphs")
801
+
802
+ if use_curriculum:
803
+ print("\n Curriculum schedule:")
804
+ for s, e, mn, mx in DEFAULT_CURRICULUM:
805
+ label = f"{mn} line only" if mn == mx else f"{mn}-{mx} lines"
806
+ print(f" Epochs {s:2d}-{e:2d}: {label}")
807
+
808
+ # Transforms
809
+ train_transform = build_eval_transform() if args.no_aug else build_train_transform()
810
+ eval_transform = build_eval_transform()
811
+
812
+ # Dataset common kwargs
813
+ ds_kwargs = dict(
814
+ max_seq_len=args.max_seq_len,
815
+ img_height=args.img_height,
816
+ img_width=args.img_width,
817
+ char_to_idx=char_to_idx)
818
+
819
+ # Model
820
+ print("\nInitializing model...")
821
+ model = TransformerOCRParagraphModel(
822
+ vocab_size=vocab_size,
823
+ hidden_size=args.hidden_size,
824
+ nhead=args.num_heads,
825
+ num_encoder_layers=args.encoder_layers,
826
+ num_decoder_layers=args.decoder_layers,
827
+ dim_feedforward=args.ff_dim,
828
+ dropout=args.dropout,
829
+ use_upsample=use_upsample,
830
+ max_seq_len=args.max_seq_len,
831
+ tf_noise_rate=args.tf_noise_rate
832
+ ).to(device)
833
+
834
+ total_params = sum(p.numel() for p in model.parameters())
835
+ print(f" Parameters: {total_params:,}")
836
+ print(f" Upsample: {'ON' if use_upsample else 'OFF'}")
837
+ print(f" Curriculum: {'ON' if use_curriculum else 'OFF'}")
838
+ print(f" Teacher forcing noise: {args.tf_noise_rate * 100:.0f}%")
839
+
840
+ # Optimizer, scheduler, criterion
841
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate,
842
+ weight_decay=args.weight_decay)
843
+ scheduler = optim.lr_scheduler.StepLR(optimizer,
844
+ step_size=args.lr_step_size,
845
+ gamma=args.lr_gamma)
846
+ criterion = nn.CrossEntropyLoss(ignore_index=PAD_TOKEN)
847
+ scaler = GradScaler('cuda') if use_mixed_precision else None
848
+ early_stopping = EarlyStopping(patience=args.patience)
849
+
850
+ best_model_path = os.path.join(args.output_dir, f"{args.model_name}.pth")
851
+
852
+ # Log file
853
+ log_path = os.path.join(args.output_dir,
854
+ f"{args.model_name}_LOG_{datetime.now():%Y%m%d_%H%M%S}.txt")
855
+ log_file = open(log_path, 'w', encoding='utf-8')
856
+
857
+ def log(msg):
858
+ print(msg)
859
+ log_file.write(msg + '\n')
860
+ log_file.flush()
861
+
862
+ log(f"\nPre-training started: {datetime.now():%Y-%m-%d %H:%M:%S}")
863
+ log(f"Config: {vars(args)}")
864
+
865
+ # Training loop
866
+ current_min_lines = None
867
+ current_max_lines = None
868
+ train_loader = None
869
+ val_loader = None
870
+
871
+ for epoch in range(1, args.num_epochs + 1):
872
+ start_time = time.time()
873
+
874
+ # Curriculum stage management
875
+ if use_curriculum:
876
+ new_min, new_max = get_curriculum_stage(epoch, DEFAULT_CURRICULUM)
877
+
878
+ if new_min != current_min_lines or new_max != current_max_lines:
879
+ current_min_lines, current_max_lines = new_min, new_max
880
+
881
+ train_filtered = filter_paragraphs_by_lines(
882
+ train_categories, current_min_lines, current_max_lines)
883
+ val_filtered = filter_paragraphs_by_lines(
884
+ val_categories, current_min_lines, current_max_lines)
885
+
886
+ label = (f"{current_min_lines} line only" if current_min_lines == current_max_lines
887
+ else f"{current_min_lines}-{current_max_lines} lines")
888
+ log(f"\n Curriculum stage: {label} "
889
+ f"(train={len(train_filtered)}, val={len(val_filtered)})")
890
+
891
+ train_dataset = KurdishParagraphDataset(
892
+ transform=train_transform, filtered_data=train_filtered, **ds_kwargs)
893
+ val_dataset = KurdishParagraphDataset(
894
+ transform=eval_transform, filtered_data=val_filtered, **ds_kwargs)
895
+
896
+ train_loader = data.DataLoader(
897
+ train_dataset, batch_size=args.batch_size, shuffle=True,
898
+ num_workers=0, collate_fn=collate_fn, pin_memory=True)
899
+ val_loader = data.DataLoader(
900
+ val_dataset, batch_size=args.batch_size, shuffle=False,
901
+ num_workers=0, collate_fn=collate_fn, pin_memory=True)
902
+
903
+ early_stopping.reset()
904
+ else:
905
+ if train_loader is None:
906
+ all_train = [p for ps in train_categories.values() for p in ps]
907
+ all_val = [p for ps in val_categories.values() for p in ps]
908
+
909
+ train_dataset = KurdishParagraphDataset(
910
+ transform=train_transform, filtered_data=all_train, **ds_kwargs)
911
+ val_dataset = KurdishParagraphDataset(
912
+ transform=eval_transform, filtered_data=all_val, **ds_kwargs)
913
+
914
+ train_loader = data.DataLoader(
915
+ train_dataset, batch_size=args.batch_size, shuffle=True,
916
+ num_workers=0, collate_fn=collate_fn, pin_memory=True)
917
+ val_loader = data.DataLoader(
918
+ val_dataset, batch_size=args.batch_size, shuffle=False,
919
+ num_workers=0, collate_fn=collate_fn, pin_memory=True)
920
+
921
+ # Train
922
+ train_loss = train_epoch(model, train_loader, optimizer, criterion,
923
+ device, scaler, use_mixed_precision, args.grad_clip)
924
+
925
+ # Train CER (periodic)
926
+ train_cer = None
927
+ if args.cer_every > 0 and epoch % args.cer_every == 0:
928
+ train_cer = evaluate_cer_batch(model, train_loader, device,
929
+ idx_to_char, args.cer_max_samples)
930
+
931
+ # Validation
932
+ val_loss = evaluate_loss(model, val_loader, criterion, device, use_mixed_precision)
933
+ val_cer = evaluate_cer_batch(model, val_loader, device, idx_to_char)
934
+
935
+ scheduler.step()
936
+ elapsed = time.time() - start_time
937
+ mins, secs = divmod(elapsed, 60)
938
+
939
+ # Log
940
+ cer_str = f", Train CER: {train_cer:.4f}" if train_cer is not None else ""
941
+ log(f"Epoch {epoch}/{args.num_epochs} ({mins:.0f}m {secs:.0f}s) | "
942
+ f"Train Loss: {train_loss:.4f}{cer_str} | "
943
+ f"Val Loss: {val_loss:.4f} | Val CER: {val_cer:.4f}")
944
+
945
+ # Early stopping and model saving
946
+ early_stopping(val_cer, model, epoch, best_model_path)
947
+ if early_stopping.early_stop:
948
+ break
949
+
950
+ gc.collect()
951
+ if torch.cuda.is_available():
952
+ torch.cuda.empty_cache()
953
+
954
+ # Final evaluation on full validation set
955
+ log(f"\nLoading best model for final evaluation...")
956
+ ckpt = torch.load(best_model_path, map_location=device)
957
+ model.load_state_dict(ckpt['model_state_dict'])
958
+
959
+ all_val = [p for ps in val_categories.values() for p in ps]
960
+ full_val_dataset = KurdishParagraphDataset(
961
+ transform=eval_transform, filtered_data=all_val, **ds_kwargs)
962
+ full_val_loader = data.DataLoader(
963
+ full_val_dataset, batch_size=args.batch_size, shuffle=False,
964
+ num_workers=0, collate_fn=collate_fn, pin_memory=True)
965
+
966
+ final_cer, final_wer, preds, targets = evaluate_full(
967
+ model, full_val_loader, device, idx_to_char)
968
+
969
+ log(f"\nFinal Validation Results (Full Set, {len(full_val_dataset)} paragraphs):")
970
+ log(f" CER: {final_cer:.4f}")
971
+ log(f" WER: {final_wer:.4f}")
972
+
973
+ log(f"\nSample Predictions:")
974
+ for i in range(min(3, len(preds))):
975
+ log(f"\n--- Sample {i + 1} ---")
976
+ log(f"Predicted: {preds[i][:200]}")
977
+ log(f"Actual: {targets[i][:200]}")
978
+
979
+ log(f"\nPre-training complete: {datetime.now():%Y-%m-%d %H:%M:%S}")
980
+ log(f"Best model saved to: {best_model_path}")
981
+
982
+ log_file.close()
983
+ print(f"Log saved to: {log_path}")
984
+
985
+
986
+ if __name__ == "__main__":
987
+ main()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ torch>=2.0.0
2
+ torchvision>=0.15.0
3
+ numpy>=1.21.0
4
+ Pillow>=9.0.0
5
+ tqdm>=4.60.0
6
+ safetensors>=0.3.0