Reza2kn commited on
Commit
4b917c4
·
0 Parent(s):

Bina 0.4 Rizeh: full 95k production pipeline

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +35 -0
  2. MANIFEST.json +653 -0
  3. README.md +111 -0
  4. examples/demo.png +0 -0
  5. requirements.txt +17 -0
  6. rizeh.py +64 -0
  7. stallion/bina04-assembly-audit-95k-20261104/graphic_blocks.py +51 -0
  8. stallion/bina04-assembly-audit-95k-20261104/production.py +8 -0
  9. stallion/bina04-block-fields-95k-20261103/assembly_fix.py +56 -0
  10. stallion/bina04-block-fields-95k-20261103/production.py +12 -0
  11. stallion/bina04-cell-rowcores-95k-20261103/candidate.py +41 -0
  12. stallion/bina04-cell-rowcores-95k-20261103/production.py +17 -0
  13. stallion/bina04-column-assembly-95k-20261103/column_order.py +39 -0
  14. stallion/bina04-column-assembly-95k-20261103/production.py +11 -0
  15. stallion/bina04-crop-bandseg-95k-20261102/bandseg.py +277 -0
  16. stallion/bina04-crop-bandseg-95k-20261102/consumer_runtime.py +51 -0
  17. stallion/bina04-crop-picture-cells-95k-20261102/CALIBRATION.json +612 -0
  18. stallion/bina04-crop-picture-cells-95k-20261102/candidate.py +62 -0
  19. stallion/bina04-crop-picture-cells-95k-20261102/cell_reader.py +95 -0
  20. stallion/bina04-crop-picture-cells-95k-20261102/junk_filter.py +54 -0
  21. stallion/bina04-crop-picture-cells-95k-20261102/pen-classifier.joblib +3 -0
  22. stallion/bina04-crop-picture-cells-95k-20261102/pen_classifier.py +22 -0
  23. stallion/bina04-detector-role-isolation-95k-20261103/production.py +21 -0
  24. stallion/bina04-document-handoff-95k-20261103/document.py +80 -0
  25. stallion/bina04-document-handoff-95k-20261103/numeric_repairs.py +106 -0
  26. stallion/bina04-document-handoff-95k-20261103/production.py +38 -0
  27. stallion/bina04-document-handoff-95k-20261103/region_reads.py +59 -0
  28. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/learned_backend.py +30 -0
  29. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/run/checkpoints/step-001000.pt +3 -0
  30. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py +39 -0
  31. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/input_policy_v3.py +96 -0
  32. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py +216 -0
  33. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/table_client.py +24 -0
  34. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py +69 -0
  35. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/pipeline_crop_fix.py +76 -0
  36. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/table_cells.py +111 -0
  37. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_builder.py +102 -0
  38. stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_pipeline.py +42 -0
  39. stallion/bina04-header-marks-95k-20261103/CALIBRATION.json +1 -0
  40. stallion/bina04-header-marks-95k-20261103/candidate.py +76 -0
  41. stallion/bina04-header-marks-95k-20261103/production.py +16 -0
  42. stallion/bina04-header-marks-95k-20261103/serializer.py +82 -0
  43. stallion/bina04-indra-pilot-20261027/models/det.onnx +3 -0
  44. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/__init__.py +49 -0
  45. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/det_post.py +155 -0
  46. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/document.py +426 -0
  47. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/layout_post.py +381 -0
  48. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/line_mask.py +153 -0
  49. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/math.py +145 -0
  50. stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/models.py +75 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz 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
MANIFEST.json ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [
3
+ {
4
+ "path": "stallion/bina04-assembly-audit-95k-20261104/graphic_blocks.py",
5
+ "source": "/home/rezo/bina04-assembly-audit-95k-20261104/graphic_blocks.py",
6
+ "bytes": 3780
7
+ },
8
+ {
9
+ "path": "stallion/bina04-assembly-audit-95k-20261104/production.py",
10
+ "source": "/home/rezo/bina04-assembly-audit-95k-20261104/production.py",
11
+ "bytes": 616
12
+ },
13
+ {
14
+ "path": "stallion/bina04-block-fields-95k-20261103/assembly_fix.py",
15
+ "source": "/home/rezo/bina04-block-fields-95k-20261103/assembly_fix.py",
16
+ "bytes": 4466
17
+ },
18
+ {
19
+ "path": "stallion/bina04-block-fields-95k-20261103/production.py",
20
+ "source": "/home/rezo/bina04-block-fields-95k-20261103/production.py",
21
+ "bytes": 872
22
+ },
23
+ {
24
+ "path": "stallion/bina04-cell-rowcores-95k-20261103/candidate.py",
25
+ "source": "/home/rezo/bina04-cell-rowcores-95k-20261103/candidate.py",
26
+ "bytes": 2858
27
+ },
28
+ {
29
+ "path": "stallion/bina04-cell-rowcores-95k-20261103/production.py",
30
+ "source": "/home/rezo/bina04-cell-rowcores-95k-20261103/production.py",
31
+ "bytes": 1229
32
+ },
33
+ {
34
+ "path": "stallion/bina04-column-assembly-95k-20261103/column_order.py",
35
+ "source": "/home/rezo/bina04-column-assembly-95k-20261103/column_order.py",
36
+ "bytes": 2812
37
+ },
38
+ {
39
+ "path": "stallion/bina04-column-assembly-95k-20261103/production.py",
40
+ "source": "/home/rezo/bina04-column-assembly-95k-20261103/production.py",
41
+ "bytes": 665
42
+ },
43
+ {
44
+ "path": "stallion/bina04-crop-bandseg-95k-20261102/bandseg.py",
45
+ "source": "/home/rezo/bina04-crop-bandseg-95k-20261102/bandseg.py",
46
+ "bytes": 14573
47
+ },
48
+ {
49
+ "path": "stallion/bina04-crop-bandseg-95k-20261102/consumer_runtime.py",
50
+ "source": "/home/rezo/bina04-crop-bandseg-95k-20261102/consumer_runtime.py",
51
+ "bytes": 2357
52
+ },
53
+ {
54
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/CALIBRATION.json",
55
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/CALIBRATION.json",
56
+ "bytes": 14990
57
+ },
58
+ {
59
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/candidate.py",
60
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/candidate.py",
61
+ "bytes": 3443
62
+ },
63
+ {
64
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/cell_reader.py",
65
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/cell_reader.py",
66
+ "bytes": 5178
67
+ },
68
+ {
69
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/junk_filter.py",
70
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/junk_filter.py",
71
+ "bytes": 3251
72
+ },
73
+ {
74
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/pen-classifier.joblib",
75
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/pen-classifier.joblib",
76
+ "bytes": 13905
77
+ },
78
+ {
79
+ "path": "stallion/bina04-crop-picture-cells-95k-20261102/pen_classifier.py",
80
+ "source": "/home/rezo/bina04-crop-picture-cells-95k-20261102/pen_classifier.py",
81
+ "bytes": 1065
82
+ },
83
+ {
84
+ "path": "stallion/bina04-detector-role-isolation-95k-20261103/production.py",
85
+ "source": "/home/rezo/bina04-detector-role-isolation-95k-20261103/production.py",
86
+ "bytes": 1676
87
+ },
88
+ {
89
+ "path": "stallion/bina04-document-handoff-95k-20261103/document.py",
90
+ "source": "/home/rezo/bina04-document-handoff-95k-20261103/document.py",
91
+ "bytes": 5647
92
+ },
93
+ {
94
+ "path": "stallion/bina04-document-handoff-95k-20261103/numeric_repairs.py",
95
+ "source": "/home/rezo/bina04-document-handoff-95k-20261103/numeric_repairs.py",
96
+ "bytes": 7574
97
+ },
98
+ {
99
+ "path": "stallion/bina04-document-handoff-95k-20261103/production.py",
100
+ "source": "/home/rezo/bina04-document-handoff-95k-20261103/production.py",
101
+ "bytes": 2693
102
+ },
103
+ {
104
+ "path": "stallion/bina04-document-handoff-95k-20261103/region_reads.py",
105
+ "source": "/home/rezo/bina04-document-handoff-95k-20261103/region_reads.py",
106
+ "bytes": 3952
107
+ },
108
+ {
109
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/learned_backend.py",
110
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/learned_backend.py",
111
+ "bytes": 1737
112
+ },
113
+ {
114
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/run/checkpoints/step-001000.pt",
115
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/run/checkpoints/step-001000.pt",
116
+ "bytes": 153922431
117
+ },
118
+ {
119
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py",
120
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py",
121
+ "bytes": 2397
122
+ },
123
+ {
124
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/input_policy_v3.py",
125
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/input_policy_v3.py",
126
+ "bytes": 5112
127
+ },
128
+ {
129
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py",
130
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py",
131
+ "bytes": 18321
132
+ },
133
+ {
134
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/table_client.py",
135
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/table_client.py",
136
+ "bytes": 1440
137
+ },
138
+ {
139
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py",
140
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py",
141
+ "bytes": 5263
142
+ },
143
+ {
144
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/pipeline_crop_fix.py",
145
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/pipeline_crop_fix.py",
146
+ "bytes": 6508
147
+ },
148
+ {
149
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/table_cells.py",
150
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/table_cells.py",
151
+ "bytes": 7008
152
+ },
153
+ {
154
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_builder.py",
155
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_builder.py",
156
+ "bytes": 10084
157
+ },
158
+ {
159
+ "path": "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_pipeline.py",
160
+ "source": "/home/rezo/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_pipeline.py",
161
+ "bytes": 4315
162
+ },
163
+ {
164
+ "path": "stallion/bina04-header-marks-95k-20261103/CALIBRATION.json",
165
+ "source": "/home/rezo/bina04-header-marks-95k-20261103/CALIBRATION.json",
166
+ "bytes": 15542
167
+ },
168
+ {
169
+ "path": "stallion/bina04-header-marks-95k-20261103/candidate.py",
170
+ "source": "/home/rezo/bina04-header-marks-95k-20261103/candidate.py",
171
+ "bytes": 6479
172
+ },
173
+ {
174
+ "path": "stallion/bina04-header-marks-95k-20261103/production.py",
175
+ "source": "/home/rezo/bina04-header-marks-95k-20261103/production.py",
176
+ "bytes": 961
177
+ },
178
+ {
179
+ "path": "stallion/bina04-header-marks-95k-20261103/serializer.py",
180
+ "source": "/home/rezo/bina04-header-marks-95k-20261103/serializer.py",
181
+ "bytes": 7021
182
+ },
183
+ {
184
+ "path": "stallion/bina04-indra-pilot-20261027/models/det.onnx",
185
+ "source": "/home/rezo/bina04-indra-pilot-20261027/models/det.onnx",
186
+ "bytes": 389948
187
+ },
188
+ {
189
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/__init__.py",
190
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/__init__.py",
191
+ "bytes": 1263
192
+ },
193
+ {
194
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/det_post.py",
195
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/det_post.py",
196
+ "bytes": 6030
197
+ },
198
+ {
199
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/document.py",
200
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/document.py",
201
+ "bytes": 18586
202
+ },
203
+ {
204
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/layout_post.py",
205
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/layout_post.py",
206
+ "bytes": 14411
207
+ },
208
+ {
209
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/line_mask.py",
210
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/line_mask.py",
211
+ "bytes": 5600
212
+ },
213
+ {
214
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/math.py",
215
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/math.py",
216
+ "bytes": 6338
217
+ },
218
+ {
219
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/models.py",
220
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/models.py",
221
+ "bytes": 2702
222
+ },
223
+ {
224
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/order_post.py",
225
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/order_post.py",
226
+ "bytes": 12544
227
+ },
228
+ {
229
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/parallel.py",
230
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/parallel.py",
231
+ "bytes": 7766
232
+ },
233
+ {
234
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/pipeline.py",
235
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/pipeline.py",
236
+ "bytes": 45649
237
+ },
238
+ {
239
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/rec_preproc.py",
240
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/rec_preproc.py",
241
+ "bytes": 5500
242
+ },
243
+ {
244
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/rtl.py",
245
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/rtl.py",
246
+ "bytes": 6097
247
+ },
248
+ {
249
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/textlayer.py",
250
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/textlayer.py",
251
+ "bytes": 14861
252
+ },
253
+ {
254
+ "path": "stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/vocab.py",
255
+ "source": "/home/rezo/bina04-indra-pilot-20261027/upstream/indra_ocr/vocab.py",
256
+ "bytes": 11395
257
+ },
258
+ {
259
+ "path": "stallion/bina04-ink-loss-guard-95k-20261104/production.py",
260
+ "source": "/home/rezo/bina04-ink-loss-guard-95k-20261104/production.py",
261
+ "bytes": 1590
262
+ },
263
+ {
264
+ "path": "stallion/bina04-line-detector-v2-20261030/detector-run/checkpoints/step-000500.pt",
265
+ "source": "/home/rezo/bina04-line-detector-v2-20261030/detector-run/checkpoints/step-000500.pt",
266
+ "bytes": 153922431
267
+ },
268
+ {
269
+ "path": "stallion/bina04-line-geometry-95k-20261103/assembly.py",
270
+ "source": "/home/rezo/bina04-line-geometry-95k-20261103/assembly.py",
271
+ "bytes": 2786
272
+ },
273
+ {
274
+ "path": "stallion/bina04-line-geometry-95k-20261103/form_rows.py",
275
+ "source": "/home/rezo/bina04-line-geometry-95k-20261103/form_rows.py",
276
+ "bytes": 2236
277
+ },
278
+ {
279
+ "path": "stallion/bina04-line-geometry-95k-20261103/geometry_fix.py",
280
+ "source": "/home/rezo/bina04-line-geometry-95k-20261103/geometry_fix.py",
281
+ "bytes": 5655
282
+ },
283
+ {
284
+ "path": "stallion/bina04-line-geometry-95k-20261103/production.py",
285
+ "source": "/home/rezo/bina04-line-geometry-95k-20261103/production.py",
286
+ "bytes": 1241
287
+ },
288
+ {
289
+ "path": "stallion/bina04-numeric-math-recognizer-95k-20261102/evaluate.py",
290
+ "source": "/home/rezo/bina04-numeric-math-recognizer-95k-20261102/evaluate.py",
291
+ "bytes": 15324
292
+ },
293
+ {
294
+ "path": "stallion/bina04-numeric-math-recognizer-95k-20261102/recognizer/selected.pt",
295
+ "source": "/home/rezo/bina04-numeric-math-recognizer-95k-20261102/recognizer/selected.pt",
296
+ "bytes": 131255668
297
+ },
298
+ {
299
+ "path": "stallion/bina04-numeric-routing-95k-20261103/consumer.py",
300
+ "source": "/home/rezo/bina04-numeric-routing-95k-20261103/consumer.py",
301
+ "bytes": 4335
302
+ },
303
+ {
304
+ "path": "stallion/bina04-numeric-routing-95k-20261103/formula.py",
305
+ "source": "/home/rezo/bina04-numeric-routing-95k-20261103/formula.py",
306
+ "bytes": 4391
307
+ },
308
+ {
309
+ "path": "stallion/bina04-numeric-routing-95k-20261103/formula_private.py",
310
+ "source": "/home/rezo/bina04-numeric-routing-95k-20261103/formula_private.py",
311
+ "bytes": 5993
312
+ },
313
+ {
314
+ "path": "stallion/bina04-numeric-routing-95k-20261103/route.py",
315
+ "source": "/home/rezo/bina04-numeric-routing-95k-20261103/route.py",
316
+ "bytes": 7778
317
+ },
318
+ {
319
+ "path": "stallion/bina04-order-application-debug-20261102/fixed_order.py",
320
+ "source": "/home/rezo/bina04-order-application-debug-20261102/fixed_order.py",
321
+ "bytes": 5790
322
+ },
323
+ {
324
+ "path": "stallion/bina04-order-form-batch-20261101/recognizer/weights-001000.pt",
325
+ "source": "/home/rezo/bina04-order-form-batch-20261101/recognizer/weights-001000.pt",
326
+ "bytes": 131256958
327
+ },
328
+ {
329
+ "path": "stallion/bina04-order-labels-current-95k-20261102/CALIBRATION.json",
330
+ "source": "/home/rezo/bina04-order-labels-current-95k-20261102/CALIBRATION.json",
331
+ "bytes": 187
332
+ },
333
+ {
334
+ "path": "stallion/bina04-order-labels-current-95k-20261102/order_only.py",
335
+ "source": "/home/rezo/bina04-order-labels-current-95k-20261102/order_only.py",
336
+ "bytes": 1888
337
+ },
338
+ {
339
+ "path": "stallion/bina04-order-labels-current-95k-20261102/production.py",
340
+ "source": "/home/rezo/bina04-order-labels-current-95k-20261102/production.py",
341
+ "bytes": 1740
342
+ },
343
+ {
344
+ "path": "stallion/bina04-order-predicted-20261101/order_nodes.py",
345
+ "source": "/home/rezo/bina04-order-predicted-20261101/order_nodes.py",
346
+ "bytes": 4931
347
+ },
348
+ {
349
+ "path": "stallion/bina04-order-predicted-20261101/pred_order_model.py",
350
+ "source": "/home/rezo/bina04-order-predicted-20261101/pred_order_model.py",
351
+ "bytes": 2771
352
+ },
353
+ {
354
+ "path": "stallion/bina04-order-salvage-20261101/evaluate_batch.py",
355
+ "source": "/home/rezo/bina04-order-salvage-20261101/evaluate_batch.py",
356
+ "bytes": 8459
357
+ },
358
+ {
359
+ "path": "stallion/bina04-order-salvage-20261101/row_builder.py",
360
+ "source": "/home/rezo/bina04-order-salvage-20261101/row_builder.py",
361
+ "bytes": 10084
362
+ },
363
+ {
364
+ "path": "stallion/bina04-page-assembly-95k-20261103/CALIBRATION.json",
365
+ "source": "/home/rezo/bina04-page-assembly-95k-20261103/CALIBRATION.json",
366
+ "bytes": 649
367
+ },
368
+ {
369
+ "path": "stallion/bina04-page-assembly-95k-20261103/assembly.py",
370
+ "source": "/home/rezo/bina04-page-assembly-95k-20261103/assembly.py",
371
+ "bytes": 2809
372
+ },
373
+ {
374
+ "path": "stallion/bina04-page-assembly-95k-20261103/production.py",
375
+ "source": "/home/rezo/bina04-page-assembly-95k-20261103/production.py",
376
+ "bytes": 905
377
+ },
378
+ {
379
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/data/vocab.json",
380
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/data/vocab.json",
381
+ "bytes": 20007
382
+ },
383
+ {
384
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/fusion_v3.py",
385
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/fusion_v3.py",
386
+ "bytes": 4521
387
+ },
388
+ {
389
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/input_policy_v3.py",
390
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/input_policy_v3.py",
391
+ "bytes": 5112
392
+ },
393
+ {
394
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/pipeline_v3.py",
395
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/pipeline_v3.py",
396
+ "bytes": 18321
397
+ },
398
+ {
399
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/run/weights-095000.pt",
400
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/run/weights-095000.pt",
401
+ "bytes": 131256958
402
+ },
403
+ {
404
+ "path": "stallion/bina04-pagepath-v3-round3-clean-20261031/text_contract.py",
405
+ "source": "/home/rezo/bina04-pagepath-v3-round3-clean-20261031/text_contract.py",
406
+ "bytes": 1030
407
+ },
408
+ {
409
+ "path": "stallion/bina04-print-textgate-20261102/CALIBRATION.json",
410
+ "source": "/home/rezo/bina04-print-textgate-20261102/CALIBRATION.json",
411
+ "bytes": 2509
412
+ },
413
+ {
414
+ "path": "stallion/bina04-print-textgate-20261102/include-model.pkl",
415
+ "source": "/home/rezo/bina04-print-textgate-20261102/include-model.pkl",
416
+ "bytes": 7982847
417
+ },
418
+ {
419
+ "path": "stallion/bina04-print-textgate-20261102/include_model.py",
420
+ "source": "/home/rezo/bina04-print-textgate-20261102/include_model.py",
421
+ "bytes": 1878
422
+ },
423
+ {
424
+ "path": "stallion/bina04-print-textgate-20261102/layer_runtime.py",
425
+ "source": "/home/rezo/bina04-print-textgate-20261102/layer_runtime.py",
426
+ "bytes": 4969
427
+ },
428
+ {
429
+ "path": "stallion/bina04-print-textgate-20261102/order_adapter.py",
430
+ "source": "/home/rezo/bina04-print-textgate-20261102/order_adapter.py",
431
+ "bytes": 940
432
+ },
433
+ {
434
+ "path": "stallion/bina04-print-textgate-20261102/production.py",
435
+ "source": "/home/rezo/bina04-print-textgate-20261102/production.py",
436
+ "bytes": 1372
437
+ },
438
+ {
439
+ "path": "stallion/bina04-print-textgate-20261102/run/best.json",
440
+ "source": "/home/rezo/bina04-print-textgate-20261102/run/best.json",
441
+ "bytes": 2157
442
+ },
443
+ {
444
+ "path": "stallion/bina04-print-textgate-20261102/run/selected-printed-recognizer.pt",
445
+ "source": "/home/rezo/bina04-print-textgate-20261102/run/selected-printed-recognizer.pt",
446
+ "bytes": 131256937
447
+ },
448
+ {
449
+ "path": "stallion/bina04-printed-form-fix-20261102/CALIBRATION.json",
450
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/CALIBRATION.json",
451
+ "bytes": 1591
452
+ },
453
+ {
454
+ "path": "stallion/bina04-printed-form-fix-20261102/SERIALIZED-LEXICON.json",
455
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/SERIALIZED-LEXICON.json",
456
+ "bytes": 2644
457
+ },
458
+ {
459
+ "path": "stallion/bina04-printed-form-fix-20261102/candidate_apply.py",
460
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/candidate_apply.py",
461
+ "bytes": 4498
462
+ },
463
+ {
464
+ "path": "stallion/bina04-printed-form-fix-20261102/label_model.py",
465
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/label_model.py",
466
+ "bytes": 1684
467
+ },
468
+ {
469
+ "path": "stallion/bina04-printed-form-fix-20261102/printed-label-model.pkl",
470
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/printed-label-model.pkl",
471
+ "bytes": 12109247
472
+ },
473
+ {
474
+ "path": "stallion/bina04-printed-form-fix-20261102/production.py",
475
+ "source": "/home/rezo/bina04-printed-form-fix-20261102/production.py",
476
+ "bytes": 4248
477
+ },
478
+ {
479
+ "path": "stallion/bina04-printed-layer-training-20261102/run/weights-002000.pt",
480
+ "source": "/home/rezo/bina04-printed-layer-training-20261102/run/weights-002000.pt",
481
+ "bytes": 132814787
482
+ },
483
+ {
484
+ "path": "stallion/bina04-quality-route-printscope-95k-20261103/QUALITY-POLICY.json",
485
+ "source": "/home/rezo/bina04-quality-route-printscope-95k-20261103/QUALITY-POLICY.json",
486
+ "bytes": 1590
487
+ },
488
+ {
489
+ "path": "stallion/bina04-quality-route-printscope-95k-20261103/print_scope.py",
490
+ "source": "/home/rezo/bina04-quality-route-printscope-95k-20261103/print_scope.py",
491
+ "bytes": 1087
492
+ },
493
+ {
494
+ "path": "stallion/bina04-quality-route-printscope-95k-20261103/production.py",
495
+ "source": "/home/rezo/bina04-quality-route-printscope-95k-20261103/production.py",
496
+ "bytes": 3428
497
+ },
498
+ {
499
+ "path": "stallion/bina04-quality-route-printscope-95k-20261103/quality.py",
500
+ "source": "/home/rezo/bina04-quality-route-printscope-95k-20261103/quality.py",
501
+ "bytes": 1947
502
+ },
503
+ {
504
+ "path": "stallion/bina04-real-degraded-95k-20261103/recognizer/selected.pt",
505
+ "source": "/home/rezo/bina04-real-degraded-95k-20261103/recognizer/selected.pt",
506
+ "bytes": 131254078
507
+ },
508
+ {
509
+ "path": "stallion/bina04-small-crossing-rows-20261102/crossing_rows.py",
510
+ "source": "/home/rezo/bina04-small-crossing-rows-20261102/crossing_rows.py",
511
+ "bytes": 4043
512
+ },
513
+ {
514
+ "path": "stallion/bina04-small-crossing-rows-20261102/runtime.py",
515
+ "source": "/home/rezo/bina04-small-crossing-rows-20261102/runtime.py",
516
+ "bytes": 2983
517
+ },
518
+ {
519
+ "path": "stallion/bina04-small-crossing-rows-20261102/runtime_release.py",
520
+ "source": "/home/rezo/bina04-small-crossing-rows-20261102/runtime_release.py",
521
+ "bytes": 1162
522
+ },
523
+ {
524
+ "path": "stallion/bina04-small-crossing-rows-20261102/scale_utils.py",
525
+ "source": "/home/rezo/bina04-small-crossing-rows-20261102/scale_utils.py",
526
+ "bytes": 1741
527
+ },
528
+ {
529
+ "path": "stallion/bina04-smallmarks-celllabels-95k-20261102/decompose.py",
530
+ "source": "/home/rezo/bina04-smallmarks-celllabels-95k-20261102/decompose.py",
531
+ "bytes": 903
532
+ },
533
+ {
534
+ "path": "stallion/bina04-surya25-order-20261101/role-model/weights-003000.pt",
535
+ "source": "/home/rezo/bina04-surya25-order-20261101/role-model/weights-003000.pt",
536
+ "bytes": 5093599
537
+ },
538
+ {
539
+ "path": "stallion/bina04-surya25-order-20261101/role_model.py",
540
+ "source": "/home/rezo/bina04-surya25-order-20261101/role_model.py",
541
+ "bytes": 4264
542
+ },
543
+ {
544
+ "path": "stallion/bina04-surya25-order-20261101/role_nodes.py",
545
+ "source": "/home/rezo/bina04-surya25-order-20261101/role_nodes.py",
546
+ "bytes": 1697
547
+ },
548
+ {
549
+ "path": "stallion/bina04-table-asides-95k-20261104/production.py",
550
+ "source": "/home/rezo/bina04-table-asides-95k-20261104/production.py",
551
+ "bytes": 616
552
+ },
553
+ {
554
+ "path": "stallion/bina04-table-asides-95k-20261104/table_asides.py",
555
+ "source": "/home/rezo/bina04-table-asides-95k-20261104/table_asides.py",
556
+ "bytes": 3046
557
+ },
558
+ {
559
+ "path": "stallion/indra-scale/geometry.py",
560
+ "source": "/tmp/bina04-indra-scale-20261029/geometry.py",
561
+ "bytes": 1935
562
+ },
563
+ {
564
+ "path": "stallion/indra-scale/gpu_eval_runtime.py",
565
+ "source": "/tmp/bina04-indra-scale-20261029/gpu_eval_runtime.py",
566
+ "bytes": 2267
567
+ },
568
+ {
569
+ "path": "stallion/indra-scale/recognizer.py",
570
+ "source": "/tmp/bina04-indra-scale-20261029/recognizer.py",
571
+ "bytes": 3455
572
+ },
573
+ {
574
+ "path": "stallion/indra-scale/regional_pipeline.py",
575
+ "source": "/tmp/bina04-indra-scale-20261029/regional_pipeline.py",
576
+ "bytes": 7967
577
+ },
578
+ {
579
+ "path": "stallion/indra-scale/regional_pipeline_v2.py",
580
+ "source": "/tmp/bina04-indra-scale-20261029/regional_pipeline_v2.py",
581
+ "bytes": 8415
582
+ },
583
+ {
584
+ "path": "stallion/indra-scale/routing-rule.json",
585
+ "source": "/tmp/bina04-indra-scale-20261029/routing-rule.json",
586
+ "bytes": 17922
587
+ },
588
+ {
589
+ "path": "stallion/indra-scale/routing_modes.py",
590
+ "source": "/tmp/bina04-indra-scale-20261029/routing_modes.py",
591
+ "bytes": 2548
592
+ },
593
+ {
594
+ "path": "stallion/indra-scale/rtl_reader.py",
595
+ "source": "/tmp/bina04-indra-scale-20261029/rtl_reader.py",
596
+ "bytes": 2892
597
+ },
598
+ {
599
+ "path": "stallion/indra-scale/scoringTT/shenavasanj.py",
600
+ "source": "/tmp/bina04-indra-scale-20261029/scoringTT/shenavasanj.py",
601
+ "bytes": 5126
602
+ },
603
+ {
604
+ "path": "stallion/indra-scale/visual_token.py",
605
+ "source": "/tmp/bina04-indra-scale-20261029/visual_token.py",
606
+ "bytes": 491
607
+ }
608
+ ],
609
+ "rewritten_text_files": [
610
+ "stallion/bina04-assembly-audit-95k-20261104/production.py",
611
+ "stallion/bina04-block-fields-95k-20261103/production.py",
612
+ "stallion/bina04-cell-rowcores-95k-20261103/candidate.py",
613
+ "stallion/bina04-cell-rowcores-95k-20261103/production.py",
614
+ "stallion/bina04-column-assembly-95k-20261103/production.py",
615
+ "stallion/bina04-detector-role-isolation-95k-20261103/production.py",
616
+ "stallion/bina04-document-handoff-95k-20261103/production.py",
617
+ "stallion/bina04-document-handoff-95k-20261103/region_reads.py",
618
+ "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py",
619
+ "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py",
620
+ "stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py",
621
+ "stallion/bina04-header-marks-95k-20261103/candidate.py",
622
+ "stallion/bina04-header-marks-95k-20261103/production.py",
623
+ "stallion/bina04-header-marks-95k-20261103/serializer.py",
624
+ "stallion/bina04-ink-loss-guard-95k-20261104/production.py",
625
+ "stallion/bina04-line-geometry-95k-20261103/production.py",
626
+ "stallion/bina04-numeric-math-recognizer-95k-20261102/evaluate.py",
627
+ "stallion/bina04-numeric-routing-95k-20261103/consumer.py",
628
+ "stallion/bina04-numeric-routing-95k-20261103/formula_private.py",
629
+ "stallion/bina04-order-application-debug-20261102/fixed_order.py",
630
+ "stallion/bina04-order-labels-current-95k-20261102/order_only.py",
631
+ "stallion/bina04-order-labels-current-95k-20261102/production.py",
632
+ "stallion/bina04-order-salvage-20261101/evaluate_batch.py",
633
+ "stallion/bina04-page-assembly-95k-20261103/production.py",
634
+ "stallion/bina04-pagepath-v3-round3-clean-20261031/pipeline_v3.py",
635
+ "stallion/bina04-pagepath-v3-round3-clean-20261031/text_contract.py",
636
+ "stallion/bina04-print-textgate-20261102/include_model.py",
637
+ "stallion/bina04-print-textgate-20261102/layer_runtime.py",
638
+ "stallion/bina04-print-textgate-20261102/order_adapter.py",
639
+ "stallion/bina04-print-textgate-20261102/production.py",
640
+ "stallion/bina04-print-textgate-20261102/run/best.json",
641
+ "stallion/bina04-printed-form-fix-20261102/CALIBRATION.json",
642
+ "stallion/bina04-printed-form-fix-20261102/production.py",
643
+ "stallion/bina04-quality-route-printscope-95k-20261103/print_scope.py",
644
+ "stallion/bina04-quality-route-printscope-95k-20261103/production.py",
645
+ "stallion/bina04-small-crossing-rows-20261102/runtime.py",
646
+ "stallion/bina04-table-asides-95k-20261104/production.py"
647
+ ],
648
+ "placeholder": "__BINA_ROOT__",
649
+ "layout_model": {
650
+ "repo": "datalab-to/surya_layout2",
651
+ "revision": "0aee81d5fd9275c0582e545bf3a56944b1e75679"
652
+ }
653
+ }
README.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - fa
4
+ license: other
5
+ license_name: bina-0.4-rizeh
6
+ pipeline_tag: image-to-text
7
+ tags:
8
+ - bina
9
+ - ocr
10
+ - persian
11
+ - farsi
12
+ - handwriting
13
+ - printed-text
14
+ - ctc
15
+ - document-understanding
16
+ datasets:
17
+ - Reza2kn/persian-handwriting-pages-3.69m
18
+ - Reza2kn/persian-printed-ocr-3.5m
19
+ ---
20
+
21
+ # Bina 0.4 Rizeh
22
+
23
+ Bina 0.4 Rizeh is the small, fast member of the Bina 0.4 Persian OCR family. It reads whole pages (printed text, handwriting, forms, screenshots, letters, newspapers and simple math). Its core is about 71M parameters: a 38.5M line detector (fine-tuned Surya text detector) and a 32.8M CTC line recognizer distilled from [Bina 0.4 Koochik](https://huggingface.co/Reza2kn/Bina-0.4-Koochik). They run inside a page pipeline:
24
+
25
+ 1. **Layout**: Surya layout (`datalab-to/surya_layout2`, pinned revision, downloaded on first run).
26
+ 2. **Lines**: fine-tuned Surya-architecture line detectors (body text, printed layer, handwriting page path).
27
+ 3. **Reading**: the 95k-step Indra CTC student for body text, plus specialist readers with the same architecture: printed/form labels, short form cells, numeric and math, and degraded scans.
28
+ 4. **Assembly**: row consolidation, column and table ordering, printed-label admission and page-level ordering.
29
+
30
+ This repository is the complete production pipeline exactly as it scored below, not just a checkpoint: all weights, calibrations and code are bundled.
31
+
32
+ ## Quick run
33
+
34
+ ```bash
35
+ hf download Reza2kn/Bina-0.4-Rizeh --local-dir Bina-0.4-Rizeh
36
+ cd Bina-0.4-Rizeh
37
+ pip install -r requirements.txt # Python 3.10, NVIDIA GPU recommended
38
+ python rizeh.py examples/demo.png --out results
39
+ python rizeh.py scan1.jpg scan2.png document.pdf --out results
40
+ ```
41
+
42
+ The first run binds the bundled paths to your checkout (it edits the bundled text files in place, and reruns automatically if you move the folder) and downloads the pinned Surya layout model. For each page you get `<name>.txt` with the page text in reading order, and `<name>.json` with every line's text, box, layer (`printed` / handwriting / body) and confidence. PDFs are rendered at 200 dpi.
43
+
44
+ From Python:
45
+
46
+ ```python
47
+ from PIL import Image
48
+ from rizeh import Rizeh
49
+ ocr = Rizeh("cuda")
50
+ result = ocr(Image.open("page.jpg").convert("RGB"))
51
+ print(result["text"])
52
+ ```
53
+
54
+ ## Scores
55
+
56
+ Character error rate (CER, %) on the internal gates this release was frozen on. Lower is better.
57
+
58
+ | Gate | CER |
59
+ |---|---:|
60
+ | 193 cropped regions, overall | 9.39 |
61
+ | · forms | 3.80 |
62
+ | · screenshots | 4.65 |
63
+ | · prose | 9.15 |
64
+ | · math | 12.09 |
65
+ | · newspaper | 13.49 |
66
+ | · tables | 19.63 |
67
+ | 50 handwriting pages, handwritten body only | 3.97 |
68
+ | 50 handwriting pages, full page incl. printed form text | 10.89 |
69
+ | 338 printed lines | 1.76 |
70
+ | 7 real scanned documents (printed regions) | 10.61 |
71
+ | Scanned official letter | 2.12 |
72
+
73
+ [Persian OCR Triple Threat](https://huggingface.co/spaces/Reza2kn/PersianOCR-TripleThreat), same 95k recognizer, measured 2026-11-03 before the final assembly fixes (lower is better):
74
+
75
+ | Split | Rows | WER | CER | S³ | Triple Threat |
76
+ |---|---:|---:|---:|---:|---:|
77
+ | Printed | 6,669 | 4.70 | 1.37 | 5.88 | 4.74 |
78
+ | Handwriting | 6,669 | 18.48 | 13.01 | 15.19 | 15.41 |
79
+ | Equal-split overall | 13,338 | 11.59 | 7.19 | 10.54 | **10.08** |
80
+
81
+ ## Limitations
82
+
83
+ - **Historical manuscripts do not work.** On HPHD (historical nastaliq and shekasteh manuscripts) the recognizer is at 67% CER even on oracle line crops, and full pages come out as fluent-looking nonsense. Do not use it for manuscripts.
84
+ - **Tables are weak** (about 20% CER), especially dense or low-resolution tables with handwriting over the cells.
85
+ - **Reading order** on busy layouts (multi-column newspapers, exam sheets with many labels) is the largest remaining error source; the characters are usually right but lines can come out in a different order.
86
+ - **Faint or coloured printed labels** (light-blue Nastaliq on forms) are often missed.
87
+ - The pipeline has many stages tuned together. Swapping one component (for example a newer recognizer) without retuning the stages after it makes results worse; this was measured, not assumed.
88
+ - Persian-first. Latin text and digits are read, but other languages were not a goal.
89
+ - Confidences are model scores, not calibrated probabilities.
90
+
91
+ ## Training and provenance
92
+
93
+ - Recognizer: Indra CTC, 32,796,250 parameters, 20 blocks, 1,617-symbol vocabulary; 95,000 training steps, distilled from Bina 0.4 Koochik checkpoint 18,404.
94
+ - Specialist readers (printed labels, form cells, numeric/math, degraded scans) are 32.8M fine-tunes of the same recognizer; the two line detectors are 38.5M fine-tunes of Surya's text detector, and the printed-text head adds 0.39M.
95
+ - Checkpoint selection used internal development sets only; the gates above were never used to pick weights.
96
+ - `MANIFEST.json` lists every bundled file with its original location and size. Under `stallion/` the files are organized by experiment directory exactly as they ran in production; `rizeh.py` is the entry point.
97
+
98
+ ## License
99
+
100
+ Bina recognizer weights and code: CC BY-NC 4.0, matching the non-commercial training data. Line-detector weights are fine-tunes of Surya (datalab) detection weights, and the Surya layout model is downloaded from datalab; both stay under [Surya's model license](https://github.com/datalab-to/surya), which limits commercial use.
101
+
102
+ ## فارسی
103
+
104
+ بینا ۰٫۴ ریزه عضو کوچک و سریع خانواده OCR فارسی بینا ۰٫۴ است. یک مدل CTC با ۳۲٫۸ میلیون پارامتر که از بینا ۰٫۴ کوچیک تقطیر شده، درون یک خط‌لوله کامل صفحه کار می‌کند: چیدمان صفحه، تشخیص خط، خواندن متن چاپی و دست‌نویس، فرم‌ها، اعداد و چیدن متن به ترتیب خواندن.
105
+
106
+ ```bash
107
+ pip install -r requirements.txt
108
+ python rizeh.py page.jpg --out results
109
+ ```
110
+
111
+ محدودیت مهم: روی نسخه‌های خطی تاریخی (نستعلیق و شکسته قدیمی) قابل استفاده نیست، و جدول‌ها و ترتیب خواندن صفحه‌های شلوغ هنوز ضعیف‌اند.
examples/demo.png ADDED
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tested with Python 3.10 on an NVIDIA RTX 5080.
2
+ torch==2.10.0
3
+ torchvision==0.25.0
4
+ surya-ocr==0.22.1
5
+ transformers==5.14.1
6
+ numpy==1.26.4
7
+ scipy==1.15.3
8
+ scikit-learn==1.7.2
9
+ joblib==1.5.3
10
+ rapidfuzz==3.14.5
11
+ jiwer==4.0.0
12
+ opencv-python-headless==4.11.0.86
13
+ onnxruntime==1.23.2
14
+ pillow==10.4.0
15
+ pyarrow==23.0.1
16
+ huggingface_hub>=1.0
17
+ pypdfium2
rizeh.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Bina 0.4 Rizeh: Persian page OCR (Surya layout + 95k Indra CTC student + printed/table/numeric experts and assembly).
3
+
4
+ usage: python rizeh.py page.jpg [more.png doc.pdf ...] [--out results] [--device cuda|cpu]
5
+ """
6
+ import argparse,json,os,sys,time,importlib.util
7
+ from pathlib import Path
8
+ ROOT=Path(__file__).resolve().parent;ST=ROOT/'stallion';MARK=ROOT/'.bina-root'
9
+ LAYOUT_REPO,LAYOUT_REV='datalab-to/surya_layout2','0aee81d5fd9275c0582e545bf3a56944b1e75679'
10
+ TEXT=('.py','.json','.yml','.yaml','.txt','.jsonl')
11
+
12
+ def setup():
13
+ """Point every bundled path at this checkout (idempotent; reruns after a move) and fetch the pinned layout model."""
14
+ here=str(ROOT);old=MARK.read_text().strip()if MARK.exists()else'__BINA_ROOT__'
15
+ if old!=here:
16
+ n=0
17
+ for p in ST.rglob('*'):
18
+ if p.is_file()and p.suffix in TEXT:
19
+ s=p.read_text(encoding='utf-8')
20
+ if old in s:p.write_text(s.replace(old,here),encoding='utf-8');n+=1
21
+ MARK.write_text(here);print(f'[rizeh] bound {n} files to {here}',file=sys.stderr)
22
+ snap=ST/'indra-scale/hf/hub/models--datalab-to--surya_layout2/snapshots'/LAYOUT_REV
23
+ if not(snap/'rfdetr_layout.pth').exists():
24
+ from huggingface_hub import snapshot_download
25
+ print('[rizeh] downloading layout model',LAYOUT_REPO,LAYOUT_REV,file=sys.stderr)
26
+ snapshot_download(LAYOUT_REPO,revision=LAYOUT_REV,cache_dir=str(ST/'indra-scale/hf/hub'))
27
+ return snap
28
+
29
+ def pages(path):
30
+ p=Path(path)
31
+ if p.suffix.lower()=='.pdf':
32
+ import pypdfium2 as pdfium
33
+ doc=pdfium.PdfDocument(str(p))
34
+ for i in range(len(doc)):yield f'{p.stem}-p{i+1:03d}',doc[i].render(scale=200/72).to_pil().convert('RGB')
35
+ else:
36
+ from PIL import Image
37
+ yield p.stem,Image.open(p).convert('RGB')
38
+
39
+ class Rizeh:
40
+ def __init__(self,device='cuda'):
41
+ snap=setup()
42
+ os.environ.setdefault('GATE_DEVICE',device);os.environ.setdefault('FAST_DETECTOR_DEVICE','cpu');os.environ.setdefault('DETECTOR_IMAGE_CHUNK_HEIGHT','1400');os.environ['FAST_ORDER_MODEL_CHECKPOINT']=str(snap/'order')
43
+ P=ST/'indra-scale';sys.path[:0]=[str(P),str(ST/'bina04-pagepath-v3-round3-clean-20261031'),str(P/'scoringTT')]
44
+ spec=importlib.util.spec_from_file_location('bina04_rizeh_runtime',ST/'bina04-ink-loss-guard-95k-20261104/production.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
45
+ self.engine=m.Runtime(device)
46
+ from surya.fast_layout.server import LayoutEngine
47
+ self.layout=LayoutEngine(checkpoint=str(snap))
48
+ def __call__(self,image,name='page'):
49
+ raw=self.layout.run_batch([image],[dict(threshold=None,use_order=True)])[0].model_dump(mode='json')
50
+ out=self.engine(image,dict(raw_layout=raw),'page',name)
51
+ lines=[dict(text=l.get('text',''),layer=l.get('layer'),box=[round(float(v),1)for v in l['box']],confidence=l.get('confidence'))for l in out.get('lines',[])if l.get('box')]
52
+ return dict(text=out['text'],lines=lines)
53
+ def close(self):self.engine.close()
54
+
55
+ def main():
56
+ ap=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter);ap.add_argument('inputs',nargs='+');ap.add_argument('--out',default='rizeh-out');ap.add_argument('--device',default='cuda',choices=['cuda','cpu']);a=ap.parse_args()
57
+ O=Path(a.out);O.mkdir(parents=True,exist_ok=True);r=Rizeh(a.device)
58
+ for src in a.inputs:
59
+ for name,im in pages(src):
60
+ t=time.perf_counter();res=r(im,name);res.update(source=str(src),seconds=round(time.perf_counter()-t,2),model='Bina-0.4-Rizeh')
61
+ (O/f'{name}.json').write_text(json.dumps(res,ensure_ascii=False,indent=1),encoding='utf-8');(O/f'{name}.txt').write_text(res['text'],encoding='utf-8')
62
+ print(f'{name}: {len(res["lines"])} lines, {res["seconds"]}s -> {O/name}.txt',flush=True)
63
+ r.close()
64
+ if __name__=='__main__':main()
stallion/bina04-assembly-audit-95k-20261104/graphic_blocks.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Atomic supported text blocks in illustrated Latin documents; frozen reader."""
2
+ import copy,unicodedata
3
+ import numpy as np
4
+
5
+ def area(b):return max(0,b[2]-b[0])*max(0,b[3]-b[1])
6
+ def ov(a,b):return area([max(a[0],b[0]),max(a[1],b[1]),min(a[2],b[2]),min(a[3],b[3])])/max(1,area(a))
7
+ def union(bs):return[min(b[0]for b in bs),min(b[1]for b in bs),max(b[2]for b in bs),max(b[3]for b in bs)]
8
+ def apply(old,image):
9
+ out=copy.deepcopy(old);ls=out.get('lines',[])
10
+ if any(l.get('layer')=='handwriting'for l in ls)or any(r.get('table_trace',{}).get('grid')for r in out.get('regions',[])):return old
11
+ text=''.join(l['text']for l in ls if l.get('confidence',0)>=.9);letters=[c for c in text if c.isalpha()];latin=sum('LATIN'in unicodedata.name(c,'')for c in letters)
12
+ if len(letters)<60 or latin/max(1,len(letters))<.8:return old
13
+ W,H=image.size;a=np.asarray(image.convert('RGB'));sat=(a.max(2).astype(float)-a.min(2));colour=float((sat>30).mean())
14
+ layouts=[r['bbox']for r in out.get('raw_layout',{}).get('bboxes',[])if r.get('label')in('Text','SectionHeader','Caption','ListItem')and'bbox'in r]
15
+ if colour<.08 or len(layouts)<3:return old
16
+ if ''.join(out['text'].split())!=''.join(''.join(l['text'].split())for l in ls):return old
17
+ med=float(np.median([l['box'][3]-l['box'][1]for l in ls if l.get('confidence',0)>.94 and len(l['text'].strip())>=8]));assigned={};groups={}
18
+ for i,l in enumerate(ls):
19
+ if not l['text'].strip():continue
20
+ scores=[ov(l['box'],b)for b in layouts]
21
+ if scores and max(scores)>=.5:k=int(np.argmax(scores));groups.setdefault(k,[]).append(i);assigned[i]=k
22
+ unsupported=[i for i,l in enumerate(ls)if i not in assigned and l['text'].strip()and l.get('confidence',0)>=.9]
23
+ # Infer omitted text blocks from high-confidence aligned physical baselines.
24
+ for i in unsupported:
25
+ b=ls[i]['box'];found=None
26
+ for k,ids in groups.items():
27
+ if k<len(layouts):continue
28
+ bb=union([ls[j]['box']for j in ids]);x=max(0,min(b[2],bb[2])-max(b[0],bb[0]));gap=max(0,max(b[1],bb[1])-min(b[3],bb[3]))
29
+ if x>.55*min(b[2]-b[0],bb[2]-bb[0])and abs(b[0]-bb[0])<1.5*med and gap<2*med:found=k;break
30
+ k=found if found is not None else len(layouts)+i+1;groups.setdefault(k,[]).append(i);assigned[i]=k
31
+ drop=[]
32
+ for i,l in enumerate(ls):
33
+ if not l['text'].strip()or i in assigned:continue
34
+ if l.get('confidence',0)<.55 and len(''.join(l['text'].split()))<=8:drop.append(i)
35
+ else:groups[len(layouts)+len(ls)+i+1]=[i]
36
+ if not groups:return old
37
+ blocks=[dict(ids=sorted(ids,key=lambda i:(ls[i]['box'][1],ls[i]['box'][0])),box=union([ls[i]['box']for i in ids]))for ids in groups.values()]
38
+ def cut(blocks):
39
+ if len(blocks)<=1:return blocks
40
+ for axis in [1,0]:
41
+ intervals=sorted((z['box'][axis],z['box'][axis+2])for z in blocks);end=intervals[0][1];gaps=[]
42
+ for a,b in intervals[1:]:
43
+ if a-end>.25*med:gaps.append((a-end,(a+end)/2))
44
+ end=max(end,b)
45
+ if gaps:
46
+ _,mid=max(gaps);left=[z for z in blocks if z['box'][axis+2]<=mid];right=[z for z in blocks if z['box'][axis]>=mid]
47
+ if left and right and len(left)+len(right)==len(blocks):return cut(left)+cut(right)
48
+ return sorted(blocks,key=lambda z:(z['box'][1],z['box'][0]))
49
+ ordered=cut(blocks);ids=[i for z in ordered for i in z['ids']];newtext='\n\n'.join(ls[i]['text']for i in ids)
50
+ if newtext==out['text']:return old
51
+ out['lines']=[ls[i]for i in ids];out['text']=newtext;out['regions']=[dict(path='atomic_graphic_text_blocks',lines=[ls[i]for i in z['ids']],text='\n'.join(ls[i]['text']for i in z['ids']),box=z['box'])for z in ordered];out['graphic_block_assembly']=dict(colour_fraction=colour,latin_fraction=latin/len(letters),removed=[ls[i]for i in drop],blocks=ordered,confidence_threshold=.55,threshold_source='Existing frozen private-dev graphic-junk threshold; no193/TT tuning',reader_changed=False);return out
stallion/bina04-assembly-audit-95k-20261104/production.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sys,importlib.util
3
+ R=Path(__file__).resolve().parent;sys.path.insert(0,str(R));from graphic_blocks import apply
4
+ class Runtime:
5
+ def __init__(self,device='auto'):
6
+ s=importlib.util.spec_from_file_location('graphic_frozen_parent','__BINA_ROOT__/stallion/bina04-block-fields-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device);self.raw=self.parent.raw;self.core=self.parent.core
7
+ def __call__(self,image,old,kind,name='',**kwargs):return apply(self.parent(image,old,kind,name,**kwargs),image)
8
+ def close(self):self.parent.close()
stallion/bina04-block-fields-95k-20261103/assembly_fix.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import numpy as np,cv2
3
+
4
+ def norm(s):return ''.join(s.split())
5
+ def rule_bands(gray):
6
+ H,W=gray.shape;bw=cv2.adaptiveThreshold(gray,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,31,12);r=cv2.morphologyEx(bw,cv2.MORPH_OPEN,np.ones((1,max(25,int(W*.35))),np.uint8));ys=np.flatnonzero((r>0).sum(1)>.65*W);return [float(np.median(g))for g in np.split(ys,np.flatnonzero(np.diff(ys)>1)+1)if len(g)]if len(ys)else[]
7
+ def apply(old,image,reader=None):
8
+ out=copy.deepcopy(old);ls=out.get('lines',[]);W,H=image.size
9
+ if len(ls)<3 or any(l.get('layer')=='handwriting'for l in ls)or any(r.get('table_trace',{}).get('grid')for r in out.get('regions',[])):return out
10
+ if norm(''.join(l.get('text','')for l in ls))!=norm(out['text']):return out
11
+ gray=np.asarray(image.convert('L'));bounds=rule_bands(gray);hs=[l['box'][3]-l['box'][1]for l in ls if len(norm(l.get('text','')))>8 and l['confidence']>.8];med=float(np.median(hs))if hs else 0
12
+ if med<=0:return out
13
+ trace=dict(header_blocks=[],form_rows=[],split_boxes=[])
14
+ # Split a vertically merged ruling-band heading and content. Horizontal expansion
15
+ # is bounded by actual foreground in that row, not the detected partial x-extent.
16
+ if reader is not None and len(bounds)>=4:
17
+ for i,l in enumerate(ls[:]):
18
+ x,y,X,Y=l['box'];hh=Y-y
19
+ if hh<2.5*med or hh>.12*H or X-x<.4*W:continue
20
+ cells=[(a,b)for a,b in zip(bounds,bounds[1:])if a>=y-2 and b<=Y+2 and .5*med<b-a<3*med]
21
+ if not 2<=len(cells)<=4:continue
22
+ boxes=[]
23
+ for a,b in cells:
24
+ yy=max(0,int(a)+2);YY=min(H,int(b)-1);ink=(gray[yy:YY]<170).astype(np.uint8);hr=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((1,max(30,int(.3*W))),np.uint8));vr=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((max(8,int(.8*(YY-yy))),1),np.uint8));ink[(hr>0)|(vr>0)]=0;ys=np.flatnonzero(ink.sum(1)>3);xs=np.flatnonzero(ink.sum(0)>1)
25
+ if len(ys)<4 or len(xs)<8:break
26
+ boxes.append([max(0,int(xs[0])-2),max(0,yy+int(ys[0])-2),min(W,int(xs[-1])+3),min(H,yy+int(ys[-1])+3)])
27
+ if len(boxes)!=len(cells):continue
28
+ rr=reader(gray,boxes)
29
+ if any(r['confidence']<.65 or len(norm(r['text']))<4 for r in rr):continue
30
+ ls[i:i+1]=[dict(r,layer=l.get('layer'),source='ruling_band_multiline_split')for r in rr];trace['split_boxes'].append(dict(original=l['box'],boxes=boxes,original_text=l['text'],new_text=[r['text']for r in rr]));break
31
+ # Restore recovered printed fields to physical rows, regardless of admission layer.
32
+ if len(bounds)>=4:
33
+ assignment={}
34
+ for a,b in zip(bounds,bounds[1:]):
35
+ if not .6*med<b-a<3*med:continue
36
+ ids=[i for i,l in enumerate(ls)if norm(l.get('text','')) and a<=(l['box'][1]+l['box'][3])/2<=b and l['box'][3]-l['box'][1]<1.6*(b-a)]
37
+ if len(ids)<2:continue
38
+ if any(min(ls[i]['box'][2],ls[j]['box'][2])-max(ls[i]['box'][0],ls[j]['box'][0])>.2*min(ls[i]['box'][2]-ls[i]['box'][0],ls[j]['box'][2]-ls[j]['box'][0])for k,i in enumerate(ids)for j in ids[k+1:]):continue
39
+ order=sorted(ids,key=lambda i:-ls[i]['box'][2]);assignment.update({i:order for i in ids});trace['form_rows'].append(dict(band=[a,b],before=ids,after=order))
40
+ done=set();order=[]
41
+ for i in range(len(ls)):
42
+ if i in done:continue
43
+ ids=assignment.get(i,[i]);order+=ids;done.update(ids)
44
+ ls=[ls[i]for i in order]
45
+ # Side-by-side headers remain atomic blocks, read RTL. The reading convention
46
+ # is geometric; no document names, reference strings or page-specific IDs.
47
+ left=[i for i,l in enumerate(ls)if l['box'][2]<.42*W and l['box'][3]<.17*H and len(norm(l['text']))>=8 and l['confidence']>.75 and l['box'][3]-l['box'][1]<2*med]
48
+ right=[i for i,l in enumerate(ls)if l['box'][0]>.58*W and l['box'][3]<.17*H and len(norm(l['text']))>=8 and l['confidence']>.75 and l['box'][3]-l['box'][1]<2*med]
49
+ if len(left)>=2 and len(right)>=2:
50
+ ids=set(left+right);ordered=sorted(right,key=lambda i:(ls[i]['box'][1],-ls[i]['box'][2]))+sorted(left,key=lambda i:(ls[i]['box'][1],-ls[i]['box'][2]));new=[]
51
+ for i in range(len(ls)):
52
+ if i==min(ids):new+=ordered
53
+ if i not in ids:new.append(i)
54
+ trace['header_blocks']=dict(left_ids=left,right_ids=right,after=ordered,convention='right block then left block; each top-to-bottom');ls=[ls[i]for i in new]
55
+ if not trace['split_boxes'] and [l['text']for l in ls]==[l['text']for l in old['lines']]:return old
56
+ out['lines']=ls;out['regions']=[dict(path='physical_header_and_form_rows',layout=None,lines=[l],text=l['text'])for l in ls];out['text']='\n\n'.join(l['text']for l in ls);out['physical_block_fields']=trace;return out
stallion/bina04-block-fields-95k-20261103/production.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import importlib.util,sys
3
+ R=Path(__file__).resolve().parent;sys.path.insert(0,str(R))
4
+ from assembly_fix import apply
5
+ class Runtime:
6
+ def __init__(self,device='auto'):
7
+ s=importlib.util.spec_from_file_location('block_fields_parent','__BINA_ROOT__/stallion/bina04-quality-route-printscope-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device);self.raw=self.parent.raw;self.core=self.parent.core
8
+ from pipeline_v3 import UnifiedReader
9
+ V=Path('__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031');self.split_reader=UnifiedReader(V/'run/weights-095000.pt',V/'data/vocab.json',device='cpu')
10
+ def __call__(self,image,old,kind,name='',**kwargs):
11
+ out=self.parent(image,old,kind,name,**kwargs);return apply(out,image,self.split_reader)if kind=='page'else out
12
+ def close(self):self.parent.close()
stallion/bina04-cell-rowcores-95k-20261103/candidate.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attach detached small bands inside cells; preserve two full text rows."""
2
+ import copy,sys,numpy as np
3
+ from pathlib import Path
4
+ sys.path.insert(0,'__BINA_ROOT__/stallion/bina04-crop-picture-cells-95k-20261102');sys.path.insert(0,'__BINA_ROOT__/stallion/bina04-crop-bandseg-95k-20261102')
5
+ from cell_reader import read_cell,owned_hand_cells
6
+ from bandseg import segment
7
+
8
+ def rowcores(image):
9
+ boxes,trace=segment(image);boxes=sorted(boxes,key=lambda b:b[1]);merged=[];events=[]
10
+ for b in boxes:
11
+ if merged:
12
+ a=merged[-1];ha=a[3]-a[1];hb=b[3]-b[1];gap=max(0,b[1]-a[3]);horizontal=min(a[2],b[2])-max(a[0],b[0])
13
+ # A small detached dot/ascender band cannot be a second full writing row.
14
+ if min(ha,hb)<.6*max(ha,hb)and gap<=.5*max(ha,hb)and horizontal>0:
15
+ merged[-1]=[min(a[0],b[0]),min(a[1],b[1]),max(a[2],b[2]),max(a[3],b[3])];events.append(dict(first=a,second=b,reason='unequal core heights, near and horizontally overlapping'));continue
16
+ merged.append(list(b))
17
+ trace=dict(trace,cell_fragment_attachments=events);return merged,trace
18
+
19
+ class Patch:
20
+ def __init__(self,reader,flip):self.reader=reader;self.flip=flip
21
+ def __call__(self,image,base,kind='crop'):
22
+ if kind!='crop':return base
23
+ out=copy.deepcopy(base);rgb=np.asarray(image.convert('RGB'));changed=[]
24
+ for region in out['regions']:
25
+ t=region.get('table_trace',{});cells=t.get('cell_debug',[])
26
+ if not t.get('grid')or not cells:continue
27
+ pen=region.get('table_cell_contract',{}).get('table_pen',False);owned,owner_trace=owned_hand_cells(rgb,cells)if pen else(None,None)
28
+ for index,c in enumerate(cells):
29
+ if len(c.get('lines',[]))<2:continue
30
+ heights=[l['box'][3]-l['box'][1]for l in c['lines']]
31
+ if min(heights)>=.6*max(heights):continue
32
+ pixels,box=owned[index]if pen else(rgb,c['bbox']);text,ll,trace=read_cell(self.reader,self.flip,pixels,box,rowcores,pen=False)
33
+ if not trace.get('proposal_trace',{}).get('cell_fragment_attachments'):continue
34
+ old=c['text'];c.update(text=text,lines=ll,cell_rowcores=trace);changed.append(dict(row=c['row'],col=c['col'],old=old,new=text,old_lines=len(heights),new_lines=len(ll)))
35
+ values={(c['row'],c['col']):c['text']for c in cells};rendered=[]
36
+ for i,row in enumerate(t['kept_rows']):
37
+ vals=[' '.join(values.get((row,col),'').split()).replace('|',r'\|')for col in t['kept_columns']];rendered.append('| '+' | '.join(vals)+' |')
38
+ if i==0:rendered.append('| '+' | '.join('---'for _ in vals)+' |')
39
+ region['text']='\n'.join(rendered);region['lines']=[l for c in cells for l in c['lines']]
40
+ if not changed:return base
41
+ out.update(text='\n\n'.join(r['text']for r in out['regions']),lines=[l for r in out['regions']for l in r['lines']],cell_rowcore_changes=changed);out['emitted_boxes']=[l['box']for l in out['lines']];out['recognition_extents']=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in out['lines']];return out
stallion/bina04-cell-rowcores-95k-20261103/production.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ from pathlib import Path
3
+ R=Path(__file__).resolve().parent
4
+ class Runtime:
5
+ def __init__(self,device='auto'):
6
+ s=importlib.util.spec_from_file_location('rowcore_frozen_parent','__BINA_ROOT__/stallion/bina04-header-marks-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device)
7
+ s=importlib.util.spec_from_file_location('rowcore_header_apply','__BINA_ROOT__/stallion/bina04-header-marks-95k-20261103/candidate.py');self.header=importlib.util.module_from_spec(s);s.loader.exec_module(self.header)
8
+ s=importlib.util.spec_from_file_location('rowcore_candidate',R/'candidate.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m)
9
+ from indra_ocr.rtl import flip
10
+ self.patch=m.Patch(self.parent.parent.numeric,lambda t:flip(t,base_rtl=True))
11
+ def __call__(self,image,old,kind,name='',**kwargs):
12
+ out=self.parent.parent(image,old,kind,name,**kwargs)
13
+ assert self.parent.params['header_confidence']>1,'Frozen accepted header mode required'
14
+ data=dict(new=[],old_support=[1.]*len(out['lines']),WH=list(image.size),header_regions=[])
15
+ out=self.header.apply(out,data,self.parent.params)
16
+ return self.patch(image,out,kind)
17
+ def close(self):self.parent.close()
stallion/bina04-column-assembly-95k-20261103/column_order.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative physical-gutter order for dense, two-column RTL text; tables atomic."""
2
+ import copy,statistics
3
+ from assembly import norm
4
+
5
+ def apply_columns(old,W,H):
6
+ out=copy.deepcopy(old);lines=out.get('lines',[])
7
+ if any(r.get('table_trace',{}).get('grid')for r in out.get('regions',[])):return out
8
+ useful=[i for i,l in enumerate(lines)if len(norm(l.get('text','')))>=8 and .12*W<l['box'][2]-l['box'][0]<.65*W]
9
+ if len(useful)<10:return out
10
+ candidates=[]
11
+ for k in range(20,81):
12
+ x=W*k/100;left=[i for i in useful if lines[i]['box'][2]<=x];right=[i for i in useful if lines[i]['box'][0]>=x];cross=[i for i in useful if i not in left+right]
13
+ if min(len(left),len(right))<4 or len(cross)>.05*len(useful):continue
14
+ span=lambda ids:max(lines[i]['box'][3]for i in ids)-min(lines[i]['box'][1]for i in ids)
15
+ if min(span(left),span(right))<.4*H:continue
16
+ gap=min(lines[i]['box'][0]for i in right)-max(lines[i]['box'][2]for i in left)
17
+ if gap<.012*W:continue
18
+ candidates.append((len(left)+len(right),gap,x,left,right))
19
+ if not candidates:return out
20
+ _,gap,x,left,right=max(candidates,key=lambda v:(v[0],v[1]));med=statistics.median(lines[i]['box'][3]-lines[i]['box'][1]for i in useful);start=max(min(lines[i]['box'][1]for i in left),min(lines[i]['box'][1]for i in right));end=min(max(lines[i]['box'][3]for i in left),max(lines[i]['box'][3]for i in right))
21
+ # A shared spanning heading distinguishes one multi-column document from
22
+ # two independent exam/form panels with their own headers. Ambiguous pages retain baseline order.
23
+ if not any(l['box'][2]-l['box'][0]>.65*W and l['box'][1]<start and len(norm(l.get('text','')))>=8 for l in lines):return out
24
+ headers=[];footers=[];lc=[];rc=[]
25
+ for i,l in enumerate(lines):
26
+ if not l.get('text','').strip():continue
27
+ b=l['box'];cy=(b[1]+b[3])/2
28
+ if b[3]<start-.25*med or b[0]<x<b[2]and cy<start+med:headers.append(i)
29
+ elif b[0]<x<b[2]and b[1]>end-med:footers.append(i)
30
+ elif b[0]<x<b[2]:return old # A genuine spanning middle block makes a simple cut ambiguous.
31
+ elif (b[0]+b[2])/2>x:rc.append(i)
32
+ else:lc.append(i)
33
+ key=lambda i:(lines[i]['box'][1],-lines[i]['box'][2]);ids=sorted(headers,key=key)+sorted(rc,key=key)+sorted(lc,key=key)+sorted(footers,key=key)
34
+ # Reorder reads, never edit them. Only apply when plain-line emission exactly accounts for output.
35
+ if norm(''.join(lines[i]['text']for i in range(len(lines))))!=norm(out['text']):return out
36
+ out['text']='\n\n'.join(lines[i]['text']for i in ids);out['lines']=[lines[i]for i in ids]
37
+ out['regions']=[dict(path='physical_column_order',layout=None,source_line_ids=[i],lines=[lines[i]],text=lines[i]['text'])for i in ids]
38
+ out['physical_column_order']=dict(applied=True,gutter_x=x,gutter_width=gap,header_ids=headers,right_ids=rc,left_ids=lc,footer_ids=footers,read_text_changed=False)
39
+ return out
stallion/bina04-column-assembly-95k-20261103/production.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accepted page assembly with conservative shared-heading RTL column order."""
2
+ import importlib.util,sys
3
+ from pathlib import Path
4
+ R=Path(__file__).resolve().parent
5
+ sys.path.insert(0,str(R))
6
+ from column_order import apply_columns
7
+ class Runtime:
8
+ def __init__(self,device='auto'):
9
+ s=importlib.util.spec_from_file_location('columns_frozen_parent','__BINA_ROOT__/stallion/bina04-page-assembly-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device)
10
+ def __call__(self,image,old,kind,name='',**kwargs):return apply_columns(self.parent(image,old,kind,name,**kwargs),*image.size)
11
+ def close(self):self.parent.close()
stallion/bina04-crop-bandseg-95k-20261102/bandseg.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pixel-only crop line-count guard; pages and atomic tables stay unchanged."""
2
+ import copy
3
+ import cv2
4
+ import numpy as np
5
+ from scipy.ndimage import gaussian_filter1d
6
+ from scipy.signal import find_peaks, peak_widths
7
+
8
+
9
+ def glyph_height(m, fallback=3.):
10
+ _,_,stats,_=cv2.connectedComponentsWithStats(m.astype(np.uint8),8)
11
+ values=[(int(h),int(a)) for x,y,w,h,a in stats[1:] if h>=3 and w>=2 and h<=m.shape[0]]
12
+ if not values:return fallback
13
+ values.sort();hs,areas=zip(*values)
14
+ return float(hs[np.searchsorted(np.cumsum(areas),.70*sum(areas))])
15
+
16
+
17
+ def mask(gray):
18
+ _, m = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
19
+ if (m > 0).mean() > .5:
20
+ m = 255 - m
21
+ n, labels, stats, _ = cv2.connectedComponentsWithStats(m, 8)
22
+ H, W = gray.shape
23
+ good = []
24
+ heights = []
25
+ for i, (x, y, w, h, area) in enumerate(stats[1:], 1):
26
+ if area < 2 or (w > .8 * W and h > .65 * H):
27
+ continue # connected frames/decorations, not a text-line core
28
+ if (w > .7 * W and h < max(3, .035 * H)) or (h > .7 * H and w < max(2,.035*W)):
29
+ continue # ruling line
30
+ good.append(i)
31
+ if h >= 3 and w >= 2 and h < .9 * H:
32
+ heights.append(h)
33
+ clean = np.isin(labels, good)
34
+ glyph = float(np.percentile(heights, 70)) if heights else max(3., H / 2)
35
+ # Remove long peripheral scan rules without deleting connected text.
36
+ hline=cv2.morphologyEx(clean.astype(np.uint8),cv2.MORPH_OPEN,np.ones((1,max(8,int(.65*W))),np.uint8))
37
+ vline=cv2.morphologyEx(clean.astype(np.uint8),cv2.MORPH_OPEN,np.ones((max(8,int(.65*H)),1),np.uint8))
38
+ hline[int(.12*H):int(.88*H),:]=0
39
+ vline[:,int(.12*W):int(.88*W)]=0
40
+ clean &= ~((hline>0)|(vline>0))
41
+ framed=any(w>.8*W and h>.65*H for x,y,w,h,area in stats[1:])
42
+ if framed:
43
+ edge_rules=cv2.morphologyEx(clean.astype(np.uint8),cv2.MORPH_OPEN,np.ones((1,max(8,int(.35*W))),np.uint8))
44
+ edge_rules[int(.25*H):int(.75*H),:]=0
45
+ clean &= ~(edge_rules>0)
46
+ if H<160:
47
+ for i,(x,y,w,h,area) in enumerate(stats[1:],1):
48
+ if h>1.8*glyph and .7*h<w<1.5*h and (x<.12*W or x+w>.88*W) and w<.2*W:
49
+ clean[labels==i]=False
50
+ return clean, glyph_height(clean,glyph)
51
+
52
+
53
+ def horizontal(m, glyph, scale=1):
54
+ H, W = m.shape
55
+ prof = gaussian_filter1d(m.sum(1).astype(float), max(.6, .055 * glyph))
56
+ if prof.max() < 1:
57
+ return [], dict(peaks=[], valleys=[])
58
+ peaks, props = find_peaks(np.r_[0., prof, 0.],
59
+ distance=max(3, int(.35 * glyph),int(min(9*scale,H/3))),
60
+ prominence=max(1.5, .10 * prof.max()))
61
+ peaks = peaks - 1
62
+ peaks = [int(p) for p in peaks if 0 <= p < H]
63
+ _, _, comps, _ = cv2.connectedComponentsWithStats(m.astype(np.uint8), 8)
64
+ def local_height(y):
65
+ rows=[(h,area) for x,cy,w,h,area in comps[1:] if cy <= y < cy+h and h>=3 and w>=2 and h<.7*H]
66
+ if not rows:return glyph
67
+ rows.sort();hs,areas=zip(*rows)
68
+ return float(hs[np.searchsorted(np.cumsum(areas),.7*sum(areas))])
69
+ # A detached dot or descender is not another baseline. Only a deep valley
70
+ # between substantial core peaks can create another physical line.
71
+ while len(peaks) > 1:
72
+ remove = None
73
+ for i, (a, b) in enumerate(zip(peaks, peaks[1:])):
74
+ valley = float(prof[a:b+1].min())
75
+ if valley > .55 * min(prof[a], prof[b]) or b-a < .85*max(local_height(a),local_height(b)):
76
+ remove = i if prof[a] < prof[b] else i + 1
77
+ break
78
+ if remove is None:
79
+ break
80
+ peaks.pop(remove)
81
+ if not peaks:
82
+ peaks = [int(prof.argmax())]
83
+ cuts = [0]
84
+ for a, b in zip(peaks, peaks[1:]):
85
+ q = prof[a:b+1]
86
+ low = np.flatnonzero(q <= q.min() + .015 * min(prof[a], prof[b]))
87
+ cuts.append(a + int(np.median(low)))
88
+ cuts.append(H)
89
+ bands = []
90
+ for i, (a, b) in enumerate(zip(cuts, cuts[1:])):
91
+ ys = np.flatnonzero(m[a:b].sum(1) >= max(1, .015 * prof.max()))
92
+ if not len(ys):
93
+ continue
94
+ y0, y1 = a + int(ys[0]), a + int(ys[-1]) + 1
95
+ xs = np.flatnonzero(m[y0:y1].any(0))
96
+ if not len(xs) or y1-y0 < max(2,.4*local_height(peaks[i])):
97
+ continue
98
+ # Keep the separating valley as a hard recognition crop boundary.
99
+ bands.append([int(xs[0]), y0, int(xs[-1])+1, y1])
100
+ if len(bands)>1:
101
+ tallest=max(b[3]-b[1] for b in bands)
102
+ bands=[b for b in bands if not (b[3]-b[1]<.45*tallest and (b[1]<=scale or b[3]>=H-scale))]
103
+ return bands, dict(peaks=peaks, valleys=cuts[1:-1], glyph=glyph)
104
+
105
+
106
+ def runs(v):
107
+ edges = np.diff(np.r_[False, v, False].astype(np.int8))
108
+ return list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)))
109
+
110
+
111
+ def segment(gray):
112
+ H, W = gray.shape
113
+ scale = 3 if H < 40 else 2 if H < 64 else 1
114
+ big = cv2.resize(gray, (W*scale, H*scale), interpolation=cv2.INTER_CUBIC) if scale != 1 else gray
115
+ events = []
116
+
117
+ def recurse(g, ox=0, oy=0, depth=0, allow_horizontal=True, supplied_mask=None):
118
+ if supplied_mask is None:m,glyph=mask(g)
119
+ else:m=supplied_mask;glyph=glyph_height(m,3.)
120
+ hh, ww = g.shape
121
+ bands, ht = horizontal(m, glyph,scale)
122
+ ys=np.flatnonzero(m.any(1))
123
+ mass=m.sum(1);total=mass.sum()
124
+ if total:
125
+ lo,hi=np.searchsorted(np.cumsum(mass),[.01*total,.99*total])
126
+ else:lo,hi=0,0
127
+ if depth==0 and (len(bands)<=1 or hi-lo<=1.5*glyph):
128
+ return [[0,int(ys[0]),ww,int(ys[-1])+1]] if len(ys) else []
129
+ if depth < 3 and len(bands):
130
+ # True gutter candidates, with multiple lines on at least one side.
131
+ # A narrow gutter is allowed only between different type scales
132
+ # (e.g. a small side column alongside a giant headline).
133
+ col = m.mean(0)
134
+ gaps = [(a,b) for a,b in runs(col <= .012)
135
+ if a > .12*ww and b < .88*ww and b-a >= 2*scale]
136
+ for a,b in sorted(gaps, key=lambda q: q[1]-q[0], reverse=True):
137
+ left,right=m[:,:a],m[:,b:]
138
+ gl,gr=glyph_height(left,3.),glyph_height(right,3.)
139
+ if min(gl,gr)<(.25 if max(gl,gr)<64 else .12)*max(gl,gr):continue
140
+ lb, _ = horizontal(left, gl,scale); rb, _ = horizontal(right, gr,scale)
141
+ area = max(1,m.sum())
142
+ disparate = max(gl,gr) >= 3.*max(1,min(gl,gr))
143
+ true_columns = min(len(lb),len(rb)) >= 2 and b-a >= 1.5*min(gl,gr)
144
+ if max(len(lb),len(rb)) < 2 or min(left.sum(),right.sum()) < .025*area or a < 3*gl or ww-b < 3*gr:
145
+ continue
146
+ if not disparate or max(len(lb),len(rb))<3:
147
+ continue
148
+ L = recurse(g[:, :a], ox, oy, depth+1, supplied_mask=left)
149
+ R = recurse(g[:, b:], ox+b, oy, depth+1, supplied_mask=right)
150
+ events.append(dict(kind='vertical_gutter', box=[ox,oy,ox+ww,oy+hh], gap=[int(a),int(b)], glyphs=[gl,gr]))
151
+ # Primary large headline before its small side column; ordinary
152
+ # columns use RTL. Within each column retain top-to-bottom.
153
+ return (L+R) if gl >= 3.*gr else (R+L)
154
+ if len(bands) >= 2 and depth < 3 and allow_horizontal:
155
+ out = []
156
+ # Inspect each horizontal band for a side column. Whitespace is
157
+ # bounded by the valley, so adjacent lines never enter the crop.
158
+ for x0,y0,x1,y1 in bands:
159
+ out += recurse(g[y0:y1, :], ox, oy+y0, depth+1, allow_horizontal=False, supplied_mask=m[y0:y1,:])
160
+ events.append(dict(kind='horizontal_valleys',box=[ox,oy,ox+ww,oy+hh],**ht))
161
+ return out
162
+ if not allow_horizontal and len(bands)>1:
163
+ ys,xs=np.where(m)
164
+ bands=[[int(xs.min()),int(ys.min()),int(xs.max())+1,int(ys.max())+1]] if len(xs) else []
165
+ return [[ox,oy+y0,ox+ww,oy+y1] for x0,y0,x1,y1 in bands]
166
+
167
+ root_mask, root_glyph=mask(big)
168
+ _,labs,stats,_=cv2.connectedComponentsWithStats(root_mask.astype(np.uint8),8)
169
+ for i,(x,y,w,h,area) in enumerate(stats[1:],1):
170
+ if h<.10*root_glyph and area<.15*root_glyph**2:
171
+ root_mask[labs==i]=False
172
+ boxes = recurse(big,supplied_mask=root_mask)
173
+ # A compact framed badge has ornamental upper/lower strokes, not extra
174
+ # text baselines. Do not turn its border into recognizer inputs.
175
+ _,_,raw_stats,_=cv2.connectedComponentsWithStats((cv2.threshold(big,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)[1]>0).astype(np.uint8),8)
176
+ framed=any(w>.8*big.shape[1] and h>.65*big.shape[0] for x,y,w,h,a in raw_stats[1:])
177
+ if framed and W/max(1,H)>2.5 and len(boxes)>=2:
178
+ dominant=max(boxes,key=lambda b:b[3]-b[1])
179
+ if dominant[3]-dominant[1]>.45*big.shape[0] and all(b is dominant or b[3]-b[1]<.25*big.shape[0] for b in boxes):
180
+ boxes=[dominant]
181
+ events.append(dict(kind='framed_badge_ornaments_not_lines'))
182
+ # Recognition margins are real pixels, not a larger coverage-only receipt.
183
+ # Limit vertical expansion at the midpoint between adjacent physical rows.
184
+ padded=[]
185
+ for b in boxes:
186
+ h=b[3]-b[1];xp=max(2*scale,.15*h);yp=max(2*scale,.2*h)
187
+ lo,hi=max(0,b[1]-yp),min(big.shape[0],b[3]+yp)
188
+ for q in boxes:
189
+ overlap=min(b[2],q[2])-max(b[0],q[0])
190
+ if q is b or overlap<.2*min(b[2]-b[0],q[2]-q[0]):continue
191
+ if q[3]<=b[1]:lo=max(lo,(q[3]+b[1])/2)
192
+ if q[1]>=b[3]:hi=min(hi,(q[1]+b[3])/2)
193
+ padded.append([max(0,b[0]-xp),lo,min(big.shape[1],b[2]+xp),hi])
194
+ boxes = [[v/scale for v in b] for b in padded]
195
+ return boxes, dict(scale=scale, boxes=boxes, events=events, image_only=True)
196
+
197
+
198
+ def should_replace(baseline, boxes):
199
+ if len(boxes) < 2:
200
+ return False, 'one_or_zero_supported_band'
201
+ lines = baseline.get('lines', [])
202
+ if any('Surya_grid' in r.get('path','') or (r.get('layout') or {}).get('label','').lower()=='table' for r in baseline.get('regions',[])) or any(r.get('label','').lower()=='table' for r in baseline.get('raw_layout',{}).get('bboxes',[])):
203
+ return False, 'atomic_table'
204
+ old = [l['box'] for l in lines]
205
+ # Keep successful existing multiline reads; repair a box spanning two or
206
+ # more separately supported cores, or a genuine shortage of physical rows.
207
+ if len(old) < len(boxes):
208
+ return True, 'more_physical_lines_than_reads'
209
+ for b in old:
210
+ contained = []
211
+ for q in boxes:
212
+ iy=max(0,min(b[3],q[3])-max(b[1],q[1]))
213
+ ix=max(0,min(b[2],q[2])-max(b[0],q[0]))
214
+ if iy >= .8*(q[3]-q[1]) and ix >= .6*(q[2]-q[0]):
215
+ contained.append(q)
216
+ if len(contained)>=2:
217
+ return True, 'existing_read_spans_multiple_cores'
218
+ return False, 'existing_multiline_reads_preserved'
219
+
220
+
221
+ class CropBandPatch:
222
+ def __init__(self, reader, flip):
223
+ self.reader, self.flip = reader, flip
224
+
225
+ def __call__(self, image, baseline, *, input_type='crop'):
226
+ if input_type != 'crop':
227
+ return copy.deepcopy(baseline)
228
+ gray = np.asarray(image.convert('L'))
229
+ boxes, trace = segment(gray)
230
+ use, reason = should_replace(baseline, boxes)
231
+ trace.update(applied=use,reason=reason,original_reads=len(baseline.get('lines',[])))
232
+ result = copy.deepcopy(baseline)
233
+ if not use:
234
+ result['crop_band_guard'] = trace
235
+ return result
236
+ lines=[]
237
+ scale=trace['scale']
238
+ big=cv2.resize(gray,(gray.shape[1]*scale,gray.shape[0]*scale),interpolation=cv2.INTER_CUBIC) if scale!=1 else gray
239
+ used=set()
240
+ for b in boxes:
241
+ # Keep an existing correctly localized row, including its actual
242
+ # training-contract margins; discard only merged/full-crop reads.
243
+ matches=[]
244
+ for j,old in enumerate(baseline.get('lines',[])):
245
+ o=old['box'];h=b[3]-b[1];oh=o[3]-o[1]
246
+ iy=max(0,min(b[3],o[3])-max(b[1],o[1]));ix=max(0,min(b[2],o[2])-max(b[0],o[0]))
247
+ if j not in used and .7*h<=oh<=1.6*h and iy>=.8*h and ix>=.65*(b[2]-b[0]):
248
+ overlaps=sum(q is not b and max(0,min(q[3],o[3])-max(q[1],o[1]))>=.7*(q[3]-q[1]) for q in boxes)
249
+ if not overlaps:matches.append((iy*ix,j))
250
+ if matches:
251
+ j=max(matches)[1];lines.append(copy.deepcopy(baseline['lines'][j]));used.add(j);continue
252
+ x0,y0,x1,y1=[int(round(v*scale)) for v in b]
253
+ local=big[y0:y1,x0:x1]
254
+ # Clip the local canvas before adding the exact training margins:
255
+ # neighbouring lines cannot bleed into crop_line64's 20% pad.
256
+ _, receipts=self.reader(local,np.asarray([[0,0,local.shape[1],local.shape[0]]],np.float32))
257
+ r=receipts[0]
258
+ text=self.flip(r['raw_visual_text'],base_rtl=True)
259
+ r['source_pixels_extent']=b
260
+ r['band_local_original_box']=b
261
+ lines.append(dict(box=b,text=text,visual_text=r['raw_visual_text'],confidence=r['confidence'],geometry=r,recognizer='unified32M_95k',confidence_filter_applied=False))
262
+ # Retain small existing reads that projection cannot see (footnotes,
263
+ # signatures), rather than losing them when repairing a merged box.
264
+ median=float(np.median([b[3]-b[1] for b in boxes]))
265
+ for j,old in enumerate(baseline.get('lines',[])):
266
+ if j in used:continue
267
+ o=old['box'];oh=o[3]-o[1]
268
+ if oh>1.5*median:continue
269
+ hits=sum(max(0,min(o[3],q[3])-max(o[1],q[1])) >= .5*min(oh,q[3]-q[1]) and min(o[2],q[2])-max(o[0],q[0]) >= .5*min(o[2]-o[0],q[2]-q[0]) for q in boxes)
270
+ if hits>=2:continue
271
+ overlaps=[max(0,min(o[3],q[3])-max(o[1],q[1]))/max(1,oh) for q in boxes if min(o[2],q[2])>max(o[0],q[0])]
272
+ if overlaps and max(overlaps)>=.65:continue
273
+ at=next((k for k,l in enumerate(lines) if (l['box'][1]+l['box'][3])/2>(o[1]+o[3])/2),len(lines))
274
+ lines.insert(at,copy.deepcopy(old))
275
+ region=dict(layout=None,path='crop_projection_physical_lines' ,text='\n'.join(l['text']for l in lines),lines=lines,source_line_ids=list(range(len(lines))))
276
+ result.update(text=region['text'],lines=lines,global_readings=lines,regions=[region],detected_boxes=boxes,emitted_boxes=[l['box'] for l in lines],recognition_extents=[l.get('geometry',{}).get('source_pixels_extent',l['box']) for l in lines],crop_band_guard=trace)
277
+ return result
stallion/bina04-crop-bandseg-95k-20261102/consumer_runtime.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crop-only preflight around the accepted 95k engine; no page-path edits.
2
+
3
+ The parent supplies its normal image-only layout receipt in `old`. This wrapper
4
+ does not need reference text, gold line boxes, benchmark IDs or categories.
5
+ """
6
+ import hashlib
7
+ import numpy as np
8
+ from bandseg import CropBandPatch, segment, should_replace
9
+
10
+
11
+ class BandGuardRuntime:
12
+ def __init__(self, parent_engine, flip):
13
+ self.parent = parent_engine
14
+ reader = parent_engine.patch.reader
15
+ self.patch = CropBandPatch(reader.reader, flip)
16
+ self.context = context = {}
17
+ original = type(reader)
18
+
19
+ class PreflightReader(original):
20
+ def __call__(self, gray, boxes):
21
+ H,W = gray.shape
22
+ if (context.get('enabled') and len(boxes)==1
23
+ and np.allclose(boxes[0],[0,0,W,H])
24
+ and gray.shape == context['shape']
25
+ and hashlib.sha256(gray.tobytes()).hexdigest()==context['pixels']):
26
+ context['full_strip_reads_skipped'] += 1
27
+ return [dict(box=[0.,0.,float(W),float(H)],text='',visual_text='',
28
+ confidence=0.,recognizer='skipped_by_multiline_preflight',
29
+ geometry=dict(source_pixels_extent=[0.,0.,float(W),float(H)],padded_source_extent=[0.,0.,float(W),float(H)],raw_visual_text=''))]
30
+ return super().__call__(gray, boxes)
31
+
32
+ reader.__class__ = PreflightReader
33
+
34
+ def __call__(self,image,old,kind,name,**kwargs):
35
+ self.context.clear()
36
+ if kind=='crop':
37
+ gray=np.asarray(image.convert('L'))
38
+ boxes,trace=segment(gray)
39
+ use,reason=should_replace(old,boxes)
40
+ self.context.update(enabled=use,shape=gray.shape,
41
+ pixels=hashlib.sha256(gray.tobytes()).hexdigest(),
42
+ full_strip_reads_skipped=0)
43
+ baseline=self.parent(image,old,kind,name,**kwargs)
44
+ result=self.patch(image,baseline,input_type=kind)
45
+ if kind=='crop':
46
+ result['crop_band_preflight']=dict(full_strip_reads_skipped=self.context['full_strip_reads_skipped'],
47
+ before_rule_C=True,reference_or_label_used=False)
48
+ return result
49
+
50
+ def close(self):
51
+ self.parent.close()
stallion/bina04-crop-picture-cells-95k-20261102/CALIBRATION.json ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source": "Internal synthetic dev80 and frozen calib25 only; no193/check25/publicTT calibration",
3
+ "pen": {
4
+ "train_samples": 12589,
5
+ "dev_samples": 3180,
6
+ "train_pages": 160,
7
+ "dev_pages": 40,
8
+ "dev": {
9
+ "threshold": 0.12000000000000001,
10
+ "printed_FPR": 0.0029154518950437317,
11
+ "handwriting_recall": 0.9996475149806133
12
+ },
13
+ "seconds": 3.9756869729608297,
14
+ "parameters": 1623,
15
+ "source": "Existing synthetic train only; threshold on synthetic dev only; no193/dev50/TT training",
16
+ "options": [
17
+ {
18
+ "threshold": 0.1,
19
+ "printed_FPR": 0.011661807580174927,
20
+ "handwriting_recall": 0.9996475149806133
21
+ },
22
+ {
23
+ "threshold": 0.11,
24
+ "printed_FPR": 0.011661807580174927,
25
+ "handwriting_recall": 0.9996475149806133
26
+ },
27
+ {
28
+ "threshold": 0.12000000000000001,
29
+ "printed_FPR": 0.0029154518950437317,
30
+ "handwriting_recall": 0.9996475149806133
31
+ },
32
+ {
33
+ "threshold": 0.13,
34
+ "printed_FPR": 0.0029154518950437317,
35
+ "handwriting_recall": 0.9996475149806133
36
+ },
37
+ {
38
+ "threshold": 0.14,
39
+ "printed_FPR": 0.0029154518950437317,
40
+ "handwriting_recall": 0.9996475149806133
41
+ },
42
+ {
43
+ "threshold": 0.15000000000000002,
44
+ "printed_FPR": 0.0029154518950437317,
45
+ "handwriting_recall": 0.9996475149806133
46
+ },
47
+ {
48
+ "threshold": 0.16,
49
+ "printed_FPR": 0.0,
50
+ "handwriting_recall": 0.9996475149806133
51
+ },
52
+ {
53
+ "threshold": 0.17,
54
+ "printed_FPR": 0.0,
55
+ "handwriting_recall": 0.9996475149806133
56
+ },
57
+ {
58
+ "threshold": 0.18,
59
+ "printed_FPR": 0.0,
60
+ "handwriting_recall": 0.9992950299612267
61
+ },
62
+ {
63
+ "threshold": 0.19,
64
+ "printed_FPR": 0.0,
65
+ "handwriting_recall": 0.9992950299612267
66
+ },
67
+ {
68
+ "threshold": 0.2,
69
+ "printed_FPR": 0.0,
70
+ "handwriting_recall": 0.9985900599224533
71
+ },
72
+ {
73
+ "threshold": 0.21000000000000002,
74
+ "printed_FPR": 0.0,
75
+ "handwriting_recall": 0.9985900599224533
76
+ },
77
+ {
78
+ "threshold": 0.22,
79
+ "printed_FPR": 0.0,
80
+ "handwriting_recall": 0.9985900599224533
81
+ },
82
+ {
83
+ "threshold": 0.23,
84
+ "printed_FPR": 0.0,
85
+ "handwriting_recall": 0.9985900599224533
86
+ },
87
+ {
88
+ "threshold": 0.24000000000000002,
89
+ "printed_FPR": 0.0,
90
+ "handwriting_recall": 0.9985900599224533
91
+ },
92
+ {
93
+ "threshold": 0.25,
94
+ "printed_FPR": 0.0,
95
+ "handwriting_recall": 0.9982375749030666
96
+ },
97
+ {
98
+ "threshold": 0.26,
99
+ "printed_FPR": 0.0,
100
+ "handwriting_recall": 0.9982375749030666
101
+ },
102
+ {
103
+ "threshold": 0.27,
104
+ "printed_FPR": 0.0,
105
+ "handwriting_recall": 0.9982375749030666
106
+ },
107
+ {
108
+ "threshold": 0.28,
109
+ "printed_FPR": 0.0,
110
+ "handwriting_recall": 0.9982375749030666
111
+ },
112
+ {
113
+ "threshold": 0.29000000000000004,
114
+ "printed_FPR": 0.0,
115
+ "handwriting_recall": 0.99788508988368
116
+ },
117
+ {
118
+ "threshold": 0.30000000000000004,
119
+ "printed_FPR": 0.0,
120
+ "handwriting_recall": 0.99788508988368
121
+ },
122
+ {
123
+ "threshold": 0.31,
124
+ "printed_FPR": 0.0,
125
+ "handwriting_recall": 0.99788508988368
126
+ },
127
+ {
128
+ "threshold": 0.32,
129
+ "printed_FPR": 0.0,
130
+ "handwriting_recall": 0.99788508988368
131
+ },
132
+ {
133
+ "threshold": 0.33,
134
+ "printed_FPR": 0.0,
135
+ "handwriting_recall": 0.9975326048642933
136
+ },
137
+ {
138
+ "threshold": 0.33999999999999997,
139
+ "printed_FPR": 0.0,
140
+ "handwriting_recall": 0.9975326048642933
141
+ },
142
+ {
143
+ "threshold": 0.35,
144
+ "printed_FPR": 0.0,
145
+ "handwriting_recall": 0.9971801198449066
146
+ },
147
+ {
148
+ "threshold": 0.36,
149
+ "printed_FPR": 0.0,
150
+ "handwriting_recall": 0.9971801198449066
151
+ },
152
+ {
153
+ "threshold": 0.37,
154
+ "printed_FPR": 0.0,
155
+ "handwriting_recall": 0.9971801198449066
156
+ },
157
+ {
158
+ "threshold": 0.38,
159
+ "printed_FPR": 0.0,
160
+ "handwriting_recall": 0.9964751498061333
161
+ },
162
+ {
163
+ "threshold": 0.39,
164
+ "printed_FPR": 0.0,
165
+ "handwriting_recall": 0.9964751498061333
166
+ },
167
+ {
168
+ "threshold": 0.4,
169
+ "printed_FPR": 0.0,
170
+ "handwriting_recall": 0.9964751498061333
171
+ },
172
+ {
173
+ "threshold": 0.41000000000000003,
174
+ "printed_FPR": 0.0,
175
+ "handwriting_recall": 0.9964751498061333
176
+ },
177
+ {
178
+ "threshold": 0.42000000000000004,
179
+ "printed_FPR": 0.0,
180
+ "handwriting_recall": 0.9964751498061333
181
+ },
182
+ {
183
+ "threshold": 0.43000000000000005,
184
+ "printed_FPR": 0.0,
185
+ "handwriting_recall": 0.9961226647867466
186
+ },
187
+ {
188
+ "threshold": 0.44000000000000006,
189
+ "printed_FPR": 0.0,
190
+ "handwriting_recall": 0.9961226647867466
191
+ },
192
+ {
193
+ "threshold": 0.45000000000000007,
194
+ "printed_FPR": 0.0,
195
+ "handwriting_recall": 0.9961226647867466
196
+ },
197
+ {
198
+ "threshold": 0.45999999999999996,
199
+ "printed_FPR": 0.0,
200
+ "handwriting_recall": 0.9961226647867466
201
+ },
202
+ {
203
+ "threshold": 0.47,
204
+ "printed_FPR": 0.0,
205
+ "handwriting_recall": 0.9954176947479733
206
+ },
207
+ {
208
+ "threshold": 0.48,
209
+ "printed_FPR": 0.0,
210
+ "handwriting_recall": 0.9947127247091999
211
+ },
212
+ {
213
+ "threshold": 0.49,
214
+ "printed_FPR": 0.0,
215
+ "handwriting_recall": 0.9947127247091999
216
+ },
217
+ {
218
+ "threshold": 0.5,
219
+ "printed_FPR": 0.0,
220
+ "handwriting_recall": 0.9947127247091999
221
+ },
222
+ {
223
+ "threshold": 0.51,
224
+ "printed_FPR": 0.0,
225
+ "handwriting_recall": 0.9947127247091999
226
+ },
227
+ {
228
+ "threshold": 0.52,
229
+ "printed_FPR": 0.0,
230
+ "handwriting_recall": 0.9940077546704265
231
+ },
232
+ {
233
+ "threshold": 0.53,
234
+ "printed_FPR": 0.0,
235
+ "handwriting_recall": 0.9940077546704265
236
+ },
237
+ {
238
+ "threshold": 0.54,
239
+ "printed_FPR": 0.0,
240
+ "handwriting_recall": 0.9936552696510398
241
+ },
242
+ {
243
+ "threshold": 0.55,
244
+ "printed_FPR": 0.0,
245
+ "handwriting_recall": 0.9936552696510398
246
+ },
247
+ {
248
+ "threshold": 0.56,
249
+ "printed_FPR": 0.0,
250
+ "handwriting_recall": 0.9936552696510398
251
+ },
252
+ {
253
+ "threshold": 0.5700000000000001,
254
+ "printed_FPR": 0.0,
255
+ "handwriting_recall": 0.9933027846316531
256
+ },
257
+ {
258
+ "threshold": 0.58,
259
+ "printed_FPR": 0.0,
260
+ "handwriting_recall": 0.9925978145928798
261
+ },
262
+ {
263
+ "threshold": 0.59,
264
+ "printed_FPR": 0.0,
265
+ "handwriting_recall": 0.9925978145928798
266
+ },
267
+ {
268
+ "threshold": 0.6,
269
+ "printed_FPR": 0.0,
270
+ "handwriting_recall": 0.9918928445541064
271
+ },
272
+ {
273
+ "threshold": 0.61,
274
+ "printed_FPR": 0.0,
275
+ "handwriting_recall": 0.9918928445541064
276
+ },
277
+ {
278
+ "threshold": 0.62,
279
+ "printed_FPR": 0.0,
280
+ "handwriting_recall": 0.9918928445541064
281
+ },
282
+ {
283
+ "threshold": 0.63,
284
+ "printed_FPR": 0.0,
285
+ "handwriting_recall": 0.9918928445541064
286
+ },
287
+ {
288
+ "threshold": 0.64,
289
+ "printed_FPR": 0.0,
290
+ "handwriting_recall": 0.9918928445541064
291
+ },
292
+ {
293
+ "threshold": 0.65,
294
+ "printed_FPR": 0.0,
295
+ "handwriting_recall": 0.9915403595347198
296
+ },
297
+ {
298
+ "threshold": 0.66,
299
+ "printed_FPR": 0.0,
300
+ "handwriting_recall": 0.9904829044765597
301
+ },
302
+ {
303
+ "threshold": 0.67,
304
+ "printed_FPR": 0.0,
305
+ "handwriting_recall": 0.9897779344377864
306
+ },
307
+ {
308
+ "threshold": 0.6799999999999999,
309
+ "printed_FPR": 0.0,
310
+ "handwriting_recall": 0.9897779344377864
311
+ },
312
+ {
313
+ "threshold": 0.69,
314
+ "printed_FPR": 0.0,
315
+ "handwriting_recall": 0.9894254494183997
316
+ },
317
+ {
318
+ "threshold": 0.7,
319
+ "printed_FPR": 0.0,
320
+ "handwriting_recall": 0.9883679943602397
321
+ },
322
+ {
323
+ "threshold": 0.71,
324
+ "printed_FPR": 0.0,
325
+ "handwriting_recall": 0.988015509340853
326
+ },
327
+ {
328
+ "threshold": 0.72,
329
+ "printed_FPR": 0.0,
330
+ "handwriting_recall": 0.9876630243214664
331
+ },
332
+ {
333
+ "threshold": 0.73,
334
+ "printed_FPR": 0.0,
335
+ "handwriting_recall": 0.986958054282693
336
+ },
337
+ {
338
+ "threshold": 0.74,
339
+ "printed_FPR": 0.0,
340
+ "handwriting_recall": 0.9866055692633063
341
+ },
342
+ {
343
+ "threshold": 0.75,
344
+ "printed_FPR": 0.0,
345
+ "handwriting_recall": 0.985900599224533
346
+ },
347
+ {
348
+ "threshold": 0.76,
349
+ "printed_FPR": 0.0,
350
+ "handwriting_recall": 0.9851956291857596
351
+ },
352
+ {
353
+ "threshold": 0.77,
354
+ "printed_FPR": 0.0,
355
+ "handwriting_recall": 0.9844906591469863
356
+ },
357
+ {
358
+ "threshold": 0.78,
359
+ "printed_FPR": 0.0,
360
+ "handwriting_recall": 0.9837856891082128
361
+ },
362
+ {
363
+ "threshold": 0.79,
364
+ "printed_FPR": 0.0,
365
+ "handwriting_recall": 0.9820232640112795
366
+ },
367
+ {
368
+ "threshold": 0.8,
369
+ "printed_FPR": 0.0,
370
+ "handwriting_recall": 0.9806133239337328
371
+ },
372
+ {
373
+ "threshold": 0.8099999999999999,
374
+ "printed_FPR": 0.0,
375
+ "handwriting_recall": 0.9792033838561861
376
+ },
377
+ {
378
+ "threshold": 0.82,
379
+ "printed_FPR": 0.0,
380
+ "handwriting_recall": 0.9777934437786394
381
+ },
382
+ {
383
+ "threshold": 0.83,
384
+ "printed_FPR": 0.0,
385
+ "handwriting_recall": 0.9767359887204794
386
+ },
387
+ {
388
+ "threshold": 0.84,
389
+ "printed_FPR": 0.0,
390
+ "handwriting_recall": 0.9756785336623194
391
+ },
392
+ {
393
+ "threshold": 0.85,
394
+ "printed_FPR": 0.0,
395
+ "handwriting_recall": 0.9725061684878392
396
+ },
397
+ {
398
+ "threshold": 0.86,
399
+ "printed_FPR": 0.0,
400
+ "handwriting_recall": 0.9700387733521325
401
+ },
402
+ {
403
+ "threshold": 0.87,
404
+ "printed_FPR": 0.0,
405
+ "handwriting_recall": 0.9682763482551991
406
+ },
407
+ {
408
+ "threshold": 0.88,
409
+ "printed_FPR": 0.0,
410
+ "handwriting_recall": 0.9658089531194924
411
+ },
412
+ {
413
+ "threshold": 0.89,
414
+ "printed_FPR": 0.0,
415
+ "handwriting_recall": 0.9629890729643991
416
+ },
417
+ {
418
+ "threshold": 0.9,
419
+ "printed_FPR": 0.0,
420
+ "handwriting_recall": 0.9591117377511456
421
+ },
422
+ {
423
+ "threshold": 0.91,
424
+ "printed_FPR": 0.0,
425
+ "handwriting_recall": 0.9548819175185055
426
+ },
427
+ {
428
+ "threshold": 0.92,
429
+ "printed_FPR": 0.0,
430
+ "handwriting_recall": 0.9502996122664786
431
+ },
432
+ {
433
+ "threshold": 0.93,
434
+ "printed_FPR": 0.0,
435
+ "handwriting_recall": 0.9436023968981319
436
+ },
437
+ {
438
+ "threshold": 0.94,
439
+ "printed_FPR": 0.0,
440
+ "handwriting_recall": 0.9344377863940783
441
+ },
442
+ {
443
+ "threshold": 0.95,
444
+ "printed_FPR": 0.0,
445
+ "handwriting_recall": 0.9213958406767713
446
+ },
447
+ {
448
+ "threshold": 0.96,
449
+ "printed_FPR": 0.0,
450
+ "handwriting_recall": 0.9041240747268241
451
+ },
452
+ {
453
+ "threshold": 0.97,
454
+ "printed_FPR": 0.0,
455
+ "handwriting_recall": 0.8790976383503701
456
+ },
457
+ {
458
+ "threshold": 0.98,
459
+ "printed_FPR": 0.0,
460
+ "handwriting_recall": 0.8403242862178357
461
+ },
462
+ {
463
+ "threshold": 0.99,
464
+ "printed_FPR": 0.0,
465
+ "handwriting_recall": 0.7673598872047938
466
+ }
467
+ ]
468
+ },
469
+ "junk": {
470
+ "threshold": 0.55,
471
+ "false_positive_controls": 0,
472
+ "dev_junk_removed": 0
473
+ },
474
+ "pen_trials": [
475
+ {
476
+ "threshold": 0.05,
477
+ "printed_FPR": 0.9416666666666667,
478
+ "coloured_handwriting_recall": 1.0,
479
+ "printed": 120,
480
+ "handwriting": 80
481
+ },
482
+ {
483
+ "threshold": 0.1,
484
+ "printed_FPR": 0.8833333333333333,
485
+ "coloured_handwriting_recall": 0.9625,
486
+ "printed": 120,
487
+ "handwriting": 80
488
+ },
489
+ {
490
+ "threshold": 0.15,
491
+ "printed_FPR": 0.775,
492
+ "coloured_handwriting_recall": 0.8375,
493
+ "printed": 120,
494
+ "handwriting": 80
495
+ },
496
+ {
497
+ "threshold": 0.2,
498
+ "printed_FPR": 0.675,
499
+ "coloured_handwriting_recall": 0.6875,
500
+ "printed": 120,
501
+ "handwriting": 80
502
+ },
503
+ {
504
+ "threshold": 0.25,
505
+ "printed_FPR": 0.4666666666666667,
506
+ "coloured_handwriting_recall": 0.525,
507
+ "printed": 120,
508
+ "handwriting": 80
509
+ },
510
+ {
511
+ "threshold": 0.3,
512
+ "printed_FPR": 0.2,
513
+ "coloured_handwriting_recall": 0.45,
514
+ "printed": 120,
515
+ "handwriting": 80
516
+ },
517
+ {
518
+ "threshold": 0.4,
519
+ "printed_FPR": 0.1,
520
+ "coloured_handwriting_recall": 0.4375,
521
+ "printed": 120,
522
+ "handwriting": 80
523
+ },
524
+ {
525
+ "threshold": 0.5,
526
+ "printed_FPR": 0.06666666666666667,
527
+ "coloured_handwriting_recall": 0.3875,
528
+ "printed": 120,
529
+ "handwriting": 80
530
+ },
531
+ {
532
+ "threshold": 0.6,
533
+ "printed_FPR": 0.05,
534
+ "coloured_handwriting_recall": 0.375,
535
+ "printed": 120,
536
+ "handwriting": 80
537
+ },
538
+ {
539
+ "threshold": 0.7,
540
+ "printed_FPR": 0.03333333333333333,
541
+ "coloured_handwriting_recall": 0.2125,
542
+ "printed": 120,
543
+ "handwriting": 80
544
+ },
545
+ {
546
+ "threshold": 0.8,
547
+ "printed_FPR": 0.008333333333333333,
548
+ "coloured_handwriting_recall": 0.0375,
549
+ "printed": 120,
550
+ "handwriting": 80
551
+ },
552
+ {
553
+ "threshold": 0.9,
554
+ "printed_FPR": 0.0,
555
+ "coloured_handwriting_recall": 0.0,
556
+ "printed": 120,
557
+ "handwriting": 80
558
+ }
559
+ ],
560
+ "junk_trials": [
561
+ {
562
+ "threshold": 0.35,
563
+ "false_positive_controls": 0,
564
+ "dev_junk_removed": 0
565
+ },
566
+ {
567
+ "threshold": 0.45,
568
+ "false_positive_controls": 0,
569
+ "dev_junk_removed": 0
570
+ },
571
+ {
572
+ "threshold": 0.55,
573
+ "false_positive_controls": 0,
574
+ "dev_junk_removed": 0
575
+ },
576
+ {
577
+ "threshold": 0.65,
578
+ "false_positive_controls": 1,
579
+ "dev_junk_removed": 0
580
+ },
581
+ {
582
+ "threshold": 0.75,
583
+ "false_positive_controls": 1,
584
+ "dev_junk_removed": 1
585
+ },
586
+ {
587
+ "threshold": 0.8,
588
+ "false_positive_controls": 2,
589
+ "dev_junk_removed": 2
590
+ },
591
+ {
592
+ "threshold": 0.85,
593
+ "false_positive_controls": 2,
594
+ "dev_junk_removed": 2
595
+ },
596
+ {
597
+ "threshold": 0.9,
598
+ "false_positive_controls": 2,
599
+ "dev_junk_removed": 2
600
+ },
601
+ {
602
+ "threshold": 0.95,
603
+ "false_positive_controls": 2,
604
+ "dev_junk_removed": 2
605
+ }
606
+ ],
607
+ "synthetic_short_control_cases": 0,
608
+ "synthetic_short_correct": 0,
609
+ "calib25_short_cases": 37,
610
+ "manifest_sha256": "959ee4f442705c44dea8557c850a1004af3fdfb8d7c0f1fdfe90a8291f773940",
611
+ "pen_colour_abstention": 0.05
612
+ }
stallion/bina04-crop-picture-cells-95k-20261102/candidate.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Isolated picture/cell candidate on the accepted bandseg path."""
2
+ import copy,json
3
+ from pathlib import Path
4
+ import numpy as np
5
+ from cell_reader import pen_score,read_cell,owned_hand_cells
6
+ from junk_filter import apply as filter_junk
7
+ from pen_classifier import PenClassifier
8
+ R=Path(__file__).resolve().parent
9
+
10
+ class CropPatch:
11
+ def __init__(self,reader,flip,segment):
12
+ self.reader,self.flip,self.segment=reader,flip,segment
13
+ self.cal=json.loads((R/'CALIBRATION.json').read_text())
14
+ self.router=PenClassifier(R/'pen-classifier.joblib')
15
+ def __call__(self,image,baseline,*,input_type='crop'):
16
+ if input_type!='crop':return copy.deepcopy(baseline)
17
+ result=copy.deepcopy(baseline);rgb=np.asarray(image.convert('RGB'));trace=[]
18
+ page_colour_score=pen_score(rgb)
19
+ for region in result.get('regions',[]):
20
+ t=region.get('table_trace',{});cells=t.get('cell_debug',[])
21
+ if not t.get('grid')or not cells:continue
22
+ probabilities=[]
23
+ for cell in cells:
24
+ x,y,X,Y=[int(round(v))for v in cell['bbox']]
25
+ view=rgb[max(0,y):min(rgb.shape[0],Y),max(0,x):min(rgb.shape[1],X)]
26
+ probabilities.append(self.router.probability(view)if view.size and page_colour_score>.05 else 0.)
27
+ nonempty=[i for i,c in enumerate(cells)if c['text'].strip()]
28
+ table_pen=bool(nonempty)and sum(probabilities[i]>=self.router.threshold for i in nonempty)>len(nonempty)/2
29
+ ownership,owner_trace=owned_hand_cells(rgb,cells)if table_pen else (None,None)
30
+ values={};lines=[]
31
+ for index,cell in enumerate(cells):
32
+ probability=probabilities[index];pen=table_pen
33
+ pixels,box=ownership[index]if table_pen else (rgb,cell['bbox'])
34
+ text,ll,info=read_cell(self.reader,self.flip,pixels,box,self.segment,pen=False)
35
+ if table_pen:info['physical_component_ownership']=owner_trace
36
+ trace.append(dict(row=cell['row'],col=cell['col'],old=cell['text'],new=text,old_lines=len(cell['lines']),new_lines=len(ll),pen_route=pen,pen_probability=probability,preprocessing=info))
37
+ cell.update(text=text,lines=ll,cell_contract=info)
38
+ values[(cell['row'],cell['col'])]=text;lines+=ll
39
+ rendered=[]
40
+ for i,row in enumerate(t['kept_rows']):
41
+ vals=[' '.join(values.get((row,col),'').split()).replace('|',r'\|')for col in t['kept_columns']]
42
+ rendered.append('| '+' | '.join(vals)+' |')
43
+ if i==0:rendered.append('| '+' | '.join('---'for _ in vals)+' |')
44
+ region['text']='\n'.join(rendered);region['lines']=lines
45
+ region['table_cell_contract']=dict(pen_cells=sum(q['pen_route']for q in trace),pen_score=page_colour_score,table_pen=table_pen,component_ownership=owner_trace,checkpoint=95000,no_date_text_substitution=True)
46
+ if trace:
47
+ result['text']='\n\n'.join(r['text']for r in result['regions'])
48
+ result['lines']=[l for r in result['regions']for l in r['lines']]
49
+ result['emitted_boxes']=[l['box']for l in result['lines']]
50
+ result['recognition_extents']=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in result['lines']]
51
+ result['cell_contract_changes']=trace
52
+ result,removed=filter_junk(result,self.cal['junk']['threshold'])
53
+ return result
54
+
55
+ class PipelineRuntime:
56
+ def __init__(self,parent,flip,segment):
57
+ self.parent=parent
58
+ # The accepted BandGuardRuntime owns the raw95k RTLReader here.
59
+ self.patch=CropPatch(parent.patch.reader,flip,segment)
60
+ def __call__(self,image,old,kind,name,**kwargs):
61
+ return self.patch(image,self.parent(image,old,kind,name,**kwargs),input_type=kind)
62
+ def close(self):self.parent.close()
stallion/bina04-crop-picture-cells-95k-20261102/cell_reader.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Physical-line cell reads; original95k, no date repair or text substitution."""
2
+ import math
3
+ import cv2
4
+ import numpy as np
5
+
6
+ def owned_hand_cells(rgb,cells):
7
+ """Assign whole connected ink components once, rather than cut at virtual grids.
8
+
9
+ Pale paper rulings do not participate. Recognition keeps original stroke
10
+ pixels plus their antialiasing fringe on a white, row-isolated canvas.
11
+ """
12
+ g=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
13
+ value,_=cv2.threshold(g,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
14
+ fg=(g<min(160,max(60,value+1))).astype(np.uint8)
15
+ n,labels,stats,_=cv2.connectedComponentsWithStats(fg,8)
16
+ owners=[[]for _ in cells];crossings=0
17
+ for i,(x,y,w,h,area)in enumerate(stats[1:],1):
18
+ if area<2:continue
19
+ scores=[]
20
+ for c in cells:
21
+ a,b,A,B=c['bbox']
22
+ scores.append(max(0,min(x+w,A)-max(x,a))*max(0,min(y+h,B)-max(y,b)))
23
+ if not scores or max(scores)<=0:continue
24
+ owner=int(np.argmax(scores));owners[owner].append(i)
25
+ a,b,A,B=cells[owner]['bbox']
26
+ crossings+=int(x<a or x+w>A or y<b or y+h>B)
27
+ out=[]
28
+ for ids,c in zip(owners,cells):
29
+ m=np.isin(labels,ids)
30
+ yy,xx=np.where(m)
31
+ if not len(xx):out.append((np.full_like(rgb,255),c['bbox']));continue
32
+ halo=cv2.dilate(m.astype(np.uint8),np.ones((3,3),np.uint8))>0
33
+ canvas=np.full_like(rgb,255);canvas[halo]=rgb[halo]
34
+ out.append((canvas,[float(xx.min()),float(yy.min()),float(xx.max()+1),float(yy.max()+1)]))
35
+ return out,dict(connected_components_assigned=sum(map(len,owners)),components_crossing_inferred_cell_edges=crossings,ink_components_never_split=True,original_stroke_pixels_preserved=True)
36
+
37
+ def pen_score(rgb):
38
+ a=rgb.astype(float);g=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
39
+ dark=g<130
40
+ return float(((a[:,:,2]-a[:,:,0]>12)&(a[:,:,1]-a[:,:,0]>3)&dark).sum()/max(1,dark.sum()))
41
+
42
+ def prepare(rgb,pen=False):
43
+ g=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
44
+ work=g.copy()
45
+ if pen:
46
+ a=rgb.astype(float)
47
+ # Pale cyan ruled-paper lines differ from the darker blue/black pen.
48
+ # Erase only long light rulings, retaining strokes crossing the rule.
49
+ cyan=((a[:,:,1]-a[:,:,0])>12)&((a[:,:,1]-a[:,:,2])>0)&(g>130)
50
+ work[cyan]=255
51
+ val,_=cv2.threshold(work,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
52
+ ink=work<min(190,max(50,val+1))
53
+ H,W=ink.shape
54
+ # Gemini's useful border check: only peripheral, long ruling segments;
55
+ # punctuation and strokes in the interior are retained.
56
+ hh=cv2.morphologyEx(ink.astype(np.uint8),cv2.MORPH_OPEN,np.ones((1,max(10,round(.75*W))),np.uint8))
57
+ vv=cv2.morphologyEx(ink.astype(np.uint8),cv2.MORPH_OPEN,np.ones((max(10,round(.75*H)),1),np.uint8))
58
+ hh[3:max(3,H-3),:]=0;vv[:,3:max(3,W-3)]=0
59
+ rules=(hh|vv)>0;work[rules]=255;ink[rules]=False
60
+ return work,ink,dict(pen_mask=pen,peripheral_rule_pixels=int(rules.sum()),interior_separators_not_removed=True)
61
+
62
+
63
+ def read_cell(reader,flip,rgb,bbox,segment,*,pen=False):
64
+ H,W=rgb.shape[:2];x,y,X,Y=bbox
65
+ a,b,A,B=max(0,math.floor(x)-3),max(0,math.floor(y)-3),min(W,math.ceil(X)+3),min(H,math.ceil(Y)+3)
66
+ local=rgb[b:B,a:A]
67
+ if not local.size:return '',[],dict(empty=True)
68
+ work,ink,trace=prepare(local,pen)
69
+ if ink.sum()<3:return '',[],dict(trace,empty=True)
70
+ binary=np.where(ink,0,255).astype(np.uint8)
71
+ proposals,pt=segment(binary)
72
+ if not proposals:proposals=[[0,0,work.shape[1],work.shape[0]]]
73
+ proposals.sort(key=lambda q:(q[1]+q[3])/2)
74
+ # A cell's columns are atomic; only physical horizontal rows are split.
75
+ centres=[(q[1]+q[3])/2 for q in proposals]
76
+ cuts=[0]+[round((c+d)/2)for c,d in zip(centres,centres[1:])]+[work.shape[0]]
77
+ lines=[]
78
+ for top,bottom in zip(cuts,cuts[1:]):
79
+ ys,xs=np.where(ink[top:bottom])
80
+ if len(ys)<3:continue
81
+ # Tight on ALL ink, including one-pixel colon dots and slash tips;
82
+ # crop_line64 supplies the unchanged20/30percent training margins.
83
+ core=[float(xs.min()),float(ys.min()),float(xs.max()+1),float(ys.max()+1)]
84
+ plane=work[top:bottom].copy()
85
+ _,receipts=reader(plane,np.asarray([core],np.float32))
86
+ r=receipts[0];offset=[a,b+top,a,b+top]
87
+ for key in ['ink_box','padded_source_extent']:
88
+ r[key]=[float(v)+o for v,o in zip(r[key],offset)]
89
+ r['source_pixels_extent']=[max(a,r['padded_source_extent'][0]),max(b+top,r['padded_source_extent'][1]),min(A,r['padded_source_extent'][2]),min(b+bottom,r['padded_source_extent'][3])]
90
+ r['original_WH']=[W,H]
91
+ r['frame_centers']=[float(v)+a for v in r['frame_centers']]
92
+ r['row_mask_isolation']=True;r['pen_mask_applied']=pen
93
+ line=dict(box=[v+o for v,o in zip(core,offset)],text=flip(r['raw_visual_text']),visual_text=r['raw_visual_text'],confidence=r['confidence'],geometry=r,recognizer='handwriting95k_pen_mask'if pen else'printed95k_cell_contract')
94
+ lines.append(line)
95
+ return ' '.join(q['text'].strip()for q in lines if q['text'].strip()),lines,dict(trace,empty=not bool(lines),proposal_trace=pt,padded_bbox=[a,b,A,B],physical_line_count=len(lines),same_training_contract=True)
stallion/bina04-crop-picture-cells-95k-20261102/junk_filter.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative crop-only graphic junk filter; threshold from dev only."""
2
+ import copy
3
+
4
+ def clean(s):return ''.join(s.split()).replace('\u200c','').replace('\u200d','')
5
+ def area(b):return max(0,b[2]-b[0])*max(0,b[3]-b[1])
6
+ def overlap(a,b):return area([max(a[0],b[0]),max(a[1],b[1]),min(a[2],b[2]),min(a[3],b[3])])/max(1,area(a))
7
+ def rect(r):
8
+ if 'bbox'in r:return r['bbox']
9
+ p=r['polygon'];return[min(q[0]for q in p),min(q[1]for q in p),max(q[0]for q in p),max(q[1]for q in p)]
10
+
11
+ def context(line,pipeline):
12
+ b=line['box'];h=max(2,b[3]-b[1]);cy=(b[1]+b[3])/2
13
+ layouts=pipeline.get('raw_layout',{}).get('bboxes',[])
14
+ text_support=any(overlap(b,rect(r))>=.5 for r in layouts if r.get('label','').lower()in['text','sectionheader','pageheader','caption','listitem','formula','table'])
15
+ picture=any(overlap(b,rect(r))>=.8 for r in layouts if r.get('label','').lower()in['picture','figure','image'])
16
+ neighbours=[]
17
+ for q in pipeline.get('lines',[]):
18
+ if q is line or len(clean(q['text']))<4 or q.get('confidence',0)<.92:continue
19
+ z=q['box'];hh=max(2,z[3]-z[1]);d=abs(cy-(z[1]+z[3])/2);scale=max(h,hh)
20
+ xgap=max(0,max(b[0],z[0])-min(b[2],z[2]));xo=max(0,min(b[2],z[2])-max(b[0],z[0]))
21
+ same=d<.6*scale and xgap<3*scale
22
+ stacked=d<1.25*scale and xo>.7*min(b[2]-b[0],z[2]-z[0])
23
+ if same or stacked:neighbours.append(z)
24
+ return dict(text_layout_support=text_support,picture=picture,neighbouring_text=bool(neighbours))
25
+
26
+ def decision(line,pipeline,threshold):
27
+ t=clean(line['text']);c=context(line,pipeline)
28
+ # Tables have their own cell path; never suppress a table digit by this rule.
29
+ table=any('grid' in r.get('path','').lower() for r in pipeline.get('regions',[]))
30
+ short=len(t)<=3;weak=line.get('confidence',0)<threshold
31
+ use=bool(t)and weak and not table and not c['text_layout_support']and not c['neighbouring_text']and(short or c['picture']and len(t)<=8)
32
+ return use,dict(c,short=short,confidence=line.get('confidence',0),threshold=threshold)
33
+
34
+ def apply(pipeline,threshold):
35
+ result=copy.deepcopy(pipeline);removed=[];indices=[]
36
+ for i,l in enumerate(result.get('lines',[])):
37
+ use,trace=decision(l,result,threshold)
38
+ if use:removed.append(dict(line=l,reason=trace));indices.append(i)
39
+ if not removed:return result,[]
40
+ ids={id(result['lines'][i])for i in indices}
41
+ # deepcopy preserves shared references where present, but saved JSON repeats
42
+ # line dictionaries. Match box+text so every representation is consistent.
43
+ keys={(tuple(r['line']['box']),r['line']['text'])for r in removed}
44
+ keep=lambda l:(tuple(l['box']),l['text'])not in keys
45
+ result['lines']=[l for l in result['lines']if keep(l)]
46
+ for r in result.get('regions',[]):
47
+ if 'grid'in r.get('path','').lower():continue
48
+ r['lines']=[l for l in r.get('lines',[])if keep(l)];r['text']='\n'.join(l['text']for l in r['lines'])
49
+ result['regions']=[r for r in result.get('regions',[])if r.get('lines')or r.get('text','').strip()]
50
+ result['text']='\n\n'.join(r['text']for r in result['regions'])
51
+ result['emitted_boxes']=[l['box']for l in result['lines']]
52
+ result['recognition_extents']=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in result['lines']]
53
+ result['graphic_junk_filter']=dict(threshold=threshold,removed=removed,dev_only_calibration=True)
54
+ return result,removed
stallion/bina04-crop-picture-cells-95k-20261102/pen-classifier.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7c520fefc9de3021459580a20dcb848854e25f1d1a72282e4c4caf55fc26490
3
+ size 13905
stallion/bina04-crop-picture-cells-95k-20261102/pen_classifier.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small HOG/logistic pen-vs-print router. Never changes OCR pixels/aspect."""
2
+ import cv2,numpy as np
3
+ def feature(rgb):
4
+ g=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)if rgb.ndim==3 else rgb
5
+ g=cv2.resize(g,(128,32),interpolation=cv2.INTER_AREA)
6
+ g=cv2.normalize(g,None,0,255,cv2.NORM_MINMAX)
7
+ hog=cv2.HOGDescriptor((128,32),(16,16),(8,8),(8,8),9)
8
+ v=hog.compute(g).ravel()
9
+ return np.r_[v,(g<128).mean(),(g.std()/255)].astype(np.float32)
10
+ class PenClassifier:
11
+ def __init__(self,path):
12
+ import joblib
13
+ d=joblib.load(path);self.model=d['model'];self.threshold=d['threshold']
14
+ def probability(self,rgb):
15
+ g=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
16
+ t,_=cv2.threshold(g,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
17
+ yy,xx=np.where(g<min(180,max(50,t+1)))
18
+ if not len(xx):return 0.
19
+ im=rgb[yy.min():yy.max()+1,xx.min():xx.max()+1]
20
+ width=min(im.shape[1],max(32,round(5*im.shape[0])))
21
+ views=[feature(im[:,x:x+width])for x in sorted(set([0,max(0,(im.shape[1]-width)//2),max(0,im.shape[1]-width)]))]
22
+ return float(np.median(self.model.predict_proba(np.array(views))[:,1]))
stallion/bina04-detector-role-isolation-95k-20261103/production.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Match frozen crop protocol without leaking the page detector into crop guard."""
2
+ import importlib.util
3
+ from pathlib import Path
4
+ R=Path(__file__).resolve().parent
5
+ class Runtime:
6
+ def __init__(self,device='auto'):
7
+ s=importlib.util.spec_from_file_location('guard_frozen_parent','__BINA_ROOT__/stallion/bina04-cell-rowcores-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime('cpu');numeric=self.parent.parent.parent;self.numeric=numeric;self.core=numeric.engine.parent.parent;self.raw=self.core.patch.reader.reader
8
+ import baseline.crop_line_guard as g
9
+ original=next(c for c in g.CropLineGuard.__mro__ if c.__name__=='CropLineGuard'and c.__module__=='baseline.crop_line_guard');self.crop_guard=original();self.page_guard=g.CropLineGuard();self.guard_assignments=[]
10
+ import torch
11
+ if device=='auto':device='cuda'if torch.cuda.is_available()else'cpu'
12
+ if device=='cuda':
13
+ from gpu_eval_runtime import configure_fp32
14
+ configure_fp32()
15
+ for reader in [self.raw,self.numeric.numeric,self.core.patch.overlay.layer.reader]:reader.model.to('cuda');reader.device=torch.device('cuda')
16
+ def __call__(self,image,old,kind,name='',**kwargs):
17
+ with self.numeric.lock:
18
+ self.core.patch.base._crop_line_guard=self.crop_guard if kind=='crop'else self.page_guard
19
+ out=self.parent(image,old,kind,name,**kwargs)
20
+ out['detector_role_isolation']=dict(main_page_detector='Frozen combined Surya step1000',crop_guard='Original Indra LineNet'if kind=='crop'else'Frozen learned page guard',detector_device='cpu',recognizer_device=str(self.raw.device),wiring_does_not_change_weights=True);return out
21
+ def close(self):self.parent.close()
stallion/bina04-document-handoff-95k-20261103/document.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """General handoff invariants and document serialization, no document identities."""
2
+ import copy,re,unicodedata
3
+ import numpy as np,cv2
4
+
5
+ def box(r):
6
+ if 'bbox'in r:return list(r['bbox'])
7
+ p=r['polygon'];return[min(a[0]for a in p),min(a[1]for a in p),max(a[0]for a in p),max(a[1]for a in p)]
8
+ def area(b):return max(0,b[2]-b[0])*max(0,b[3]-b[1])
9
+ def overlap(a,b):return area([max(a[0],b[0]),max(a[1],b[1]),min(a[2],b[2]),min(a[3],b[3])])/max(1,area(a))
10
+ def lost_main_text(image,pipeline):
11
+ gray=np.asarray(image.convert('L'));H,W=gray.shape;coverage=np.zeros((H,W),np.uint8)
12
+ for l in pipeline['lines']:
13
+ b=l.get('geometry',{}).get('source_pixels_extent',l['box']);x,y,X,Y=map(round,b);x=max(0,x);y=max(0,y);X=min(W,X);Y=min(H,Y);coverage[y:Y,x:X]=1
14
+ failed=[];checked=[]
15
+ raw=pipeline.get('raw_line_detection',{}).get('full_image_SDK',{}).get('bboxes',[])
16
+ for a in raw:
17
+ b=box(a);x,y,X,Y=map(round,b)
18
+ if X-x<.5*W or Y-y>.06*H or Y-y<3:continue
19
+ x=max(0,x);y=max(0,y);X=min(W,X);Y=min(H,Y);g=gray[y:Y,x:X]
20
+ if not g.size:continue
21
+ val,_=cv2.threshold(g,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU);ink=g<min(190,max(60,val+1));n=int(ink.sum());fraction=float((ink*(coverage[y:Y,x:X]>0)).sum()/max(1,n));rec=dict(box=b,source_ink=n,emitted_ink_fraction=fraction);checked.append(rec)
22
+ if n>=20 and fraction<.5:failed.append(rec)
23
+ return dict(fallback_required=len(failed)>=3,failed_primary_lines=failed,checked=checked,criterion='At least3 wide primary detector lines have <50% ink covered by emitted crops; same pixels, no text labels used')
24
+
25
+ def ltr_decode(line):
26
+ from indra_ocr.rtl import flip,has_rtl
27
+ if line.get('ltr_repair'):return line
28
+ old=line['text'];g=line.get('geometry',{});raw=g.get('raw_visual_text',line.get('visual_text',''))
29
+ # Tatweel is a layout extender. Remove it before choosing runs, never after reordering.
30
+ if raw and any(c.isascii()and c.isalpha()for c in raw):
31
+ v=raw.replace('\u0640','');t=flip(v,base_rtl=True)
32
+ # Literal email punctuation can be joined without inventing letters or domains.
33
+ t=re.sub(r'(?<=[A-Za-z0-9])\s*([@.])\s*(?=[A-Za-z0-9])',r'\1',t)
34
+ t=re.sub(r'(?<=[A-Za-z0-9])\s+([.])\s*(?=[A-Za-z0-9])',r'\1',t)
35
+ if t!=old:return dict(line,text=t,document_decode=dict(original=old,raw_visual_used=True,tatweel_removed_before_bidi=True))
36
+ return line
37
+
38
+ def row_order(lines):
39
+ rows=[]
40
+ for l in sorted(lines,key=lambda l:((l['box'][1]+l['box'][3])/2,-l['box'][0])):
41
+ b=l['box'];found=False
42
+ for group in rows[-3:]:
43
+ g=group[0]['box'];vy=min(b[3],g[3])-max(b[1],g[1]);vx=min(b[2],g[2])-max(b[0],g[0])
44
+ if vy>=.5*min(b[3]-b[1],g[3]-g[1])and vx<=0:group.append(l);found=True;break
45
+ if not found:rows.append([l])
46
+ return [l for group in rows for l in sorted(group,key=lambda l:-l['box'][0])]
47
+ def join_fields(lines,W):
48
+ result=copy.deepcopy(lines);used=set()
49
+ for i,key in enumerate(result):
50
+ b=key['box'];t=key['text'].strip()
51
+ if not t or t[-1]not in ':،' or len(t)>20 or b[2]-b[0]>.25*W:continue
52
+ options=[]
53
+ for j,v in enumerate(result):
54
+ if j==i or j in used:continue
55
+ z=v['box'];s=''.join(v['text'].split());value=bool(s)and(all(c.isdigit()or c in '/:.-٫،'for c in s)or bool(re.search(r'[@.]',s)and any(c.isascii()and c.isalpha()for c in s)))
56
+ h=max(b[3]-b[1],z[3]-z[1]);distance=abs((b[1]+b[3]-z[1]-z[3])/2);gap=b[0]-z[2]
57
+ if value and 0<=gap<=3*h and distance<=1.5*h:options.append((distance,j))
58
+ if options:
59
+ _,j=min(options);v=result[j];key['text']=t+' '+v['text'].strip();key['joined_field_evidence']=[copy.deepcopy(lines[i]),copy.deepcopy(lines[j])];used.add(j)
60
+ return [l for i,l in enumerate(result)if i not in used]
61
+
62
+ def serialize(image,pipeline,graphic_threshold=.75):
63
+ out=copy.deepcopy(pipeline);W,H=image.size;layout=out.get('raw_layout',{}).get('bboxes',[]);graphics=[box(r)for r in layout if str(r.get('label','')).lower()in ['picture','figure','image','diagram']];removed=[];lines=[]
64
+ for l in out['lines']:
65
+ if l.get('uncertain_graphic')or any(overlap(l['box'],r)>=.6 for r in graphics)and l.get('confidence',0)<graphic_threshold:removed.append(l);continue
66
+ lines.append(ltr_decode(l))
67
+ # Separate a narrow edge column from a wider main column; preserve all surviving reads.
68
+ b=[l['box']for l in lines];candidates=[]
69
+ for start,end,side in [(0,.25*W,'left'),(.75*W,W,'right')]:
70
+ narrow=[i for i,a in enumerate(b)if start<=(a[0]+a[2])/2<=end and a[2]-a[0]<.3*W];wide=[i for i,a in enumerate(b)if a[2]-a[0]>.4*W and i not in narrow]
71
+ if len(narrow)<3 or len(wide)<3:continue
72
+ if side=='right':gap=min(b[i][0]for i in narrow)-max(b[i][2]for i in wide)
73
+ else:gap=min(b[i][0]for i in wide)-max(b[i][2]for i in narrow)
74
+ if gap>max(5,.005*W):candidates.append((len(narrow),side,narrow))
75
+ if candidates:
76
+ _,side,ids=max(candidates);ids=set(ids);main=[l for i,l in enumerate(lines)if i not in ids];sidebar=[l for i,l in enumerate(lines)if i in ids];main=row_order(join_fields(main,W));sidebar=row_order(join_fields(sidebar,W));lines=main+sidebar
77
+ else:side=None
78
+ # Keep generic grid serialization atomic instead of flattening cells.
79
+ if any(r.get('table_trace',{}).get('grid')for r in pipeline['regions']):return copy.deepcopy(pipeline)
80
+ out.update(lines=lines,regions=[dict(path='document_columns',lines=lines,text='\n'.join(l['text']for l in lines))],text='\n'.join(l['text']for l in lines),global_readings=lines,emitted_boxes=[l['box']for l in lines],recognition_extents=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in lines]);out['document_handoff']=dict(graphic_confidence_threshold=graphic_threshold,graphic_reads_removed=removed,narrow_sidebar=side,main_column_before_sidebar=bool(side),no_document_identity_used=True);return out
stallion/bina04-document-handoff-95k-20261103/numeric_repairs.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image-grounded numeric groups and LTR runs; no values, hosts or words hardcoded."""
2
+ import re,copy,collections,unicodedata
3
+ import cv2,numpy as np
4
+ from PIL import Image
5
+ MAP=str.maketrans('٠١٢٣٤٥٦٧٨٩','۰۱۲۳۴۵۶۷۸۹')
6
+ def slash_pixels(gray):
7
+ _,m=cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU);yy,xx=np.where(m>0)
8
+ if len(xx)<8:return False
9
+ w=xx.max()-xx.min()+1;h=yy.max()-yy.min()+1
10
+ if not(.12<w/max(1,h)<.85)or h<.25*gray.shape[0]:return False
11
+ return bool(np.corrcoef(xx,yy)[0,1]<-.7)
12
+ def compress_extenders(gray,ratio=.75):
13
+ _,m=cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU);ink=m>0;yy,xx=np.where(ink)
14
+ if not len(xx):return gray,[]
15
+ h=yy.max()-yy.min()+1;counts=ink.sum(0);thin=(counts>0)&(counts<=max(2,.12*h));e=np.diff(np.r_[False,thin,False].astype(int));remove=[];trace=[]
16
+ for a,b in zip(np.flatnonzero(e==1),np.flatnonzero(e==-1)):
17
+ if b-a<ratio*h or a<.25*h or b>gray.shape[1]-.25*h:continue
18
+ ys=np.where(ink[:,a:b])[0]
19
+ if not len(ys)or ys.max()-ys.min()+1>max(3,.2*h):continue
20
+ left=np.where(ink[:,max(0,a-round(h)):a])[0];right=np.where(ink[:,b:min(gray.shape[1],b+round(h))])[0]
21
+ if not len(left)or not len(right)or np.ptp(left)<.4*h or np.ptp(right)<.4*h:continue
22
+ keep=max(2,round(.12*h));remove+=list(range(a+keep,b));trace.append(dict(x0=int(a),x1=int(b),kept=keep))
23
+ if not remove:return gray,[]
24
+ return np.delete(gray,remove,axis=1),trace
25
+
26
+ def url_normal(s):
27
+ s=s.replace('\u0640','');s=re.sub(r'(?<=[A-Za-z0-9])\s*([@.٫])\s*(?=[A-Za-z0-9])',lambda m:'.'if m[1]=='٫'else m[1],s)
28
+ return s.strip()
29
+ def url_valid(s):return bool(re.fullmatch(r'(?:https?://)?(?:[A-Za-z0-9._%+\-]+@)?(?:[A-Za-z0-9\-]+\.)+[A-Za-z]{2,16}(?:[/:?#][\x21-\x7e]*)?',s))
30
+ class Repairs:
31
+ def __init__(self,primary,numeric,short):self.readers=dict(primary=primary,numeric=numeric,short=short)
32
+ def read(self,gray,boxes,scales=(3,)):
33
+ from text_contract import decode_prediction
34
+ rows=[[]for b in boxes]
35
+ for name,reader in self.readers.items():
36
+ for scale in scales:
37
+ work=cv2.resize(gray,None,fx=scale,fy=scale,interpolation=cv2.INTER_CUBIC)if scale!=1 else gray
38
+ for start in range(0,len(boxes),24):
39
+ bb=boxes[start:start+24];_,gg=reader(work,np.asarray([[v*scale for v in b]for b in bb],np.float32))
40
+ for j,g in enumerate(gg):rows[start+j].append(dict(reader=name,scale=scale,text=decode_prediction(g['raw_visual_text']),raw=g['raw_visual_text'],confidence=float(g['confidence'])))
41
+ return rows
42
+ def dates(self,image,line):
43
+ raw=line.get('geometry',{}).get('raw_visual_text',line.get('visual_text',''));xs=line.get('geometry',{}).get('frame_centers',[]);b=line['box'];h=b[3]-b[1]
44
+ if len(xs)!=len(raw)or not any(unicodedata.bidirectional(c)in('R','AL')for c in raw):return line
45
+ spans=[];self.date_diagnostics=[];gray=np.asarray(image.convert('L'));H,W=gray.shape
46
+ for m in re.finditer(r'[\d\s/]{3,}',raw):
47
+ if m.group().count('/')<2 or not re.search(r'\d\s+/|/\s+\d',m.group()):continue
48
+ xx=xs[m.start():m.end()];a=max(0,round(min(xx)-.2*h));A=min(W,round(max(xx)+.2*h));y=max(0,round(b[1]-2));Y=min(H,round(b[3]+2));g=gray[y:Y,a:A];_,ink=cv2.threshold(g,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU);active=(ink>0).sum(0)>1;e=np.diff(np.r_[False,active,False].astype(int));groups=[]
49
+ for x,X in zip(np.flatnonzero(e==1),np.flatnonzero(e==-1)):
50
+ if groups and x-groups[-1][1]<=.3*h:groups[-1][1]=int(X)
51
+ else:groups.append([int(x),int(X)])
52
+ boxes=[[max(0,a+x-2),y,min(W,a+X+2),Y]for x,X in groups];reads=self.read(gray,boxes);tokens=[];evidence=[]
53
+ for bb,rr in zip(boxes,reads):
54
+ votes=collections.defaultdict(dict)
55
+ for r in rr:
56
+ t=''.join(r['text'].split()).translate(MAP)
57
+ if re.fullmatch(r'[\d/]+',t)and any(c.isdigit()for c in t):votes[t][r['reader']]=r['confidence']
58
+ values=[t for t,models in votes.items()if len(models)>=2 and np.mean(sorted(models.values(),reverse=True)[:2])>=.9]
59
+ if len(values)==1:tokens.extend(re.findall(r'\d+|/',values[0]));evidence.append(dict(box=bb,value=values[0],reads=rr))
60
+ elif slash_pixels(gray[bb[1]:bb[3],bb[0]:bb[2]]):tokens.append('/');evidence.append(dict(box=bb,value='/',source='diagonal source ink'))
61
+ else:tokens.append(None);evidence.append(dict(box=bb,value=None,reads=rr))
62
+ self.date_diagnostics.append(dict(raw_span=m.group(),tokens=tokens,evidence=evidence))
63
+ # Ignore flanking letters; require every internal group to have source evidence.
64
+ good=[i for i,t in enumerate(tokens)if t is not None]
65
+ if not good:continue
66
+ ts=tokens[min(good):max(good)+1]
67
+ if None in ts:continue
68
+ nums=[t for t in ts if t!='/'];rev=list(reversed(ts));valid=False
69
+ if len(nums)==3 and len(nums[0])==4 and int(nums[0])>=1000 and 1<=int(nums[1])<=12 and 1<=int(nums[2])<=31 and ts.count('/')==2:valid=True
70
+ elif len(nums)==2 and ts.count('/')==2 and ts[0]=='/' and 1<=int(nums[0])<=12 and 1<=int(nums[1])<=31:valid=True
71
+ if valid:spans.append((m.start(),m.end(),' '.join(rev),evidence,m.group()))
72
+ if not spans:return line
73
+ result=copy.deepcopy(line);text=line['text'];replacements=[]
74
+ for start,end,new,evidence,old_run in spans:
75
+ digits=lambda t:''.join(c for c in t.translate(MAP)if c.isdigit())
76
+ matches=[m for m in re.finditer(r'[\d\s/]{3,}',text)if m.group().count('/')>=2 and digits(m.group())==digits(old_run)]
77
+ if len(matches)!=1:continue
78
+ m=matches[0];a=m.start();A=m.end();leading=' 'if m.group()[:1].isspace()else'';trailing=' 'if m.group()[-1:].isspace()else'';text=text[:a]+leading+new+trailing+text[A:];replacements.append(dict(original=m.group(),new=new,evidence=evidence))
79
+ if not replacements:return line
80
+ result['text']=text;result['numeric_group_reboxing']=dict(original=line['text'],source_groups=replacements,values_require_two_model_agreement=True,words_outside_numeric_runs_preserved=True);return result
81
+ def latin(self,image,line):
82
+ old=line['text'];raw=line.get('geometry',{}).get('raw_visual_text',line.get('visual_text',''));base=url_normal(raw)
83
+ if not any(k in raw.lower()for k in ['www','http','@']):return line
84
+ if url_valid(base):return dict(line,text=base,ltr_repair=dict(source='original visual glyph run',original=old))
85
+ gray=np.asarray(image.convert('L'));rr=self.read(gray,[line['box']],scales=(1,3))[0];candidates=[]
86
+ for r in rr:
87
+ t=url_normal(r['raw'])
88
+ if not url_valid(t)or r['confidence']<.85:continue
89
+ key=re.sub('[^A-Za-z0-9]','',t).lower();models={s['reader']for s in rr if re.sub('[^A-Za-z0-9]','',s['raw']).lower()==key}
90
+ same=[s for s in rr if url_normal(s['raw'])==t]
91
+ if len(models)>=2 and len({s['scale']for s in same})==2:candidates.append((r['confidence'],t,r))
92
+ if not candidates:return line
93
+ _,t,r=max(candidates);return dict(line,text=t,ltr_repair=dict(source='two-model alphanumeric agreement; two-scale punctuation agreement',original=old,reader=r['reader']))
94
+ def extenders(self,image,line):
95
+ t=line['text']
96
+ if len(t)>30 or any(c.isdigit()or c.isascii()and c.isalpha()for c in t):return line
97
+ x,y,X,Y=map(round,line['box']);gray=np.asarray(image.convert('L'))[y:Y,x:X]
98
+ if not gray.size:return line
99
+ work,trace=compress_extenders(gray)
100
+ if not trace:return line
101
+ rr=self.read(work,[[0,0,work.shape[1],work.shape[0]]])[0];votes=collections.defaultdict(set)
102
+ for r in rr:
103
+ if r['confidence']>=.9:votes[r['text'].strip()].add(r['reader'])
104
+ options=[s for s,v in votes.items()if len(v)>=2 and s]
105
+ if len(options)!=1:return line
106
+ return dict(line,text=options[0],extender_repair=dict(source='source-pixel horizontal connector compression; two-model agreement',original=t,compressed_spans=trace))
stallion/bina04-document-handoff-95k-20261103/production.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A page handoff cannot discard primary text ink. OCR remains image-grounded."""
2
+ import importlib.util,json,sys,copy
3
+ from pathlib import Path
4
+ R=Path(__file__).resolve().parent;V=Path('__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031')
5
+ class Runtime:
6
+ def __init__(self,device='auto'):
7
+ s=importlib.util.spec_from_file_location('frozen_header_parent','__BINA_ROOT__/stallion/bina04-header-marks-95k-20261103/production.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device);sys.path.insert(0,'__BINA_ROOT__/stallion/bina04-header-marks-95k-20261103');sys.path.insert(0,str(R));from numeric_repairs import Repairs
8
+ numeric=self.parent.parent;numeric.capture.reset();self.core=numeric.engine.parent.parent;self.repair=Repairs(self.core.patch.reader.reader,numeric.numeric,self.core.patch.overlay.layer.reader);self.generic=None
9
+ def generic_page(self,image,old):
10
+ if self.generic is None:
11
+ sys.path.insert(0,str(V));from pipeline_v3 import PagePipeline,UnifiedReader
12
+ import numpy as np
13
+ class WholeLinePipeline(PagePipeline):
14
+ def detect(self,image):
15
+ boxes,trace=super().detect(image);heights=[b[3]-b[1]for b in boxes if b[3]>b[1]];median=float(np.median(heights))if heights else 1.;keep=[];dropped=[]
16
+ from document import overlap
17
+ for i,b in enumerate(boxes):
18
+ if any(i!=j and overlap(b,a)>=.8 and b[3]-b[1]<.6*(a[3]-a[1])and a[3]-a[1]<=2*median for j,a in enumerate(boxes)):dropped.append(b)
19
+ else:keep.append(b)
20
+ trace['contained_partial_height_slivers_removed']=dropped;return keep,trace
21
+ self.generic=WholeLinePipeline(self.core.patch.detector,self.core.patch.base.layout,UnifiedReader(V/'run/weights-095000.pt',V/'data/vocab.json',device=str(self.core.patch.reader.reader.device)),self.core.patch.table)
22
+ self.core.context['layout']=copy.deepcopy(old['raw_layout']);return self.generic(image,input_type='page')
23
+ def __call__(self,image,old,kind,name='',**kwargs):
24
+ base=self.parent(image,old,kind,name,**kwargs)
25
+ return self.finish(image,base,old,kind)
26
+ def finish(self,image,base,old,kind):
27
+ from document import lost_main_text,serialize
28
+ if kind!='page':return base
29
+ guard=lost_main_text(image,base)
30
+ if not guard['fallback_required']:return base
31
+ out=self.generic_page(image,old);out['ink_handoff_guard']=guard
32
+ from region_reads import supplement,display_evidence
33
+ out=supplement(image,out,self.repair)
34
+ for i,line in enumerate(out['lines']):
35
+ line=self.repair.dates(image,line);line=self.repair.latin(image,line);line=self.repair.extenders(image,line);out['lines'][i]=line
36
+ out=display_evidence(image,out,self.repair)
37
+ return serialize(image,out,graphic_threshold=.75)
38
+ def close(self):self.parent.close()
stallion/bina04-document-handoff-95k-20261103/region_reads.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """General small text-box reads and independent-reader evidence on display fonts."""
2
+ import copy,re,unicodedata
3
+ from difflib import SequenceMatcher
4
+ import cv2,numpy as np
5
+ from PIL import Image
6
+ from document import box,overlap
7
+
8
+ def ns(s):return ''.join(s.split()).replace('\u0640','')
9
+ def consensus(rows,minimum=.85):
10
+ groups={}
11
+ for r in rows:
12
+ key=ns(r['text'])
13
+ if not key:continue
14
+ groups.setdefault(key,[]).append(r)
15
+ good=[]
16
+ for k,rr in groups.items():
17
+ models={r['reader']for r in rr}
18
+ if len(models)>=2 and np.mean(sorted([r['confidence']for r in rr],reverse=True)[:2])>=minimum:good.append((len(models),max(r['confidence']for r in rr),rr))
19
+ if not good:return None
20
+ _,_,rr=max(good,key=lambda a:a[:2]);return max(rr,key=lambda r:r['confidence'])
21
+
22
+ def supplement(image,pipeline,repairs):
23
+ import sys
24
+ sys.path.insert(0,'__BINA_ROOT__/stallion/bina04-crop-bandseg-95k-20261102')
25
+ from bandseg import mask,horizontal
26
+ W,H=image.size;result=copy.deepcopy(pipeline);new=[];debug=[]
27
+ for region in result.get('raw_layout',{}).get('bboxes',[]):
28
+ if region.get('label','').lower()!='text':continue
29
+ b=box(region);x,y,X,Y=map(round,b)
30
+ if X-x>.3*W or Y-y>.12*H or Y-y<6:continue
31
+ rgb=np.asarray(image.crop((x,y,X,Y)).convert('RGB'));g=rgb.min(2).astype('float32');bg=cv2.GaussianBlur(g,(0,0),max(7,(Y-y)/8));norm=np.clip(g/(bg+1e-4)*245,0,255).astype('uint8');work=cv2.resize(norm,None,fx=3,fy=3,interpolation=cv2.INTER_CUBIC);ink,glyph=mask(work);boxes,trace=horizontal(ink,glyph,scale=3)
32
+ if not boxes:continue
33
+ reads=repairs.read(work,boxes,scales=(1,))
34
+ for bb,rows in zip(boxes,reads):
35
+ target=[bb[0]/3+x,bb[1]/3+y,bb[2]/3+x,bb[3]/3+y]
36
+ if any(l.get('text','').strip()and overlap(target,l['box'])>=.6 and l['box'][3]-l['box'][1]<=1.8*(target[3]-target[1])for l in result['lines']):
37
+ debug.append(dict(box=target,covered=True,reads=rows));continue
38
+ chosen=consensus(rows,.9);debug.append(dict(box=target,chosen=chosen,reads=rows))
39
+ if chosen is None:continue
40
+ # A tighter reread of the same glyphs is not a second text line.
41
+ if any(l.get('text','').strip() and overlap(target,l['box'])>=.8 and SequenceMatcher(None,ns(chosen['text']),ns(l['text'])).ratio()>=.7 for l in result['lines']):continue
42
+ if any(overlap(target,l['box'])>.5 and ns(chosen['text'])==ns(l['text'])for l in new):continue
43
+ new.append(dict(box=target,text=chosen['text'],visual_text=chosen['raw'],confidence=chosen['confidence'],recognizer='existing_reader_consensus_small_textbox',geometry=dict(source_pixels_extent=target,padded_source_extent=target,raw_visual_text=chosen['raw'],frame_centers=[],frame_centers_source_valid=False,source_pixels_modified=True),source_region=region,small_textbox_read=dict(upscale=3,background_normalized=True,readers=rows)))
44
+ result['lines']+=new;result['small_textbox_supplement']=dict(added=new,template_text_used=False,diagnostics=debug);return result
45
+
46
+ def display_evidence(image,pipeline,repairs):
47
+ W,H=image.size;result=copy.deepcopy(pipeline);gray=np.asarray(image.convert('L'));graphic=[r for r in result.get('raw_layout',{}).get('bboxes',[])if r.get('label','').lower()in['picture','figure','image','diagram']]
48
+ for i,line in enumerate(result['lines']):
49
+ b=line['box'];g=max([r.get('confidence',0)for r in graphic if overlap(b,box(r))>=.6]or[0.]);edge=(b[0]+b[2])/2>.75*W or(b[0]+b[2])/2<.25*W;display=edge and b[3]-b[1]>.018*H and b[2]-b[0]<.3*W
50
+ if not(g or display):continue
51
+ if g:
52
+ score=line.get('confidence',0)*(1-g);line['document_text_confidence']=score
53
+ # The raw read is retained in provenance even when not emitted as document text.
54
+ if score<.75:line['uncertain_graphic']=True;continue
55
+ if display:
56
+ rr=repairs.read(gray,[b],scales=(1,3))[0];chosen=consensus(rr,.9);models={r['reader']for r in rr if chosen and ns(r['text'])==ns(chosen['text'])};line['display_font_evidence']=rr
57
+ if chosen and len(models)==3:line['text']=chosen['text']
58
+ else:line['uncertain_graphic']=True
59
+ return result
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/learned_backend.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Substitute the trained detector in existing newspaper/cell line roles.
2
+
3
+ Keep the frozen guard, margins, NMS, grid, ordering and recognizer code intact.
4
+ The SDK preprocessing used here is also used for synthetic page/cell training.
5
+ """
6
+ import hashlib
7
+ import numpy as np
8
+ from PIL import Image
9
+ def install(detector,model_sha256,yield_check=lambda:None):
10
+ import baseline.crop_line_guard as module
11
+ original=module.CropLineGuard
12
+ cache={}
13
+ class Engine:
14
+ long_side=0
15
+ def detect(self,gray):
16
+ key=hashlib.sha256(str(gray.shape).encode()+gray.tobytes()).hexdigest()
17
+ if key not in cache:
18
+ yield_check();raw=detector([Image.fromarray(gray).convert('RGB')],batch_size=1)[0].model_dump(mode='json');rows=[]
19
+ for item in raw['bboxes']:
20
+ p=np.asarray(item['polygon']);rows.append([float(p[:,0].min()),float(p[:,1].min()),float(p[:,0].max()),float(p[:,1].max()),float(item.get('confidence',0.))])
21
+ cache[key]=np.asarray(rows,np.float32).reshape(-1,5)
22
+ return cache[key].copy()
23
+ class LearnedGuard(original):
24
+ def __init__(self):self.engine=Engine();self.sha256=model_sha256
25
+ def __call__(self,*args,**kwargs):
26
+ boxes,trace=super().__call__(*args,**kwargs)
27
+ trace.update(detector='Selected learned Surya physical-line model',model_sha256=model_sha256,providers=['CUDA FP32 SDK'],legacy_long_side_not_used_by_SDK=True)
28
+ return boxes,trace
29
+ module.CropLineGuard=LearnedGuard
30
+ return dict(model_sha256=model_sha256,roles=['whole_page','native_context','newspaper_guard_and_rescan','table_cell_lines'],recognizer_preprocessing_changed=False,heuristic_rules_changed=False)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/run/checkpoints/step-001000.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3208fac304f7c8a64735587f1e46024acb51cc0b0ef2d2288944eadd8992d4c9
3
+ size 153922431
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crop-only anti-merge detector. Original Indra LineNet; no recognizer banks."""
2
+ import hashlib,json
3
+ from pathlib import Path
4
+ import numpy as np
5
+ MODELS=Path('__BINA_ROOT__/stallion/bina04-indra-pilot-20261027/models')
6
+
7
+ class CropLineGuard:
8
+ def __init__(self):
9
+ import onnxruntime as ort
10
+ from indra_ocr.pipeline import OrtOCR
11
+ so=ort.SessionOptions();so.intra_op_num_threads=1;so.inter_op_num_threads=1;so.enable_cpu_mem_arena=False
12
+ self.engine=OrtOCR.__new__(OrtOCR)
13
+ self.engine.det=ort.InferenceSession(str(MODELS/'det.onnx'),so,providers=['CPUExecutionProvider'])
14
+ self.engine.adaptive=False
15
+ self.sha256=hashlib.sha256((MODELS/'det.onnx').read_bytes()).hexdigest()
16
+ def __call__(self,gray,boxes,line):
17
+ long_side=max(32,round(max(gray.shape)*12/max(1,line)/32)*32)
18
+ self.engine.long_side=long_side
19
+ native=self.engine.detect(gray)[:,:4];out=[];used=set();events=[]
20
+ for box in boxes:
21
+ x,y,X,Y=box;candidate=[]
22
+ if Y-y>1.8*line:
23
+ for i,b in enumerate(native):
24
+ a,t,A,T=map(float,b);h=T-t
25
+ overlap=max(0,min(A,X)-max(a,x))*max(0,min(T,Y)-max(t,y))/max(1.,(A-a)*h)
26
+ if i not in used and overlap>=.7 and .5*line<=h<=2.*line:candidate.append(i)
27
+ groups=[]
28
+ for i in sorted(candidate,key=lambda i:(native[i,1]+native[i,3])/2):
29
+ center=float((native[i,1]+native[i,3])/2)
30
+ if not groups or center-groups[-1][-1][0]>.6*line:groups.append([])
31
+ groups[-1].append((center,i))
32
+ if len(groups)>=2:
33
+ replacements=[]
34
+ for group in groups:
35
+ members=[native[i]for _,i in group]
36
+ replacements.append([min(float(b[0])for b in members),min(float(b[1])for b in members),max(float(b[2])for b in members),max(float(b[3])for b in members)])
37
+ out+=replacements;used.update(candidate);events.append(dict(original_box=box,replacement_boxes=replacements,native_indices=candidate))
38
+ else:out.append(list(box))
39
+ return out,dict(detector='Original Indra LineNet only; no OCR/script bank',model_sha256=self.sha256,providers=['CPUExecutionProvider'],long_side=long_side,target_input_line_height=12,native_boxes=native.tolist(),replacements=events,reference_or_layout_used=False)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/input_policy_v3.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen rule C estimator and same-line sliver consolidation; image geometry only."""
2
+ import numpy as np
3
+
4
+ def input_kind(width,height,requested='auto'):
5
+ if requested not in {'auto','crop','page'}:raise ValueError('input_type must be auto/crop/page')
6
+ if requested!='auto':return requested
7
+ return 'crop' if min(width,height)<=384 or height<=128 or(height<=768 and width/max(1,height)>=2.5) else 'page'
8
+
9
+
10
+ def estimated_line_height(gray):
11
+ # Exact estimator from the previously frozen rule C. No labels/categories.
12
+ import cv2
13
+ H, W = gray.shape
14
+ _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
15
+ ink = (binary == 0).astype(np.uint8)
16
+ if ink.mean() > .5:
17
+ ink = 1 - ink
18
+ _, labels, stats, _ = cv2.connectedComponentsWithStats(ink, 8)
19
+ heights = [];glyph_ids=[]
20
+ for label,(x, y, w, h, area) in enumerate(stats[1:],1):
21
+ aspect = float(w) / max(1, int(h))
22
+ if area >= 6 and h >= 3 and w >= 2 and h <= .95 * H and .05 <= aspect <= 20.:
23
+ heights.append(int(h));glyph_ids.append(label)
24
+ glyph = float(np.percentile(heights, 75)) if heights else None
25
+ line = min(float(H), max(1., 1.5 * glyph)) if glyph is not None else float(H)
26
+ original_line=line
27
+ projection_glyph=glyph
28
+ noise_robust=False
29
+ projection_ids=glyph_ids
30
+ if H<=96 and glyph_ids:
31
+ selected=stats[glyph_ids];order=np.argsort(selected[:,3]);ordered=selected[order]
32
+ weighted=float(ordered[np.searchsorted(np.cumsum(ordered[:,4]),.75*ordered[:,4].sum()),3])
33
+ if weighted>=2.*max(1.,glyph):
34
+ # Speckle can outnumber letters. Weight height by ink area and
35
+ # remove tiny speckles from this routing-only projection.
36
+ noise_robust=True;projection_glyph=weighted
37
+ projection_ids=[i for i in glyph_ids if stats[i,4]>=max(6,.05*selected[:,4].max())]
38
+ line=max(line,min(float(H),1.5*weighted))
39
+ # Padded one-line strips have small glyphs inside a much taller canvas.
40
+ # Do not count their blank margins as extra lines. This is confined to
41
+ # short/wide inputs and a single compact ink band, never handwriting pages.
42
+ # Exclude full-height scan borders/rules from the projection, using the
43
+ # same glyph-component eligibility as the height estimator.
44
+ glyph_ink=np.isin(labels,projection_ids) if projection_ids else ink
45
+ active=glyph_ink.sum(axis=1)>=max(2,.0015*W)
46
+ gap=max(2,round(.35*(projection_glyph or 1.)))
47
+ edges=np.diff(np.r_[False,active,False].astype(np.int8))
48
+ bands=[[int(a),int(b)] for a,b in zip(np.where(edges==1)[0],np.where(edges==-1)[0])]
49
+ joined=[]
50
+ for a,b in bands:
51
+ if joined and a-joined[-1][1]<=gap:joined[-1][1]=b
52
+ else:joined.append([a,b])
53
+ padded_single=(H<=96 or W/max(1,H)>=3.) and len(joined)==1 and joined[0][1]-joined[0][0]<=max(6,3.5*(projection_glyph or H))
54
+ if padded_single:line=float(H)
55
+ return dict(estimated_line_height=line, estimated_glyph_height=glyph,
56
+ valid_components=len(heights), crop_height=H,
57
+ height_to_line_height=H / max(1., line), segment=H > 1.8 * line,
58
+ original_glyph_based_line_height=original_line,ink_bands=joined,
59
+ single_ink_band_with_blank_margins=bool(padded_single),
60
+ noise_robust_projection=noise_robust,projection_glyph_height=projection_glyph)
61
+
62
+
63
+ def same_line(a, b):
64
+ overlap_y = min(a[3], b[3]) - max(a[1], b[1])
65
+ overlap_x = min(a[2], b[2]) - max(a[0], b[0])
66
+ return overlap_x > 0 and overlap_y > .5 * min(a[3] - a[1], b[3] - b[1])
67
+
68
+
69
+ def merge_slivers(boxes, line_height=None):
70
+ """Union overlapping observations; complete-link groups avoid bridge chaining.
71
+
72
+ A grown union is never used to decide subsequent matches. Every pair of
73
+ original members must overlap, or a plausible full-height anchor must
74
+ contain the sliver. A grown union never recruits a neighbouring row.
75
+ """
76
+ groups = []
77
+ for i in sorted(range(len(boxes)), key=lambda i: (-(boxes[i][3]-boxes[i][1]), i)):
78
+ compatible = []
79
+ for j, group in enumerate(groups):
80
+ anchor=boxes[group[0]];box=boxes[i]
81
+ inside=(anchor[0]<=box[0] and anchor[1]<=box[1] and
82
+ anchor[2]>=box[2] and anchor[3]>=box[3])
83
+ plausible=line_height is None or anchor[3]-anchor[1]<=1.8*line_height
84
+ if (inside and plausible) or all(same_line(box,boxes[k]) for k in group):
85
+ compatible.append(j)
86
+ if compatible:
87
+ groups[compatible[0]].append(i)
88
+ else:
89
+ groups.append([i])
90
+ groups.sort(key=min)
91
+ merged = [[min(boxes[i][0] for i in g), min(boxes[i][1] for i in g),
92
+ max(boxes[i][2] for i in g), max(boxes[i][3] for i in g)] for g in groups]
93
+ return merged, dict(input_boxes=len(boxes), output_boxes=len(merged),
94
+ original_member_indices=groups,
95
+ criterion='Vertical overlap >50% smaller height and positive horizontal overlap; complete-link unions',
96
+ reference_text_or_boxes_used=False)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Layout cannot filter detection. Exactly one recognizer reads every script."""
2
+ import math, sys, time
3
+ from pathlib import Path
4
+ import numpy as np
5
+ from PIL import Image
6
+ P=Path('__BINA_ROOT__/stallion/indra-scale')
7
+ sys.path[:0]=[str(P),'__BINA_ROOT__/stallion/bina04-indra-pilot-20261027/upstream']
8
+ from geometry import crop_line64
9
+ from indra_ocr.rtl import flip,rtl_base
10
+
11
+ def rectangle(polygon):
12
+ p=np.asarray(polygon,float)
13
+ return [float(p[:,0].min()),float(p[:,1].min()),float(p[:,0].max()),float(p[:,1].max())]
14
+
15
+ def area(b):return max(0,b[2]-b[0])*max(0,b[3]-b[1])
16
+ def intersection(a,b):return [max(a[0],b[0]),max(a[1],b[1]),min(a[2],b[2]),min(a[3],b[3])]
17
+ def clipped(b,w,h):return [max(0,b[0]),max(0,b[1]),min(w,b[2]),min(h,b[3])]
18
+ def reading_extent(line):return line['geometry'].get('source_pixels_extent',line['geometry']['padded_source_extent'])
19
+
20
+ def xycut_order(boxes,width):
21
+ """RTL column order; spanning headings divide vertical bands."""
22
+ n=len(boxes)
23
+ raster=lambda ids:sorted(ids,key=lambda i:((boxes[i][1]+boxes[i][3])/2,-boxes[i][0]))
24
+ core=[b for b in boxes if b[2]-b[0]<.7*width]
25
+ if len(core)<4:return raster(range(n))
26
+ profile=np.zeros(max(1,int(math.ceil(width))),int)
27
+ for b in core:profile[max(0,int(b[0])):min(len(profile),int(math.ceil(b[2])))]+=1
28
+ low=profile<=max(1,int(.05*len(core)));low[:int(.25*width)]=False;low[int(.75*width):]=False
29
+ e=np.diff(np.r_[False,low,False].astype(int));candidates=[]
30
+ for a,b in zip(np.where(e==1)[0],np.where(e==-1)[0]):
31
+ if b-a<max(8,.01*width):continue
32
+ cut=(a+b)/2;left=[i for i,v in enumerate(boxes)if v[2]<=cut];right=[i for i,v in enumerate(boxes)if v[0]>=cut]
33
+ if len(left)>=2 and len(right)>=2:candidates.append((b-a,cut,left,right))
34
+ if not candidates:return raster(range(n))
35
+ _,cut,left,right=max(candidates);middle=raster(set(range(n))-set(left)-set(right));remaining=set(left+right);out=[]
36
+ for stop in middle+[None]:
37
+ boundary=float('inf')if stop is None else(boxes[stop][1]+boxes[stop][3])/2
38
+ for group in[right,left]:
39
+ take=raster(i for i in group if i in remaining and(boxes[i][1]+boxes[i][3])/2<boundary);out+=take;remaining.difference_update(take)
40
+ if stop is not None:out.append(stop)
41
+ assert sorted(out)==list(range(n));return out
42
+
43
+ def order_blocks(boxes,layout,width):
44
+ regions=layout.get('bboxes',[]);blocks={};assign=[]
45
+ for i,box in enumerate(boxes):
46
+ scores=[area(intersection(box,rectangle(r['polygon'])))for r in regions]
47
+ j=int(np.argmax(scores))if scores and max(scores)>0 else None
48
+ key=('region',j)if j is not None else('outside',i)
49
+ if key not in blocks:blocks[key]=dict(key=list(key),region=regions[j]if j is not None else None,line_ids=[])
50
+ blocks[key]['line_ids'].append(i);assign.append(list(key))
51
+ blocks=list(blocks.values());bounds=[]
52
+ for b in blocks:
53
+ members=[boxes[i]for i in b['line_ids']];bounds.append([min(x[0]for x in members),min(x[1]for x in members),max(x[2]for x in members),max(x[3]for x in members)])
54
+ b['line_ids'].sort(key=lambda i:((boxes[i][1]+boxes[i][3])/2,-boxes[i][0]))
55
+ positions=[b['region'].get('position')if b['region']else None for b in blocks]
56
+ reasons=[]
57
+ if any(not isinstance(p,int)for p in positions)or len(set(positions))!=len(positions):reasons.append('missing_or_duplicate_region_order_or_outside_lines')
58
+ for i,a in enumerate(bounds):
59
+ for b in bounds[i+1:]:
60
+ if area(intersection(a,b))>.1*max(1,min(area(a),area(b))):reasons.append('overlapping_blocks');break
61
+ for block in blocks:
62
+ if block['region']and block['region'].get('label','').lower()=='table':continue
63
+ group=block['line_ids'];local=[boxes[i]for i in group];raster=sorted(range(len(group)),key=lambda i:((local[i][1]+local[i][3])/2,-local[i][0]))
64
+ if xycut_order(local,width)!=raster:reasons.append('multiple_columns_inside_layout_block')
65
+ if reasons:
66
+ # Apply the column fallback to physical lines, then form contiguous
67
+ # column chunks. Sorting the enclosing blocks alone interleaves any
68
+ # columns that Surya put inside one large Picture/Text region.
69
+ physical=xycut_order(boxes,width);owner={i:j for j,b in enumerate(blocks)for i in b['line_ids']};result=[];used_tables=set()
70
+ for i in physical:
71
+ j=owner[i];block=blocks[j];is_table=block['region']and block['region'].get('label','').lower()=='table'
72
+ if is_table:
73
+ if j not in used_tables:result.append(dict(block));used_tables.add(j)
74
+ elif result and result[-1].get('column_chunk_owner')==j and(boxes[i][1]+boxes[i][3])/2>=(boxes[result[-1]['line_ids'][-1]][1]+boxes[result[-1]['line_ids'][-1]][3])/2-.25*(boxes[i][3]-boxes[i][1]):result[-1]['line_ids'].append(i)
75
+ else:result.append(dict(block,line_ids=[i],column_chunk_owner=j))
76
+ mode='RTL_physical_line_columns_with_atomic_tables'
77
+ else:result=[blocks[i]for i in sorted(range(len(blocks)),key=lambda i:positions[i])];mode='Surya_block_order'
78
+ assert sorted(i for b in result for i in b['line_ids'])==list(range(len(boxes)))
79
+ return result,dict(mode=mode,reasons=sorted(set(reasons)),assignments=assign,region_type_line_losses=0)
80
+
81
+ class UnifiedReader:
82
+ """One model, unfiltered CTC, logical-order output. No script classifier."""
83
+ def __init__(self,checkpoint,vocabulary,device='cpu'):
84
+ from rtl_reader import RTLReader
85
+ self.reader=RTLReader(checkpoint,vocabulary,device=device);self.source_is_crop=False
86
+ def __call__(self,gray,boxes):
87
+ boxes=np.asarray(boxes,np.float32).reshape(-1,4).tolist();normalization=None
88
+ if self.source_is_crop and len(boxes)==1 and np.allclose(boxes[0],[0.,0.,gray.shape[1],gray.shape[0]]):
89
+ from input_policy_v3 import estimated_line_height
90
+ from single_line_normalization import ink_band_box
91
+ estimate=estimated_line_height(gray)
92
+ if not estimate['segment']:
93
+ original=boxes[0];boxes=[ink_band_box(gray,estimate)]
94
+ normalization=dict(original_box=original,ink_band_box=boxes[0],one_full_width_line=True,no_detection_or_line_splitting=True)
95
+ if self.source_is_crop and boxes:
96
+ from line_isolation import atlas
97
+ canvas,shifted,maps=atlas(gray,boxes);_,receipts=self.reader(canvas,np.asarray(shifted,np.float32).reshape(-1,4))
98
+ for receipt,mapping in zip(receipts,maps):
99
+ dx,dy=mapping['dx'],mapping['dy'];x,y,X,Y=receipt['padded_source_extent'];receipt.update(original_WH=[gray.shape[1],gray.shape[0]],padded_source_extent=[x-dx,y-dy,X-dx,Y-dy],source_pixels_extent=mapping['visible'],neighbor_line_pixels_excluded=True)
100
+ receipt['frame_centers']=[float(x-dx)for x in receipt['frame_centers']]
101
+ x,y,X,Y=receipt['ink_box'];receipt['ink_box']=[x-dx,y-dy,X-dx,Y-dy]
102
+ else:_,receipts=self.reader(gray,np.asarray(boxes,np.float32).reshape(-1,4))
103
+ return[dict(box=list(map(float,b)),text=flip(r['raw_visual_text'],base_rtl=True),visual_text=r['raw_visual_text'],confidence=r['confidence'],recognizer='unified32M',geometry=r,confidence_filter_applied=False,whole_input_normalization=normalization)for b,r in zip(boxes,receipts)]
104
+
105
+ def markdown_cell(text):return ' '.join(text.split()).replace('|',r'\|')
106
+
107
+ class PagePipeline:
108
+ def __init__(self,detector,layout,reader,table=None):
109
+ self.detector=detector;self.layout=layout;self.reader=reader;self.table=table;self._crop_line_guard=None
110
+ def detect(self,image):
111
+ # The SDK performs its own resize/pad/tiling on the WHOLE input.
112
+ raw=self.detector([image],batch_size=1)[0].model_dump(mode='json');w,h=image.size
113
+ boxes=[clipped(rectangle(r['polygon']),w,h)for r in raw['bboxes']]
114
+ boxes=[b for b in boxes if area(b)>0]
115
+ # Fixed native-resolution windows are independent of every layout label.
116
+ # Full-width SDK downsampling otherwise makes dense columns/cells tiny.
117
+ def starts(size,tile,overlap):
118
+ if size<=tile:return [0]
119
+ out=list(range(0,size-tile+1,tile-overlap))
120
+ if out[-1]!=size-tile:out.append(size-tile)
121
+ return out
122
+ traces=[];supplements=[];background=tuple(int(v)for v in np.median(np.asarray(image).reshape(-1,3),axis=0));size=max(self.detector.processor.size.values())
123
+ def context(piece,x,y,scale):
124
+ # Padding prevents prepare_image's final square stretch from
125
+ # deforming glyphs. Recognition never uses these resized pixels.
126
+ pw,ph=piece.size;rw=max(1,round(pw*scale));rh=max(1,round(ph*scale));canvas=Image.new('RGB',(size,size),background);canvas.paste(piece.resize((rw,rh),Image.Resampling.LANCZOS),(0,0));part=self.detector([canvas],batch_size=1)[0].model_dump(mode='json');out=[]
127
+ for item in part['bboxes']:
128
+ a,b,A,B=rectangle(item['polygon']);a=max(0,min(rw,a));A=max(0,min(rw,A));b=max(0,min(rh,b));B=max(0,min(rh,B));v=clipped([a*pw/rw+x,b*ph/rh+y,A*pw/rw+x,B*ph/rh+y],w,h)
129
+ if area(v)>0:out.append(v)
130
+ traces.append(dict(tile=[x,y,x+pw,y+ph],scale=scale,canvas_WH=[size,size],boxes=out));supplements.extend(out)
131
+ for y in starts(h,size,240):
132
+ for x in starts(w,size,240):context(image.crop((x,y,min(w,x+size),min(h,y+size))),x,y,1.)
133
+ if max(w,h)<=1400:context(image,0,0,min(.5,size/max(w,h)))
134
+ from fusion_v3 import fuse
135
+ merged,fusion=fuse(np.asarray(image.convert('L')),boxes,supplements)
136
+ return merged,dict(full_image_SDK=raw,native_resolution_tiles=traces,fusion=fusion,layout_used_for_detection=False,recognizer_pixels='Original image only; detector context resize is inverted geometrically')
137
+
138
+ def table_block(self,image,gray,block,boxes,readings):
139
+ region=block['region'];w,h=image.size;b=clipped(rectangle(region['polygon']),w,h);x,y,X,Y=map(int,[math.floor(b[0]),math.floor(b[1]),math.ceil(b[2]),math.ceil(b[3])]);crop=image.crop((x,y,X,Y))
140
+ if self.table is None:return None,dict(error='Surya_table_engine_not_ready',all_original_lines_retained=True)
141
+ try:result=self.table(crop)
142
+ except Exception as e:return None,dict(error=type(e).__name__+': '+str(e),all_original_lines_retained=True)
143
+ cells=result.get('cells',[])
144
+ if result.get('error')or not cells:return None,dict(error='Surya_table_grid_empty',grid=result,all_original_lines_retained=True)
145
+ cells=sorted(cells,key=lambda c:(c['row_id'],c.get('col_id')if c.get('col_id')is not None else c.get('within_row_id',0)));pieces=[];meta=[];represented=set();cell_fragments={i:[]for i in block['line_ids']}
146
+ for cell in cells:
147
+ cb=cell.get('bbox')or rectangle(cell['polygon']);cb=[cb[0]+x,cb[1]+y,cb[2]+x,cb[3]+y];parts=[]
148
+ for i in block['line_ids']:
149
+ part=intersection(boxes[i],cb)
150
+ if part[2]-part[0]>=2 and part[3]-part[1]>=.5*(boxes[i][3]-boxes[i][1]):
151
+ # A physical row inside a cell keeps the cell's horizontal
152
+ # context; only its vertical line band is cropped.
153
+ part=[cb[0],part[1],cb[2],part[3]];parts.append(part);represented.add(i);cell_fragments[i].append(part)
154
+ if not parts:parts=[cb]
155
+ parts.sort(key=lambda p:((p[1]+p[3])/2,-p[0]));bands=[]
156
+ for part in parts:
157
+ hit=next((j for j,b in enumerate(bands)if min(part[3],b[3])-max(part[1],b[1])>=.65*min(part[3]-part[1],b[3]-b[1])),None)
158
+ if hit is None:bands.append(part)
159
+ else:bands[hit]=[cb[0],min(bands[hit][1],part[1]),cb[2],max(bands[hit][3],part[3])]
160
+ begin=len(pieces);pieces+=bands;meta.append((cell,cb,begin,len(pieces)))
161
+ # A grid may not silently erase part of a detected line. A line entirely
162
+ # outside cells is a retained note; a partly clipped line fails back to
163
+ # the original block rather than emitting a misleading partial table.
164
+ import cv2
165
+ coverage=[]
166
+ for i in represented:
167
+ b=boxes[i];a,c,A,C=map(int,[math.floor(b[0]),math.floor(b[1]),math.ceil(b[2]),math.ceil(b[3])]);patch=gray[c:C,a:A];_,ink=cv2.threshold(patch,0,255,(cv2.THRESH_BINARY if np.median(patch)<128 else cv2.THRESH_BINARY_INV)|cv2.THRESH_OTSU);mask=np.zeros(patch.shape,bool)
168
+ for q in cell_fragments[i]:
169
+ height=q[3]-q[1];u,v,U,V=map(int,[math.floor(q[0]-.3*height),math.floor(q[1]-.2*height),math.ceil(q[2]+.3*height),math.ceil(q[3]+.2*height)]);mask[max(0,v-c):min(C-c,V-c),max(0,u-a):min(A-a,U-a)]=True
170
+ n=int((ink>0).sum());fraction=float((mask&(ink>0)).sum())/n if n else 1.;coverage.append(dict(source_line=i,ink_fraction=fraction))
171
+ if any(r['ink_fraction']<.98 for r in coverage):return None,dict(error='Table_grid_clips_detected_line_ink',grid=result,coverage=coverage,all_original_lines_retained=True)
172
+ lines=self.reader(gray,pieces);rows={};rtl=rtl_base(' '.join(l['text']for l in lines))
173
+ for cell,cb,a,b in meta:
174
+ rows.setdefault(cell['row_id'],[]).append(dict(text=' '.join(line['text']for line in lines[a:b]),box=cb,colspan=cell.get('colspan',1)))
175
+ rendered=[]
176
+ for j,(_,row)in enumerate(sorted(rows.items())):
177
+ row.sort(key=lambda c:(-1 if rtl else 1)*(c['box'][0]+c['box'][2])/2);values=[]
178
+ for cell in row:values+=[cell['text']]+['']*(max(1,cell['colspan'])-1)
179
+ rendered.append('| '+' | '.join(markdown_cell(s)for s in values)+' |')
180
+ if j==0:rendered.append('| '+' | '.join('---'for _ in values)+' |')
181
+ # A line not represented by any cell remains in output after the table.
182
+ remainder=[i for i in block['line_ids']if i not in represented]
183
+ rendered +=[readings[i]['text']for i in remainder]
184
+ emitted=lines+[readings[i]for i in remainder]
185
+ return dict(text='\n'.join(rendered),lines=emitted),dict(grid=result,represented_line_ids=sorted(represented),unassigned_line_ids=remainder,grid_text_never_used=True,Markdown_first_row_separator=True,column_direction='RTL'if rtl else'LTR',cell_coverage=coverage)
186
+ def __call__(self,image,*,input_type='auto',**_):
187
+ from input_policy_v3 import estimated_line_height,merge_slivers,input_kind
188
+ tick=time.monotonic();image=image.convert('RGB');gray=np.asarray(image.convert('L'));estimate=estimated_line_height(gray);kind=input_kind(image.width,image.height,input_type);self.reader.source_is_crop=kind=='crop'
189
+ if kind=='crop'and not estimate['segment']:
190
+ boxes=[[0.,0.,float(image.width),float(image.height)]];readings=self.reader(gray,boxes);assert len(readings)==1
191
+ region=dict(layout=None,source_line_ids=[0],path='rule_C_whole_input_unified_reader',text=readings[0]['text'],lines=readings)
192
+ return dict(text=readings[0]['text'],regions=[region],lines=readings,global_readings=readings,detected_boxes=[],emitted_boxes=[r['box']for r in readings],recognition_extents=[reading_extent(r)for r in readings],raw_line_detection=dict(skipped=True,reason='Rule C: height <=1.8 estimated line height'),raw_layout=dict(bboxes=[],skipped=True),order=dict(mode='single_input_rule_C',region_type_line_losses=0),fallbacks=[],crop_routing_policy=dict(mode='c',input_type=kind,estimate=estimate,whole_crop_by_policy=True,native_detector_calls=0),region_type_line_losses=0,recognizer_bank='unified32M_only',whole_image_detection=False,seconds=time.monotonic()-tick,main_Bina_training_changes=False)
193
+ boxes,detection=self.detect(image);fallbacks=[]
194
+ if kind=='crop':
195
+ line=estimate['estimated_line_height']
196
+ if boxes and any(b[3]-b[1]>1.8*line for b in boxes):
197
+ if self._crop_line_guard is None:
198
+ from crop_line_guard import CropLineGuard
199
+ self._crop_line_guard=CropLineGuard()
200
+ boxes,guard=self._crop_line_guard(gray,boxes,line);detection['crop_anti_merge_guard']=guard
201
+ boxes,consolidation=merge_slivers(boxes,line);detection['same_line_box_consolidation']=consolidation
202
+ if not boxes:boxes=[[0.,0.,float(image.width),float(image.height)]];fallbacks.append('zero_detector_lines_whole_image_attempt_not_valid_for_coverage_gate')
203
+ try:layout=self.layout.run_batch([image],[dict(threshold=None,use_order=True)])[0].model_dump(mode='json')
204
+ except Exception as e:layout=dict(bboxes=[],error=repr(e));fallbacks.append('layout_error_RTL_column_order_all_lines_retained')
205
+ blocks,order=order_blocks(boxes,layout,image.width);readings=self.reader(gray,boxes);assert len(readings)==len(boxes)
206
+ regions=[];emitted=[]
207
+ for block in blocks:
208
+ ids=block['line_ids'];lines=[readings[i]for i in ids];record=dict(layout=block['region'],source_line_ids=ids,path='whole_image_lines',text='\n'.join(l['text']for l in lines),lines=lines)
209
+ if block['region']and block['region'].get('label','').lower()=='table':
210
+ assembled,trace=self.table_block(image,gray,block,boxes,readings);record['table_trace']=trace
211
+ if assembled:record.update(assembled,path='Surya_grid_unified_recognizer_cells')
212
+ else:
213
+ fallback_order=xycut_order([boxes[i]for i in ids],image.width);record['lines']=[readings[ids[j]]for j in fallback_order];record['text']='\n'.join(l['text']for l in record['lines']);record['source_line_ids']=[ids[j]for j in fallback_order];fallbacks.append('table_grid_failed_original_lines_retained_in_RTL_columns')
214
+ regions.append(record);emitted+=record['lines']
215
+ assert sorted(i for r in regions for i in r['source_line_ids'])==list(range(len(boxes)))
216
+ return dict(text='\n\n'.join(r['text']for r in regions),regions=regions,lines=emitted,global_readings=readings,detected_boxes=boxes,emitted_boxes=[l['box']for l in emitted],recognition_extents=[reading_extent(l)for l in emitted],raw_line_detection=detection,raw_layout=layout,order=order,fallbacks=fallbacks,crop_routing_policy=dict(mode='c',input_type=kind,estimate=estimate,whole_crop_by_policy=False),region_type_line_losses=0,recognizer_bank='unified32M_only',whole_image_detection=True,seconds=time.monotonic()-tick,main_Bina_training_changes=False)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/table_client.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64,io,json,subprocess,select,time
2
+ from pathlib import Path
3
+ R=Path(__file__).resolve().parent
4
+ class SuryaTableGrid:
5
+ def __init__(self,yield_check=None):self.process=None;self.log=None;self.yield_check=yield_check
6
+ def __call__(self,image):
7
+ if self.process is None:
8
+ self.log=(R/'table-worker.log').open('a');self.process=subprocess.Popen([str(R/'table-runtime/bin/python'),'-u',str(R/'table_worker.py')],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=self.log,text=True)
9
+ stream=io.BytesIO();image.save(stream,format='PNG');self.process.stdin.write(json.dumps(dict(image=base64.b64encode(stream.getvalue()).decode()))+'\n');self.process.stdin.flush();tick=time.monotonic()
10
+ while not select.select([self.process.stdout],[],[],.5)[0]:
11
+ if self.yield_check:self.yield_check()
12
+ if time.monotonic()-tick>300:self.close();raise TimeoutError('Owned table worker timeout; original line output retained')
13
+ line=self.process.stdout.readline()
14
+ if not line:raise RuntimeError('Isolated Surya table worker exited; original line text retained')
15
+ reply=json.loads(line)
16
+ if not reply['ok']:raise RuntimeError(reply['error'])
17
+ return reply['result']
18
+ def close(self):
19
+ if self.process is not None:
20
+ self.process.stdin.close()
21
+ try:self.process.wait(timeout=15)
22
+ except subprocess.TimeoutExpired:self.process.terminate();self.process.wait(timeout=10)
23
+ self.process=None
24
+ if self.log:self.log.close();self.log=None
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-line shadow candidate: exact round-2 detector and crop_line64 contract.
2
+ Always one recognition input for a rule-C line; no router and no extra strips.
3
+ """
4
+ import hashlib,json,math
5
+ from pathlib import Path
6
+ import numpy as np
7
+ from baseline.pipeline_v3 import UnifiedReader,rectangle,clipped,flip
8
+ from baseline.input_policy_v3 import estimated_line_height
9
+ from pipeline_crop_fix import CropPipeline
10
+ DETECTOR=Path('__BINA_ROOT__/stallion/bina04-line-detector-v2-20261030/detector-run/checkpoints/step-000500.pt')
11
+ DETECTOR_SHA='0320b9d1d9e7464c4eae6f35b93fdd835913e785eae552eb5f04b58091b32ae6'
12
+
13
+ def round2_estimate(gray):
14
+ # Execute only the exact frozen estimator function, without creating any
15
+ # of the old recognizer/script banks. This is a boxing decision only.
16
+ import ast
17
+ p=Path('__BINA_ROOT__/stallion/indra-scale/routing_modes.py')
18
+ rule=json.loads((p.parent/'routing-rule.json').read_text())
19
+ assert hashlib.sha256(p.read_bytes()).hexdigest()==rule['source_sha256'][p.name]
20
+ node=next(n for n in ast.parse(p.read_text()).body if isinstance(n,ast.FunctionDef)and n.name=='estimated_line_height')
21
+ scope={'np':np};exec(compile(ast.Module(body=[node],type_ignores=[]),str(p),'exec'),scope)
22
+ return scope['estimated_line_height'](gray)
23
+
24
+ class ContractReader(UnifiedReader):
25
+ def __init__(self,*args,cache,**kwargs):
26
+ super().__init__(*args,**kwargs);self.rule_c_active=False;self.detector=None;self.cache=Path(cache);self.cache.mkdir(parents=True,exist_ok=True)
27
+ def locate(self,gray):
28
+ old=round2_estimate(gray)
29
+ if not old['segment']:
30
+ H,W=gray.shape
31
+ return dict(box=[0.,0.,float(W),float(H)],round2_rule_C=old,round2_skipped_detection=True,one_recognition_crop=True,no_reference_or_label_used=True)
32
+ key=hashlib.sha256(str(gray.shape).encode()+gray.tobytes()).hexdigest();target=self.cache/(key+'.json')
33
+ if target.exists():return json.loads(target.read_text())
34
+ if self.detector is None:
35
+ import torch
36
+ from surya.detection import DetectionPredictor
37
+ assert hashlib.sha256(DETECTOR.read_bytes()).hexdigest()==DETECTOR_SHA
38
+ self.detector=DetectionPredictor.local(device='cpu',dtype=torch.float32);self.detector._disable_tqdm=True
39
+ self.detector.model.load_state_dict(torch.load(DETECTOR,map_location='cpu',weights_only=True)['model']);self.detector.model.eval()
40
+ from PIL import Image
41
+ H,W=gray.shape;raw=self.detector([Image.fromarray(gray).convert('RGB')],batch_size=1)[0].model_dump(mode='json');boxes=[clipped(rectangle(b['polygon']),W,H)for b in raw['bboxes']];boxes=[b for b in boxes if b[2]>b[0]and b[3]>b[1]]
42
+ box=[min(b[0]for b in boxes),min(b[1]for b in boxes),max(b[2]for b in boxes),max(b[3]for b in boxes)]if boxes else[0.,0.,float(W),float(H)]
43
+ d=dict(box=box,raw_detector_boxes=boxes,raw=raw,detector_sha256=DETECTOR_SHA,device='cpu',one_recognition_crop=True,fallback=not bool(boxes),no_reference_or_label_used=True)
44
+ tmp=target.with_suffix('.partial');tmp.write_text(json.dumps(d)+'\n');tmp.replace(target);return d
45
+ def __call__(self,gray,boxes):
46
+ H,W=gray.shape
47
+ if not(self.rule_c_active and self.source_is_crop and len(boxes)==1 and np.allclose(boxes[0],[0.,0.,float(W),float(H)])):
48
+ return super().__call__(gray,boxes)
49
+ trace=self.locate(gray);box=trace['box']
50
+ # Same direct original grayscale -> crop_line64 -> pad_batch -> model
51
+ # path as round2 RTLReader, bypassing v3 projection and the atlas.
52
+ _,receipts=self.reader(gray,np.asarray([box],np.float32));r=receipts[0]
53
+ if not r['raw_visual_text'].strip():
54
+ recovered=super().__call__(gray,boxes)
55
+ for line in recovered:line['single_line_contract_fallback']=dict(reason='Tight crop decoded empty; original single-line read retained',attempt_box=box,attempt_confidence=r['confidence'],reference_or_label_used=False)
56
+ return recovered
57
+ a,b,A,B=r['padded_source_extent'];r['source_pixels_extent']=[max(0,a),max(0,b),min(W,A),min(H,B)]
58
+ r['preprocessing_contract']='Exact shared geometry.crop_line64 and RTLReader, direct original uint8 pixels'
59
+ return[dict(box=list(map(float,box)),text=flip(r['raw_visual_text'],base_rtl=True),visual_text=r['raw_visual_text'],confidence=r['confidence'],recognizer='unified32M',geometry=r,confidence_filter_applied=False,whole_input_normalization=dict(rule_c_single_line=True,round2_detector_reboxing=trace,one_recognition_crop=True))]
60
+
61
+ class ContractPipeline(CropPipeline):
62
+ """Apply the new reader to generic rule-C crops only.
63
+ The existing specialized newspaper/table and whole-page paths stay frozen.
64
+ """
65
+ def __call__(self,image,*,input_type='auto',source_name='',content_type='',**kw):
66
+ from baseline.input_policy_v3 import input_kind
67
+ gray=np.asarray(image.convert('L'));generic=content_type.lower()not in ['newspaper','table']and'table'not in source_name.lower()
68
+ self.reader.rule_c_active=generic and input_kind(*image.size,input_type)=='crop'and not estimated_line_height(gray)['segment']
69
+ return super().__call__(image,input_type=input_type,source_name=source_name,content_type=content_type,**kw)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/pipeline_crop_fix.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """New crop path only. Full pages delegate to the exact frozen v3 implementation."""
2
+ import copy,time
3
+ import numpy as np
4
+ from baseline import pipeline_v3 as base
5
+ from baseline.input_policy_v3 import input_kind,estimated_line_height,merge_slivers
6
+ from table_cells import CellDetector,assemble
7
+
8
+ class CropPipeline(base.PagePipeline):
9
+ def __init__(self,*args,variant='tables-v1',**kwargs):
10
+ super().__init__(*args,**kwargs);self.variant=variant;self._crop_active=False;self._column_order='auto';self._cell_detector=None
11
+ def table_block(self,image,gray,block,boxes,readings):
12
+ if not self._crop_active:return super().table_block(image,gray,block,boxes,readings)
13
+ if self._cell_detector is None:self._cell_detector=CellDetector()
14
+ return assemble(image,gray,block['region'],self.reader,self.table,self._cell_detector,self._column_order)
15
+ def __call__(self,image,*,input_type='auto',source_name='',content_type='',table_column_order='auto',**kwargs):
16
+ kind=input_kind(*image.size,input_type);self._crop_active=kind=='crop';self._column_order=table_column_order
17
+ if kind!='crop':return super().__call__(image,input_type=input_type,**kwargs)
18
+ image=image.convert('RGB');gray=np.asarray(image.convert('L'));self.reader.source_is_crop=True
19
+ if content_type.lower()=='newspaper'and ('news' in self.variant or 'combined' in self.variant):
20
+ stage=next((n for n in [4,3,2,1]if f'news{n}'in self.variant),2)
21
+ return self.newspaper_call(image,gray,stage)
22
+ table_hint=content_type.lower()=='table'or'table'in source_name.lower()
23
+ if table_hint:
24
+ tick=time.monotonic();w,h=image.size;region=dict(label='Table',position=0,polygon=[[0,0],[w,0],[w,h],[0,h]])
25
+ block=dict(region=region,line_ids=[]);value,trace=self.table_block(image,gray,block,[],[])
26
+ if value is not None:
27
+ record=dict(layout=region,path='Surya_grid_unified_recognizer_cells',source_line_ids=[],table_trace=trace,**value)
28
+ return dict(text=value['text'],regions=[record],lines=value['lines'],global_readings=[],detected_boxes=[],emitted_boxes=[q['box']for q in value['lines']],recognition_extents=[base.reading_extent(q)for q in value['lines']],raw_line_detection=dict(skipped=True,reason='Explicit table crop: each cell is segmented independently'),raw_layout=dict(bboxes=[region],source='Explicit table metadata/name'),order=dict(mode='table_grid'),fallbacks=[],crop_routing_policy=dict(input_type='crop',whole_crop_by_policy=False),region_type_line_losses=0,recognizer_bank='unified32M_only',whole_image_detection=False,seconds=time.monotonic()-tick,main_Bina_training_changes=False)
29
+ original=self.layout
30
+ class PreferTableOnTie:
31
+ def run_batch(_,images,*args,**kw):
32
+ results=original.run_batch(images,*args,**kw);out=[]
33
+ for result in results:
34
+ data=copy.deepcopy(result.model_dump(mode='json'));data['bboxes'].sort(key=lambda r:r.get('label','').lower()!='table')
35
+ class Result:
36
+ def __init__(self,d):self.data=d
37
+ def model_dump(self,**kw):return self.data
38
+ out.append(Result(data))
39
+ return out
40
+ self.layout=PreferTableOnTie()
41
+ try:
42
+ result=super().__call__(image,input_type='crop',**kwargs)
43
+ result['crop_table_policy']=dict(explicit_hint=table_hint,layout_table_wins_exact_overlap_tie=True,column_order=table_column_order)
44
+ return result
45
+ finally:self.layout=original
46
+
47
+ def newspaper_call(self,image,gray,stage):
48
+ tick=time.monotonic();estimate=estimated_line_height(gray)
49
+ if not estimate['segment']:
50
+ result=super().__call__(image,input_type='crop');result['newspaper_policy']=dict(stage=stage,already_one_full_width_line=True);return result
51
+ boxes,detection=self.detect(image);fallbacks=[];line=estimate['estimated_line_height']
52
+ if self._crop_line_guard is None:
53
+ from baseline.crop_line_guard import CropLineGuard
54
+ self._crop_line_guard=CropLineGuard()
55
+ if boxes and any(b[3]-b[1]>1.8*line for b in boxes):
56
+ boxes,guard=self._crop_line_guard(gray,boxes,line);detection['crop_anti_merge_guard']=guard
57
+ boxes,consolidation=merge_slivers(boxes,line);detection['same_line_box_consolidation']=consolidation
58
+ from newspaper_crop import apply
59
+ boxes,trace=apply(gray,boxes,stage,self._crop_line_guard);detection['newspaper_policy']=trace
60
+ if not boxes:boxes=[[0.,0.,float(image.width),float(image.height)]];fallbacks.append('zero_detector_lines_whole_image_attempt_not_valid_for_coverage_gate')
61
+ try:layout=self.layout.run_batch([image],[dict(threshold=None,use_order=True)])[0].model_dump(mode='json')
62
+ except Exception as e:layout=dict(bboxes=[],error=repr(e));fallbacks.append('layout_error_RTL_column_order_all_lines_retained')
63
+ blocks,order=base.order_blocks(boxes,layout,image.width)
64
+ if stage>=4:
65
+ from newspaper_crop import read_lines
66
+ readings=read_lines(self.reader,gray,boxes)
67
+ else:readings=self.reader(gray,boxes)
68
+ regions=[];emitted=[]
69
+ for block in blocks:
70
+ ids=block['line_ids'];lines=[readings[i]for i in ids];record=dict(layout=block['region'],source_line_ids=ids,path='whole_image_lines',text='\n'.join(l['text']for l in lines),lines=lines)
71
+ if block['region']and block['region'].get('label','').lower()=='table':
72
+ assembled,t=self.table_block(image,gray,block,boxes,readings);record['table_trace']=t
73
+ if assembled:record.update(assembled,path='Surya_grid_unified_recognizer_cells')
74
+ regions.append(record);emitted+=record['lines']
75
+ assert sorted(i for r in regions for i in r['source_line_ids'])==list(range(len(boxes)))
76
+ return dict(text='\n\n'.join(r['text']for r in regions),regions=regions,lines=emitted,global_readings=readings,detected_boxes=boxes,emitted_boxes=[l['box']for l in emitted],recognition_extents=[base.reading_extent(l)for l in emitted],raw_line_detection=detection,raw_layout=layout,order=order,fallbacks=fallbacks,crop_routing_policy=dict(mode='c',input_type='crop',estimate=estimate,whole_crop_by_policy=False),region_type_line_losses=0,recognizer_bank='unified32M_only',whole_image_detection=True,seconds=time.monotonic()-tick,main_Bina_training_changes=False)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/table_cells.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crop-only table cells: native line splitting, cell padding, explicit serialization."""
2
+ import math
3
+ from pathlib import Path
4
+ import numpy as np
5
+ from baseline.pipeline_v3 import rectangle,clipped,markdown_cell,reading_extent
6
+ from baseline.input_policy_v3 import estimated_line_height
7
+ from indra_ocr.rtl import rtl_base
8
+
9
+ class CellDetector:
10
+ def __init__(self):
11
+ from baseline.crop_line_guard import CropLineGuard
12
+ self.detector=CropLineGuard()
13
+ def lines(self,gray,line):
14
+ engine=self.detector.engine
15
+ engine.long_side=max(32,min(2048,round(max(gray.shape)*12/max(3,line)/32)*32))
16
+ return engine.detect(gray)[:,:4].tolist()
17
+
18
+ def glyph_presence(gray):
19
+ import cv2
20
+ H,W=gray.shape;_,v=cv2.threshold(gray,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU);ink=(v==0).astype(np.uint8)
21
+ if ink.mean()>.5:ink=1-ink
22
+ _,_,stats,_=cv2.connectedComponentsWithStats(ink,8);valid=[]
23
+ for x,y,w,h,n in stats[1:]:
24
+ if n<6 or h<3 or w<2 or not .05<=w/max(1,h)<=20:continue
25
+ frame=(h>=.7*H and w>=.7*W and n/max(1,w*h)<.15)
26
+ if frame or h>=.98*H or(w>=.95*W and h<=3):continue
27
+ valid.append([int(x),int(y),int(w),int(h),int(n)])
28
+ return bool(valid),valid
29
+
30
+ def tighten_ink_band(gray,box,components):
31
+ """Remove native detector margin while retaining >=98% observed glyph ink
32
+ inside the unchanged reader's .2H padding. No decoded labels enter this.
33
+ """
34
+ import cv2
35
+ H,W=gray.shape;lo=max(0,math.floor(box[1]));hi=min(H,math.ceil(box[3]))
36
+ _,v=cv2.threshold(gray,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU);ink=v==0
37
+ if ink.mean()>.5:ink=~ink
38
+ mask=np.zeros(gray.shape,bool)
39
+ for x,y,w,h,n in components:mask[y:y+h,x:x+w]=ink[y:y+h,x:x+w]
40
+ counts=mask[lo:hi].sum(axis=1);total=int(counts.sum())
41
+ if not total:return box,dict(changed=False,reason='No glyph ink')
42
+ active=np.where(counts>=max(1,.2*counts.max()))[0]
43
+ if not len(active):return box,dict(changed=False)
44
+ top=lo+int(active[0]);bottom=lo+int(active[-1])+1
45
+ while True:
46
+ h=bottom-top;a=max(lo,math.floor(top-.2*h));b=min(hi,math.ceil(bottom+.2*h));kept=int(mask[a:b].sum())
47
+ if kept/total>=.98 or(top<=lo and bottom>=hi):break
48
+ top=max(lo,top-1);bottom=min(hi,bottom+1)
49
+ return [box[0],float(top),box[2],float(bottom)],dict(changed=True,original=box,core=[top,bottom],glyph_ink_fraction_in_padded_reader=kept/total)
50
+
51
+ def remap(line,x,y,W,H):
52
+ line=dict(line);line['box']=[line['box'][0]+x,line['box'][1]+y,line['box'][2]+x,line['box'][3]+y]
53
+ g=dict(line['geometry'])
54
+ for key in ['ink_box','padded_source_extent','source_pixels_extent']:
55
+ if key in g:a,b,A,B=g[key];g[key]=[a+x,b+y,A+x,B+y]
56
+ g['original_WH']=[W,H]
57
+ if 'frame_centers'in g:g['frame_centers']=[a+x for a in g['frame_centers']]
58
+ line['geometry']=g;return line
59
+
60
+ def recognize_cell(image,gray,cb,reader,detector,padding=3):
61
+ W,H=image.size;x,y,X,Y=clipped(cb,W,H)
62
+ a=max(0,math.floor(x)-padding);b=max(0,math.floor(y)-padding);A=min(W,math.ceil(X)+padding);B=min(H,math.ceil(Y)+padding)
63
+ if A<=a or B<=b:return '',[],dict(empty=True,reason='invalid_cell')
64
+ local=gray[b:B,a:A].copy();present,components=glyph_presence(local)
65
+ if not present:return '',[],dict(empty=True,padded_bbox=[a,b,A,B],glyph_components=[])
66
+ estimate=estimated_line_height(local);line=max(3.,estimate['original_glyph_based_line_height']);tall=local.shape[0]>1.8*line
67
+ native=detector.lines(local,line)if tall else[]
68
+ # Padding may include a neighbour's descender; its centre lies outside this cell.
69
+ native=[q for q in native if y-b<=(q[1]+q[3])/2<=Y-b and q[3]-q[1]>=2]
70
+ groups=[]
71
+ for q in sorted(native,key=lambda q:(q[1]+q[3])/2):
72
+ cy=(q[1]+q[3])/2
73
+ if not groups or cy-groups[-1][-1][0]>.6*line:groups.append([])
74
+ groups[-1].append((cy,q))
75
+ bands=[[0.,min(q[1]for _,q in g),float(local.shape[1]),max(q[3]for _,q in g)]for g in groups]
76
+ projected=estimate['ink_bands']
77
+ fallback=None
78
+ if tall and len(bands)<=1 and len(projected)>=2:
79
+ bands=[[0.,float(t),float(local.shape[1]),float(T)]for t,T in projected if T-t>=2];fallback='projection_after_native_merged_or_empty'
80
+ if not bands:bands=[[0.,0.,float(local.shape[1]),float(local.shape[0])]]
81
+ tight=[];normalization=[]
82
+ for band in bands:
83
+ q,t=tighten_ink_band(local,band,components);tight.append(q);normalization.append(t)
84
+ lines=reader(local,tight);lines=[remap(q,a,b,W,H)for q in lines]
85
+ return ' '.join(q['text'].strip()for q in lines if q['text'].strip()),lines,dict(empty=False,padded_bbox=[a,b,A,B],glyph_components=components,line_estimate=estimate,native_called=tall,native_boxes=native,line_boxes=tight,ink_normalization=normalization,fallback=fallback)
86
+
87
+ def assemble(image,gray,region,reader,grid,detector,column_order='auto'):
88
+ W,H=image.size;b=clipped(rectangle(region['polygon']),W,H);x,y,X,Y=[math.floor(b[0]),math.floor(b[1]),math.ceil(b[2]),math.ceil(b[3])]
89
+ result=grid(image.crop((x,y,X,Y)));cells=result.get('cells',[])
90
+ if result.get('error')or not cells:return None,dict(error='Surya_table_grid_empty',grid=result,grid_invoked=True)
91
+ values={};row_y={};col_x={};debug=[];emitted=[];presence={}
92
+ for cell in cells:
93
+ cb=cell.get('bbox')or rectangle(cell['polygon']);cb=[cb[0]+x,cb[1]+y,cb[2]+x,cb[3]+y]
94
+ row=cell['row_id'];col=cell.get('col_id')
95
+ if col is None:col=cell.get('within_row_id',0)
96
+ text,lines,trace=recognize_cell(image,gray,cb,reader,detector)
97
+ key=(row,col);values[key]=text;presence[key]=not trace['empty'];row_y.setdefault(row,[]).append((cb[1]+cb[3])/2);col_x.setdefault(col,[]).append((cb[0]+cb[2])/2)
98
+ emitted+=lines;debug.append(dict(row=row,col=col,bbox=cb,text=text,lines=lines,trace=trace,colspan=cell.get('colspan',1),rowspan=cell.get('rowspan',1)))
99
+ rows=sorted(row_y,key=lambda r:float(np.median(row_y[r])))
100
+ cols=sorted(col_x,key=lambda c:float(np.median(col_x[c])))
101
+ empty_rows=[r for r in rows if not any(values.get((r,c),'').strip()or presence.get((r,c),False)for c in cols)]
102
+ empty_cols=[c for c in cols if not any(values.get((r,c),'').strip()or presence.get((r,c),False)for r in rows)]
103
+ rows=[r for r in rows if r not in empty_rows];cols=[c for c in cols if c not in empty_cols]
104
+ rtl=rtl_base(' '.join(values.values()));chosen=('rtl'if rtl else'ltr')if column_order=='auto'else column_order
105
+ assert chosen in ['rtl','ltr']
106
+ if chosen=='rtl':cols.reverse()
107
+ rendered=[]
108
+ for i,r in enumerate(rows):
109
+ rendered.append('| '+' | '.join(markdown_cell(values.get((r,c),''))for c in cols)+' |')
110
+ if i==0:rendered.append('| '+' | '.join('---'for _ in cols)+' |')
111
+ return dict(text='\n'.join(rendered),lines=emitted),dict(grid=result,grid_invoked=True,cell_debug=debug,kept_rows=rows,kept_columns=cols,dropped_empty_rows=empty_rows,dropped_empty_columns=empty_cols,content_RTL=bool(rtl),physical_column_serialization=chosen,cell_padding=3,grid_text_never_used=True)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_builder.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image-only pen/print separation and physical row construction; no gold inputs."""
2
+ import math
3
+ import numpy as np
4
+ import cv2
5
+
6
+ DEFAULT=dict(core_overlap=.5,baseline_distance=.4,max_gap=3.,nominal_pad=.06,color_angle_degrees=35,color_depth=.055,color_chroma=.045,color_relative_chroma=.16,min_pen_components=15,min_pen_pixels=80)
7
+ def rect_intersection(a,b):return[max(a[0],b[0]),max(a[1],b[1]),min(a[2],b[2]),min(a[3],b[3])]
8
+ def rect_area(a):return max(0,a[2]-a[0])*max(0,a[3]-a[1])
9
+ def estimate_height(proposals,H):
10
+ h=[b[3]-b[1]for b in proposals if b[3]>b[1]and b[2]-b[0]>=2*(b[3]-b[1])and 4<=b[3]-b[1]<=min(128,.12*H)]
11
+ return max(6.,min(100.,float(np.percentile(h,60))if h else H/40))
12
+ def remove_rules(mask,line):
13
+ H,W=mask.shape;u=mask.astype(np.uint8);horizontal=cv2.morphologyEx(u,cv2.MORPH_OPEN,np.ones((1,max(30,round(.12*W))),np.uint8));vertical=cv2.morphologyEx(u,cv2.MORPH_OPEN,np.ones((max(30,round(.1*H)),1),np.uint8));rules=cv2.dilate(horizontal|vertical,np.ones((3,3),np.uint8));return(u>0)&~(rules>0),rules>0
14
+ def components(mask,min_area=2):
15
+ n,labels,stats,centers=cv2.connectedComponentsWithStats(mask.astype(np.uint8),8);return labels,[dict(label=i,x=int(x),y=int(y),w=int(w),h=int(h),area=int(a),cx=float(centers[i][0]),cy=float(centers[i][1]))for i,(x,y,w,h,a)in enumerate(stats)if i and a>=min_area]
16
+ def columns(mask,line,proposals=()):
17
+ H,W=mask.shape;profile=mask.sum(axis=0);low=profile<=max(1,.002*H);edges=np.diff(np.r_[False,low,False].astype(int));gaps=[]
18
+ for a,b in zip(np.where(edges==1)[0],np.where(edges==-1)[0]):
19
+ if b-a<max(round(.4*line),.005*W)or a<.15*W or b>.85*W:continue
20
+ cut=(a+b)//2
21
+ crossing=[p for p in proposals if p[0]<a-line and p[2]>b+line and p[2]-p[0]>.55*W and p[3]-p[1]<2*line]
22
+ if len(crossing)>=3:continue
23
+ left=int(mask[:,:a].sum());right=int(mask[:,b:].sum());total=max(1,int(mask.sum()))
24
+ if min(left,right)>=.15*total and min(a,W-b)>=5*line:gaps.append((b-a,(a+b)//2))
25
+ if not gaps:return[(0,W)]
26
+ cut=max(gaps)[1];return[(cut,W),(0,cut)]
27
+ def proposal_columns(boxes,W,H,gray):
28
+ vertical=cv2.morphologyEx((gray<100).astype(np.uint8),cv2.MORPH_OPEN,np.ones((max(30,round(.18*H)),1),np.uint8));occupied=vertical.sum(0)>.18*H;edges=np.diff(np.r_[False,occupied,False].astype(int));rulings=[]
29
+ for a,b in zip(np.where(edges==1)[0],np.where(edges==-1)[0]):
30
+ if not rulings or a-rulings[-1][1]>.01*W:rulings.append([a,b])
31
+ else:rulings[-1][1]=b
32
+ # Count physical rulings once, including antialiased/double-stroke borders.
33
+ if len(rulings)>=5:return[(0,W)]
34
+ anchors=[b for b in boxes if .2*W<=b[2]-b[0]<=.7*W and b[3]-b[1]<=.15*H];centers=sorted(((b[0]+b[2])/2,b)for b in anchors)
35
+ if len(centers)<6:return None
36
+ for gap,k in sorted(((centers[k+1][0]-centers[k][0],k)for k in range(2,len(centers)-3)),reverse=True):
37
+ if gap<.15*W:break
38
+ left=[b for _,b in centers[:k+1]];right=[b for _,b in centers[k+1:]];a=float(np.median([b[2]for b in left]));z=float(np.median([b[0]for b in right]))
39
+ if z-a>=.02*W:
40
+ cut=round((a+z)/2);return[(cut,W),(0,cut)]
41
+ return None
42
+
43
+ def layer_masks(rgb,proposals,params):
44
+ H,W=rgb.shape[:2];line=estimate_height(proposals,H);k=max(15,min(71,round(1.2*line)));k+=1-k%2
45
+ background=cv2.morphologyEx(rgb,cv2.MORPH_CLOSE,cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(k,k))).astype(np.float32)
46
+ optical=np.maximum(0,np.log((background+2)/(rgb.astype(np.float32)+2)));depth=optical.mean(2);chroma=optical-depth[:,:,None];strength=np.linalg.norm(chroma,axis=2);relative=strength/(depth+.05)
47
+ direction=optical/(optical.sum(2)[:,:,None]+1e-5);valid=depth>.30;points=direction[valid].astype(np.float32)
48
+ if len(points)<100:return np.zeros((H,W),bool),np.zeros((H,W),bool),cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY),dict(reliable=False)
49
+ cv2.setRNGSeed(1729);sample=points[::max(1,len(points)//30000)].copy();K=4;_,_,centers=cv2.kmeans(sample,K,None,(cv2.TERM_CRITERIA_EPS+cv2.TERM_CRITERIA_MAX_ITER,60,.0002),3,cv2.KMEANS_PP_CENTERS)
50
+ distances=np.stack([np.linalg.norm(direction-c,axis=2)for c in centers]);assigned=distances.argmin(0);scores=[];clean=[];traces=[]
51
+ for i in range(K):
52
+ # Include neighbouring optical directions belonging to the same pen colour.
53
+ compatible=np.linalg.norm(centers-centers[i],axis=1)<.035
54
+ mask=(depth>.22)&compatible[assigned];mask,rules=remove_rules(mask,line);lab,cc=components(mask,max(3,round(.004*line*line)))
55
+ good=[c for c in cc if c['h']<3*line and c['w']<.85*W and c['area']<.04*W*H and not(c['w']<.3*line and c['h']>.9*line and(c['x']<.04*W or c['x']+c['w']>.96*W))]
56
+ mask=np.isin(lab,[c['label']for c in good]);long=[c for c in good if c['w']>=1.5*c['h']and c['h']>=.15*line and c['w']>=.5*line]
57
+ bins_y=len(set(int(c['cy']/max(1,2*line))for c in long));mass=sum(c['area']for c in long);score=mass*min(1,bins_y/8)*max(.008,float(np.linalg.norm(centers[i]-np.ones(3)/3)))
58
+ scores.append(score);clean.append(mask);traces.append(dict(class_id=i,center=centers[i].tolist(),pixels=int(mask.sum()),long_components=len(long),vertical_bins=bins_y,score=score))
59
+ best=int(np.argmax(scores));pen=clean[best];lab,cc=components(pen,2);span=max((c['y']+c['h']for c in cc),default=0)-min((c['y']for c in cc),default=0)
60
+ other=[scores[j]for j in range(K)if np.linalg.norm(centers[j]-centers[best])>=.04];secondary=max(other,default=0)
61
+ reliable=traces[best]['long_components']>=8 and traces[best]['vertical_bins']>=5 and secondary>max(.5,.015*scores[best])
62
+ neutral=centers[int(np.argmin(np.linalg.norm(centers-np.ones(3)/3,axis=1)))];radius=max(.025,.65*float(np.linalg.norm(centers[best]-neutral)));compatible=np.linalg.norm(centers-centers[best],axis=1)<radius
63
+ _,all_rules=remove_rules(depth>.16,line);pen=(depth>.07)&compatible[assigned]&~all_rules;labels,allcc=components(pen,2)
64
+ ids=[c['label']for c in allcc if c['h']<3*line and c['w']<.85*W and not(c['w']<.3*line and c['h']>.9*line and(c['x']<.04*W or c['x']+c['w']>.96*W))];pen=np.isin(labels,ids)
65
+ printed=(depth>.22)&~(cv2.dilate(pen.astype(np.uint8),np.ones((3,3),np.uint8))>0);printed,_=remove_rules(printed,line)
66
+ dilated=cv2.dilate(pen.astype(np.uint8),np.ones((3,3),np.uint8))>0
67
+ gray=cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY);bg=cv2.cvtColor(background.clip(0,255).astype(np.uint8),cv2.COLOR_RGB2GRAY);print_gray=gray.copy();print_gray[dilated]=bg[dilated]
68
+ trace_core=(depth>.22)&compatible[assigned]&~all_rules
69
+ return pen,printed,print_gray,dict(_core_mask=trace_core,reliable=bool(reliable),estimated_line_height=line,selected_class=best,classes=traces,pen_components=len(cc),pen_pixels=int(pen.sum()),color_span_y=span,background_kernel=k,no_gold_or_template_used=True)
70
+
71
+ def build_rows(mask,proposals,params,regions=None,core=None):
72
+ from scipy.ndimage import gaussian_filter1d
73
+ from scipy.signal import find_peaks
74
+ H,W=mask.shape;line=estimate_height(proposals,H);cols=regions or columns(mask,line,proposals);rows=[];row_trace=[]
75
+ for column,(left,right)in enumerate(cols):
76
+ m=mask[:,left:right];cm=(core if core is not None else mask)[:,left:right];profile=cm.sum(1).astype(float);smooth=gaussian_filter1d(profile,max(1,.08*line));corr=np.correlate(smooth-smooth.mean(),smooth-smooth.mean(),mode='full')[H-1:];lo=max(5,round(.5*line));hi=min(H//3,round(3.5*line));period=line*1.7
77
+ if hi>lo:
78
+ peaks,_=find_peaks(corr[lo:hi]);valid=[lo+int(k)for k in peaks if corr[lo+k]>.12*corr[0]]
79
+ if valid:period=min(valid)
80
+ period=line;peaks,_=find_peaks(smooth,distance=max(3,round(.65*line)),prominence=max(.8,.025*smooth.max()))
81
+ peaks=[int(y)for y in peaks if smooth[y]>=max(1,.025*smooth.max())]
82
+ for j,peak in enumerate(peaks):
83
+ low=0 if not j else peaks[j-1]+int(np.argmin(smooth[peaks[j-1]:peak+1]));high=H if j+1==len(peaks)else peak+int(np.argmin(smooth[peak:peaks[j+1]+1]))
84
+ # Limit isolated/noisy bands, retaining full connected strokes and dots.
85
+ low=max(low,round(peak-.8*period));high=min(high,round(peak+.8*period));xs=np.where(m[low:high].any(0))[0]
86
+ segments=[]
87
+ for x in xs:
88
+ if not segments or x-segments[-1][-1]>max(params['max_gap'],8.)*line:segments.append([])
89
+ segments[-1].append(int(x))
90
+ for segment in segments:
91
+ x=left+segment[0];X=left+segment[-1]+1;ys,xx=np.where(mask[low:high,x:X])
92
+ if len(ys)<max(5,.01*line*line):continue
93
+ coreys=np.where(cm[low:high,x-left:X-left])[0];tight=coreys if len(coreys)>=max(4,.05*len(ys))else ys
94
+ y=low+int(tight.min());Y=low+int(tight.max())+1;pad=max(1.,.04*line);box=[max(0.,x-pad),max(0.,y-pad),min(float(W),X+pad),min(float(H),Y+pad)];support=[i for i,p in enumerate(proposals)if rect_area(rect_intersection(box,p))>0]
95
+ rows.append(dict(box=box,baseline=float(np.quantile(ys,.8)+low),column=column));row_trace.append(dict(source_proposals=support,ink_pixels=len(ys),read_band=[low,high],peak=peak,period=period))
96
+ return rows,dict(estimated_line_height=line,columns=cols,rows=row_trace)
97
+
98
+ def build(rgb,proposals,params=None,primary=None):
99
+ params=dict(DEFAULT,**(params or {}));pen,printed,print_gray,trace=layer_masks(rgb,proposals,params);core=trace.pop('_core_mask',None)
100
+ if not trace['reliable']:return None,dict(layer_mask=trace,reason='No confident separated handwriting and printed layers')
101
+ cols=proposal_columns(primary or proposals,rgb.shape[1],rgb.shape[0],cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY));hand,ht=build_rows(pen,proposals,params,core=core,regions=cols);printed_rows,pt=build_rows(printed,proposals,params,regions=ht['columns'])
102
+ return dict(handwriting=hand,printed=printed_rows,printed_gray=print_gray,pen_mask=pen,printed_mask=printed),dict(parameters=params,layer_mask=trace,handwriting_rows=ht,printed_rows=pt,source_proposal_count=len(proposals),handwriting_readings=len(hand),printed_readings=len(printed_rows),no_gold_or_template_used=True)
stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_pipeline.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import numpy as np
3
+ from line_contract_reader import ContractPipeline
4
+ from baseline.pipeline_v3 import rectangle,clipped,order_blocks,reading_extent
5
+ from baseline.input_policy_v3 import input_kind
6
+ from row_builder import build
7
+
8
+ class RowPipeline(ContractPipeline):
9
+ def __init__(self,*args,row_parameters=None,**kw):
10
+ super().__init__(*args,**kw);self.row_parameters=row_parameters or{}
11
+ def __call__(self,image,*,input_type='auto',source_name='',content_type='',**kw):
12
+ if input_kind(*image.size,input_type)!='page':return super().__call__(image,input_type=input_type,source_name=source_name,content_type=content_type,**kw)
13
+ tick=time.monotonic();image=image.convert('RGB');rgb=np.asarray(image);gray=np.asarray(image.convert('L'));boxes,detection=self.detect(image);proposals=[]
14
+ if detection.get('full_image_SDK'):
15
+ proposals +=[clipped(rectangle(b['polygon']),image.width,image.height)for b in detection['full_image_SDK']['bboxes']]
16
+ proposals +=[b for t in detection.get('native_resolution_tiles',[])for b in t['boxes']]
17
+ if not proposals:proposals=boxes
18
+ layers,trace=build(rgb,proposals,self.row_parameters,primary=boxes)
19
+ if layers is None or not layers['handwriting']:
20
+ result=super().__call__(image,input_type='page',source_name=source_name,content_type=content_type,**kw);result['row_consolidation']=dict(applied=False,**trace);return result
21
+ self.reader.rule_c_active=False;self.reader.source_is_crop=False
22
+ hand=self.reader(gray,[r['box']for r in layers['handwriting']])
23
+ for line,meta in zip(hand,layers['handwriting']):line.update(layer='handwriting',baseline=meta['baseline'],column=meta['column'],source_pixels_modified=False)
24
+ # Retain the established printed reads outside consolidated pen rows.
25
+ # This avoids turning weak paper texture or printed rules into new reads.
26
+ original=super().__call__(image,input_type='page',source_name=source_name,content_type=content_type,**kw);printed=[]
27
+ for line in original['lines']:
28
+ b=line['box'];overlap=False
29
+ for row in layers['handwriting']:
30
+ r=row['box'];vy=min(b[3],r[3])-max(b[1],r[1]);vx=min(b[2],r[2])-max(b[0],r[0])
31
+ if vy>.35*min(b[3]-b[1],r[3]-r[1])and vx>0:overlap=True;break
32
+ if overlap:continue
33
+ x=dict(line);x.update(layer='printed',baseline=float(b[3]),column=next((i for i,(a,z)in enumerate(trace['handwriting_rows']['columns'])if a<=(b[0]+b[2])/2<z),0),source_pixels_modified=False);printed.append(x)
34
+ all_attempts=hand+printed;hand=[l for l in hand if l['text'].strip()];printed=[l for l in printed if l['text'].strip()]
35
+ lines=hand+printed;newboxes=[l['box']for l in lines];layout=self.layout.run_batch([image],[dict(threshold=None,use_order=True)])[0].model_dump(mode='json');blocks=[];ordering={'method':'image_column_then_baseline'}
36
+ for col in sorted(set(l['column']for l in lines)):
37
+ blocks.append(dict(line_ids=[i for i,l in enumerate(lines)if l['column']==col],region={'column':col,'polygon':[[0,0],[image.width,0],[image.width,image.height],[0,image.height]]}))
38
+ regions=[];emitted=[]
39
+ for block in blocks:
40
+ ids=block['line_ids'];ids.sort(key=lambda i:(lines[i]['baseline'],-lines[i]['box'][0]));rr=[lines[i]for i in ids];regions.append(dict(layout=block['region'],source_line_ids=ids,path='consolidated_rows_printed_layer',lines=rr,text='\n'.join(l['text']for l in rr)));emitted+=rr
41
+ assert sorted(i for r in regions for i in r['source_line_ids'])==list(range(len(lines)))
42
+ return dict(text='\n\n'.join(r['text']for r in regions),regions=regions,lines=emitted,global_readings=lines,detected_boxes=newboxes,emitted_boxes=[l['box']for l in emitted],recognition_extents=[reading_extent(l)for l in emitted],handwriting_extents=[reading_extent(l)for l in hand],raw_line_detection=detection,raw_layout=layout,order=ordering,fallbacks=[],region_type_line_losses=0,recognizer_bank='unified32M_only',whole_image_detection=True,seconds=time.monotonic()-tick,row_consolidation=dict(applied=True,read_attempts=len(all_attempts),blank_attempts_not_emitted=sum(not l['text'].strip()for l in all_attempts),**trace),main_Bina_training_changes=False)
stallion/bina04-header-marks-95k-20261103/CALIBRATION.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"selected": {"parameters": {"max_chars": 1, "radius": 2.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, "trials": [{"parameters": {"max_chars": 0, "radius": 2.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.40206629358588, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7692638829100302, "ordered": 9.328454584588894}, "full_CER": 12.11040676175383, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 2.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.303056392595781, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6616444253120963, "ordered": 9.229444683598794}, "full_CER": 12.013558725127663, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 2.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.277227722772277, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6358157554885924, "ordered": 9.20361601377529}, "full_CER": 11.987145624229617, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 2.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.04476969436074, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3560051657339647, "ordered": 8.953938872148084}, "full_CER": 11.753829899630217, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 3.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.40206629358588, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7692638829100302, "ordered": 9.328454584588894}, "full_CER": 12.11040676175383, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 3.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.303056392595781, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6616444253120963, "ordered": 9.229444683598794}, "full_CER": 12.013558725127663, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 3.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.277227722772277, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6358157554885924, "ordered": 9.20361601377529}, "full_CER": 11.987145624229617, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 3.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.04476969436074, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3560051657339647, "ordered": 8.953938872148084}, "full_CER": 11.753829899630217, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 4.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.40206629358588, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7692638829100302, "ordered": 9.328454584588894}, "full_CER": 12.11040676175383, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 4.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.303056392595781, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6616444253120963, "ordered": 9.229444683598794}, "full_CER": 12.013558725127663, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 4.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.277227722772277, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6358157554885924, "ordered": 9.20361601377529}, "full_CER": 11.987145624229617, "reading_gate_pass": true}, {"parameters": {"max_chars": 0, "radius": 4.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.04476969436074, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3560051657339647, "ordered": 8.953938872148084}, "full_CER": 11.753829899630217, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 2.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 2.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 2.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 2.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 3.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 3.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 3.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 3.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 4.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 4.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 4.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 1, "radius": 4.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 2.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 2.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 2.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 2.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 3.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 3.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 3.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 3.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 4.0, "header_confidence": 0.7}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.397761515281964, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.7649591046061128, "ordered": 9.324149806284977}, "full_CER": 12.106004578270822, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 4.0, "header_confidence": 0.85}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.298751614291865, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.6573396470081792, "ordered": 9.225139905294878}, "full_CER": 12.009156541644655, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 4.0, "header_confidence": 0.95}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.27292294446836, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.631510977184675, "ordered": 9.199311235471374}, "full_CER": 11.98274344074661, "reading_gate_pass": true}, {"parameters": {"max_chars": 2, "radius": 4.0, "header_confidence": 1.1}, "reading": {"pages": 25, "reference_chars": 23230, "total_CER": 12.040464916056823, "match_short": 0.4476969436074042, "match_long": 2.0705983641842445, "missing_short": 5.368058544984933, "missing_long": 0.594059405940594, "extra": 1.3517003874300473, "ordered": 8.949634093844168}, "full_CER": 11.74942771614721, "reading_gate_pass": true}], "check25_used_for_thresholds": false, "no_template_classifier": true}
stallion/bina04-header-marks-95k-20261103/candidate.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image-only header enhancement plus isolated-mark removal; no templates."""
2
+ import sys,copy,json
3
+ from pathlib import Path
4
+ import cv2,numpy as np
5
+ from PIL import Image
6
+ R=Path(__file__).resolve().parent;P=Path('__BINA_ROOT__/stallion/indra-scale');sys.path[:0]=[str(R),str(P),'__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031','__BINA_ROOT__/stallion/bina04-indra-pilot-20261027/upstream']
7
+ from serializer import apply as add_printed
8
+
9
+ def header_boxes(image):
10
+ rgb=np.asarray(image.convert('RGB'));H,W=rgb.shape[:2];h=max(1,round(.2*H));top=rgb[:h];gray=cv2.cvtColor(top,cv2.COLOR_RGB2GRAY);hsv=cv2.cvtColor(top,cv2.COLOR_RGB2HSV);tint=((hsv[:,:,1]>12)&(hsv[:,:,2]>135)).astype('uint8')*255;tint=cv2.morphologyEx(tint,cv2.MORPH_CLOSE,np.ones((9,25),np.uint8));tint=cv2.morphologyEx(tint,cv2.MORPH_OPEN,np.ones((5,9),np.uint8))
11
+ ink=cv2.adaptiveThreshold(gray,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,31,5);hor=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((1,max(30,W//8)),np.uint8));ver=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((max(12,h//5),1),np.uint8));rules=cv2.dilate(hor|ver,np.ones((5,5),np.uint8));out=[]
12
+ for kind,mask in [('tinted',tint),('bordered',rules)]:
13
+ nc,_,stats,_=cv2.connectedComponentsWithStats(mask)
14
+ for x,y,w,hh,a in stats[1:]:
15
+ if w<.18*W or hh<max(8,.012*H)or a<.0008*H*W:continue
16
+ b=[max(0,int(x-3)),max(0,int(y-3)),min(W,int(x+w+3)),min(h,int(y+hh+3))]
17
+ if not any(b[0]>=q['box'][0]and b[1]>=q['box'][1]and b[2]<=q['box'][2]and b[3]<=q['box'][3]for q in out):out.append(dict(box=b,source=kind))
18
+ return out
19
+
20
+ def runs(mask):
21
+ e=np.diff(np.r_[False,mask,False].astype(int));return list(zip(np.flatnonzero(e==1),np.flatnonzero(e==-1)))
22
+ def split_labels(gray):
23
+ ink=cv2.adaptiveThreshold(gray,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,31,3);H,W=gray.shape;hr=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((1,max(60,W//8)),np.uint8));vr=cv2.morphologyEx(ink,cv2.MORPH_OPEN,np.ones((max(40,H//3),1),np.uint8));ink=cv2.subtract(ink,hr|vr);nc,_,stats,_=cv2.connectedComponentsWithStats(ink)
24
+ heights=[a[3]for a in stats[1:]if 4<=a[3]<=H*.3 and a[4]>=5];charh=float(np.median(heights))if heights else 18.;profile=ink.sum(1)/255;bands=runs(profile>=max(2,.001*W));merged=[]
25
+ for a,b in bands:
26
+ if merged and a-merged[-1][1]<=max(2,.25*charh):merged[-1][1]=b
27
+ else:merged.append([a,b])
28
+ out=[]
29
+ for y,Y in merged:
30
+ if Y-y<3:continue
31
+ xbands=runs((ink[y:Y]>0).any(0));groups=[]
32
+ for x,X in xbands:
33
+ if groups and x-groups[-1][1]<=2*charh:groups[-1][1]=X
34
+ else:groups.append([x,X])
35
+ for x,X in groups:
36
+ if X-x>=2 and int((ink[y:Y,x:X]>0).sum())>=5:out.append([max(0,int(x-3)),max(0,int(y-3)),min(W,int(X+3)),min(H,int(Y+3))])
37
+ return out,dict(character_height_upscaled=charh,rows=len(merged),labels=len(out),split_gap_character_heights=2.)
38
+ class Collector:
39
+ def __init__(self,device='cpu'):
40
+ from rtl_reader import RTLReader
41
+ self.reader=RTLReader('__BINA_ROOT__/stallion/bina04-print-textgate-20261102/run/selected-printed-recognizer.pt','__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031/data/vocab.json',device=device)
42
+ def __call__(self,image,base):
43
+ from text_contract import decode_prediction
44
+ new=[];traces=[]
45
+ for region in header_boxes(image):
46
+ x,y,X,Y=region['box'];rgb=np.asarray(image.crop((x,y,X,Y)).convert('RGB'));gray=rgb.min(2).astype('float32');bg=cv2.GaussianBlur(gray,(0,0),max(9,(Y-y)/12));norm=np.clip(gray/(bg+1e-4)*245,0,255).astype('uint8');norm=cv2.createCLAHE(clipLimit=2.,tileGridSize=(8,8)).apply(norm);up=cv2.resize(norm,None,fx=3,fy=3,interpolation=cv2.INTER_CUBIC);boxes,trace=split_labels(up);traces.append(dict(**region,segmentation=trace))
47
+ for start in range(0,len(boxes),24):
48
+ bb=boxes[start:start+24];_,receipts=self.reader(up,np.asarray(bb,np.float32))
49
+ for b,g in zip(bb,receipts):
50
+ text=decode_prediction(g['raw_visual_text']);box=[b[0]/3+x,b[1]/3+y,b[2]/3+x,b[3]/3+y];extent=g['padded_source_extent'];geom=dict(raw_visual_text=g['raw_visual_text'],raw_token_ids=g.get('raw_token_ids',[]),confidence=g['confidence'],frame_centers=[v/3+x for v in g.get('frame_centers',[])],source_pixels_extent=[extent[0]/3+x,extent[1]/3+y,extent[2]/3+x,extent[3]/3+y],padded_source_extent=[extent[0]/3+x,extent[1]/3+y,extent[2]/3+x,extent[3]/3+y],preprocessing_contract='3x bicubic, min-RGB background division and CLAHE; fixed crop_line64',source_pixels_modified=True)
51
+ new.append(dict(box=box,text=text,visual_text=g['raw_visual_text'],confidence=g['confidence'],geometry=geom,layer='printed',recognizer='frozen_overlap_short_expert',printed_probability=1.,enhanced_header=True,header_region=region))
52
+ return dict(new=new,old_support=[1.]*len(base['lines']),WH=list(image.size),header_regions=traces)
53
+
54
+ def clean(s):return ''.join(s.split()).replace('\u200c','')
55
+ def isolated(line,lines,maxlen,radius,W):
56
+ t=clean(line['text']);b=line['box'];h=max(2,b[3]-b[1]);cx=(b[0]+b[2])/2
57
+ if not t or len(t)>maxlen:return False
58
+ if all(c.isdigit()for c in t)and(cx<.16*W or cx>.84*W):return False
59
+ for q in lines:
60
+ if q is line or len(clean(q['text']))<=2:continue
61
+ z=q['box'];hh=max(2,z[3]-z[1]);over=min(b[3],z[3])-max(b[1],z[1]);gap=max(0,max(b[0],z[0])-min(b[2],z[2]))
62
+ if over>=.5*min(h,hh)and gap<=radius*max(h,hh):return False
63
+ return True
64
+
65
+ def apply(base,prepared,params):
66
+ p=dict(printed_probability=.5,read_confidence=params['header_confidence'],old_support=.5);result=add_printed(base,prepared,p);original=copy.deepcopy(result);removed=[]
67
+ if params['max_chars']:
68
+ removed=[l for l in result['lines']if isolated(l,result['lines'],params['max_chars'],params['radius'],prepared['WH'][0])]
69
+ if not removed:return result
70
+ # Preserve grids as atomic structures; their punctuation/digit cells have a dedicated path.
71
+ if any(r.get('table_trace',{}).get('grid')for r in result['regions']):return result
72
+ keys={(tuple(l['box']),l['text'])for l in removed};ll=[l for l in result['lines']if(tuple(l['box']),l['text'])not in keys];result['lines']=ll;regions=[]
73
+ for r in result['regions']:
74
+ r['lines']=[l for l in r.get('lines',[])if(tuple(l['box']),l['text'])not in keys];r['text']='\n'.join(l['text']for l in r['lines'])
75
+ if r['lines']:regions.append(r)
76
+ result.update(regions=regions,text='\n\n'.join(r['text']for r in regions),global_readings=ll,emitted_boxes=[l['box']for l in ll],recognition_extents=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in ll]);result['isolated_mark_filter']=dict(removed=removed,parameters=params,margin_digits_exempt=True);return result
stallion/bina04-header-marks-95k-20261103/production.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json,importlib.util,sys
2
+ from pathlib import Path
3
+ R=Path(__file__).resolve().parent
4
+ class Runtime:
5
+ def __init__(self,device='auto'):
6
+ s=importlib.util.spec_from_file_location('frozen_parent_header_marks','__BINA_ROOT__/stallion/bina04-numeric-routing-95k-20261103/consumer.py');m=importlib.util.module_from_spec(s);s.loader.exec_module(m);self.parent=m.Runtime(device);self.params=json.loads((R/'CALIBRATION.json').read_text())['selected']['parameters'];self.collector=None;sys.path.insert(0,str(R))
7
+ def __call__(self,image,old,kind,name='',**kwargs):
8
+ out=self.parent(image,old,kind,name,**kwargs)
9
+ if self.params['header_confidence']>1:p=dict(new=[],old_support=[1.]*len(out['lines']),WH=list(image.size),header_regions=[])
10
+ else:
11
+ from candidate import Collector
12
+ if self.collector is None:self.collector=Collector('cpu')
13
+ p=self.collector(image,out)
14
+ from candidate import apply
15
+ return apply(out,p,self.params)
16
+ def close(self):self.parent.close()
stallion/bina04-header-marks-95k-20261103/serializer.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Learned visual printed layer; body records and order remain immutable."""
2
+ import copy,json,sys
3
+ from pathlib import Path
4
+ import numpy as np
5
+ from PIL import Image
6
+ R=Path(__file__).resolve().parent;P=Path('__BINA_ROOT__/stallion/indra-scale');sys.path[:0]=[str(P),'__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031','__BINA_ROOT__/stallion/bina04-indra-pilot-20261027/upstream']
7
+ def body(p):return json.dumps([l for l in p['lines']if l.get('layer')!='printed'],sort_keys=True,ensure_ascii=False)
8
+ def hit(a,b):
9
+ z=max(0,min(a[2],b[2])-max(a[0],b[0]))*max(0,min(a[3],b[3])-max(a[1],b[1]));return z/max(1,min((a[2]-a[0])*(a[3]-a[1]),(b[2]-b[0])*(b[3]-b[1])))
10
+ def same_glyph_evidence(new,old):
11
+ """Independent heads must not serialize the same CTC glyphs twice.
12
+ Different text at the same location is preserved, including print under pen.
13
+ """
14
+ if old.get('layer')=='printed':return False
15
+ a=new['box'];b=old['box'];ah=max(1,a[3]-a[1]);bh=max(1,b[3]-b[1])
16
+ if hit(a,b)<.5 or abs((a[1]+a[3]-b[1]-b[3])/2)>.5*min(ah,bh):return False
17
+ ng=new.get('geometry',{});og=old.get('geometry',{})
18
+ if og.get('frame_centers_source_valid',True)is False:return False
19
+ nv=ng.get('raw_visual_text',new.get('visual_text',''));ov=og.get('raw_visual_text',old.get('visual_text',''));nc=ng.get('frame_centers',[]);oc=og.get('frame_centers',[])
20
+ if not nv or len(nc)!=len(nv) or len(oc)!=len(ov):return False
21
+ start=0
22
+ while True:
23
+ at=ov.find(nv,start)
24
+ if at<0:return False
25
+ delta=np.abs(np.asarray(nc)-np.asarray(oc[at:at+len(nv)]))
26
+ if float(np.quantile(delta,.9))<=max(2.,.5*min(ah,bh)):return True
27
+ start=at+1
28
+
29
+ class Layer:
30
+ def __init__(self,device='cuda'):
31
+ import torch
32
+ from surya.detection import DetectionPredictor
33
+ from rtl_reader import RTLReader
34
+ self.det=DetectionPredictor.local(device=device,dtype=torch.float32);q=torch.load(R/'run/selected.pt',map_location='cpu',weights_only=True);assert q['classes']==['printed','handwriting'];self.det.model.load_state_dict(q['model']);self.det.model.eval();self.det._disable_tqdm=True;self.reader=RTLReader('__BINA_ROOT__/stallion/bina04-print-textgate-20261102/run/selected-printed-recognizer.pt','__BINA_ROOT__/stallion/bina04-pagepath-v3-round3-clean-20261031/data/vocab.json',device=device)
35
+ def infer(self,image,base):
36
+ from surya.detection.heatmap import parallel_get_boxes
37
+ from text_contract import decode_prediction
38
+ W,H=image.size;preds,sizes=next(self.det.batch_detection([image],batch_size=1));p,h=preds[0];raw=parallel_get_boxes([np.minimum(1,p*1.5),h],sizes[0]).model_dump(mode='json');boxes=[]
39
+ def support(b):
40
+ x,y,X,Y=b;hh,ww=p.shape;a=max(0,round(x*ww/W));A=min(ww,round(X*ww/W));b0=max(0,round(y*hh/H));B=min(hh,round(Y*hh/H));z=p[b0:B,a:A];return float(np.quantile(z,.65))if z.size else 0.
41
+ atomic=[r for r in base['regions']if r.get('table_trace',{}).get('grid')];gridboxes=[]
42
+ for r in atomic:
43
+ ll=r.get('lines',[])
44
+ if ll:gridboxes.append([min(l['box'][0]for l in ll),min(l['box'][1]for l in ll),max(l['box'][2]for l in ll),max(l['box'][3]for l in ll)])
45
+ for a in raw['bboxes']:
46
+ q=np.asarray(a['polygon']);b=[float(q[:,0].min()),float(q[:,1].min()),float(q[:,0].max()),float(q[:,1].max())]
47
+ if not any(hit(b,g)>.8 for g in gridboxes):boxes.append((b,support(b)))
48
+ up=np.asarray(image.resize((2*W,2*H),Image.Resampling.BICUBIC).convert('L'));new=[]
49
+ for i in range(0,len(boxes),24):
50
+ bb=boxes[i:i+24];_,receipts=self.reader(up,np.asarray([[2*v for v in b]for b,_ in bb],np.float32))
51
+ for (b,s),g in zip(bb,receipts):
52
+ g={k:v for k,v in g.items()if k!='_numeric_capture_id'};text=decode_prediction(g['raw_visual_text']);geometry=dict(original_WH=[W,H],ink_box=[v/2 for v in g['ink_box']],source_pixels_extent=[v/2 for v in g['padded_source_extent']],padded_source_extent=[v/2 for v in g['padded_source_extent']],raw_visual_text=g['raw_visual_text'],raw_token_ids=g.get('raw_token_ids',[]),confidence=g['confidence'],frame_centers=[v/2 for v in g.get('frame_centers',[])],upscale=2,upscaled_input_receipt=g,preprocessing_contract='2x bicubic then unchanged crop_line64 and overlap-trained short-label expert')
53
+ new.append(dict(box=b,text=text,visual_text=g['raw_visual_text'],confidence=g['confidence'],geometry=geometry,layer='printed',recognizer='frozen_overlap_short_expert',printed_probability=s,new_two_class_printed=True))
54
+ return dict(new=new,old_support=[support(l['box'])if l.get('layer')=='printed'else None for l in base['lines']],raw=raw,WH=[W,H],no_template_or_order_model=True)
55
+ def apply(base,prepared,params):
56
+ if params.get('disabled'):return copy.deepcopy(base)
57
+ result=copy.deepcopy(base);original=body(base);new=[l for l in prepared['new']if l['text'].strip()and l['confidence']>=params['read_confidence']and l['printed_probability']>=params['printed_probability']];duplicates=[l for l in new if any(same_glyph_evidence(l,b)for b in base['lines'])];new=[l for l in new if not any(same_glyph_evidence(l,b)for b in base['lines'])];new.sort(key=lambda l:(-l['printed_probability'],-l['confidence']));unique=[]
58
+ for l in new:
59
+ if not any(hit(l['box'],q['box'])>.6 for q in unique):unique.append(l)
60
+ keep=[];removed=[]
61
+ for i,l in enumerate(base['lines']):
62
+ if l.get('layer')!='printed':keep.append(copy.deepcopy(l));continue
63
+ hits=[n for n in unique if hit(l['box'],n['box'])>.5];b=l['box'];covered=bool(hits)and max(0,min(b[2],max(n['box'][2]for n in hits))-max(b[0],min(n['box'][0]for n in hits)))/max(1,b[2]-b[0])>=.8
64
+ if not covered and prepared['old_support'][i]>=params['old_support']:keep.append(copy.deepcopy(l))
65
+ else:removed.append(l)
66
+ # Never re-sort existing nodes. Insert new print by the nearest physical anchor.
67
+ slots={i:[]for i in range(len(keep)+1)}
68
+ for l in unique:
69
+ cy=(l['box'][1]+l['box'][3])/2;cx=(l['box'][0]+l['box'][2])/2
70
+ if keep:
71
+ k=min(range(len(keep)),key=lambda i:abs(cy-(keep[i]['box'][1]+keep[i]['box'][3])/2)+.15*abs(cx-(keep[i]['box'][0]+keep[i]['box'][2])/2));slot=k+int(cy>=(keep[k]['box'][1]+keep[k]['box'][3])/2)
72
+ else:slot=0
73
+ slots[slot].append(l)
74
+ lines=[]
75
+ for i in range(len(keep)+1):
76
+ lines+=sorted(slots[i],key=lambda l:((l['box'][1]+l['box'][3])/2,-l['box'][0]))
77
+ if i<len(keep):lines.append(keep[i])
78
+ if not unique and not removed:return result
79
+ # Atomic grids remain byte-identical; page row paths serialize line records.
80
+ grids=[r for r in base['regions']if r.get('table_trace',{}).get('grid')]
81
+ if grids:return copy.deepcopy(base)
82
+ result.update(global_readings=lines,detected_boxes=[l['box']for l in lines],lines=lines,text='\n'.join(l['text']for l in lines),regions=[dict(path='two_class_printed_overlay',lines=lines,text='\n'.join(l['text']for l in lines))],emitted_boxes=[l['box']for l in lines],recognition_extents=[l.get('geometry',{}).get('source_pixels_extent',l['box'])for l in lines]);assert body(result)==original;result['two_class_printed_layer']=dict(new_printed=len(unique),duplicate_read_evidence_removed=len(duplicates),old_printed_removed=len(removed),parameters=params,body_records_byte_identical=True,template_text_used=False,learned_order_changed=False);return result
stallion/bina04-indra-pilot-20261027/models/det.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2bd4cba23e9c328cf7ec824634e423e191a2c6aacbbf35c62bd79dd70f62f59
3
+ size 389948
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/__init__.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Indra OCR: fast CPU OCR (line detection, CTC recognition, reading order, markdown)."""
2
+
3
+ from .document import Converter
4
+ from .models import model_dir
5
+ from .parallel import convert_many, usable_cores
6
+ from .models import ModelError
7
+ from .pipeline import InputError, Line, OrtOCR, Page, load_pages
8
+
9
+ __all__ = [
10
+ "Converter",
11
+ "InputError",
12
+ "ModelError",
13
+ "Line",
14
+ "OrtOCR",
15
+ "Page",
16
+ "convert_many",
17
+ "usable_cores",
18
+ "load_pages",
19
+ "load",
20
+ "model_dir",
21
+ ]
22
+ __version__ = "1.1.2"
23
+
24
+
25
+ def load(
26
+ path=None,
27
+ threads: int = 4,
28
+ dpi: float = 200.0,
29
+ mode: str = "auto",
30
+ image_ocr: bool = True,
31
+ providers: list | None = None,
32
+ furniture: bool = False,
33
+ math: bool = False,
34
+ ) -> Converter:
35
+ """A ready Converter (see ``Converter`` for the options). mode="ocr" never
36
+ reads the PDF text layer. furniture: keep page headers and footers in the
37
+ markdown. math: write math as LaTeX (slower on math pages; off by default)."""
38
+ return Converter(
39
+ OrtOCR(
40
+ model_dir(path, math=math),
41
+ threads=threads,
42
+ providers=providers,
43
+ furniture=furniture,
44
+ math=math,
45
+ ),
46
+ dpi=dpi,
47
+ mode=mode,
48
+ image_ocr=image_ocr,
49
+ )
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/det_post.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Torch-free pre- and post-processing for LineNet (shared by training and inference).
2
+
3
+ Inference with ONNX Runtime needs only numpy and cv2, so these functions live
4
+ apart from the PyTorch model in det_model.py, which re-exports them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+
13
+ # Target geometry, shared by training (encode) and inference (decode).
14
+ CORE_FRAC = 0.4 # core band height as a fraction of the line ink height
15
+ CORE_X_SHRINK = 0.15 # the core is shorter than the line by this fraction of its height, at each end
16
+ OUT_STRIDE = 2
17
+ INPUT_MULTIPLE = 32 # H and W must divide by this
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Input preprocessing (shared by training and inference)
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def page_to_input(gray: np.ndarray, long_side: int) -> tuple[np.ndarray, float]:
26
+ """Resize a uint8 grayscale page so its long side is ``long_side`` and pad
27
+ to a multiple of 32 with white. Returns (float32 [H, W] ink map, scale)."""
28
+ import cv2
29
+
30
+ h, w = gray.shape
31
+ scale = long_side / max(h, w)
32
+ nh, nw = max(1, round(h * scale)), max(1, round(w * scale))
33
+ if (nh, nw) != (h, w):
34
+ interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
35
+ gray = cv2.resize(gray, (nw, nh), interpolation=interp)
36
+ ph = math.ceil(nh / INPUT_MULTIPLE) * INPUT_MULTIPLE
37
+ pw = math.ceil(nw / INPUT_MULTIPLE) * INPUT_MULTIPLE
38
+ out = np.zeros((ph, pw), np.float32) # ink map: 0 = white
39
+ out[:nh, :nw] = 1.0 - gray.astype(np.float32) / 255.0
40
+ return out, scale
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Targets
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def encode_targets(
49
+ boxes: np.ndarray,
50
+ in_hw: tuple[int, int],
51
+ valid_hw: tuple[int, int],
52
+ ignore: np.ndarray | None = None,
53
+ ):
54
+ """Build training targets at 1/2 resolution.
55
+
56
+ boxes: [N, 4] line ink boxes in input pixels (x0, y0, x1, y1)
57
+ in_hw: padded input size; valid_hw: size of the real page inside it
58
+ ignore: [M, 4] regions with no loss, in input pixels
59
+
60
+ Returns core [h, w] float32, dist [2, h, w] float32, weight [h, w] float32
61
+ (0 on padding and ignore regions), and reg_mask [h, w] bool (core pixels).
62
+ """
63
+ s = OUT_STRIDE
64
+ h, w = in_hw[0] // s, in_hw[1] // s
65
+ core = np.zeros((h, w), np.float32)
66
+ dist = np.zeros((2, h, w), np.float32)
67
+ weight = np.zeros((h, w), np.float32)
68
+ weight[: math.ceil(valid_hw[0] / s), : math.ceil(valid_hw[1] / s)] = 1.0
69
+ if ignore is not None:
70
+ for x0, y0, x1, y1 in ignore:
71
+ weight[
72
+ max(0, int(y0 // s)) : max(0, math.ceil(y1 / s)),
73
+ max(0, int(x0 // s)) : max(0, math.ceil(x1 / s)),
74
+ ] = 0.0
75
+ # Larger lines first, so a small line drawn later keeps its own band.
76
+ order = np.argsort(-(boxes[:, 3] - boxes[:, 1])) if len(boxes) else []
77
+ ys = (np.arange(h, dtype=np.float32) + 0.5) * s # pixel centres in input px
78
+ for i in order:
79
+ x0, y0, x1, y1 = boxes[i]
80
+ lh = y1 - y0
81
+ if lh < 2 or x1 - x0 < 2:
82
+ continue
83
+ cy = 0.5 * (y0 + y1)
84
+ half = 0.5 * CORE_FRAC * lh
85
+ r0 = max(0, int(math.floor((cy - half) / s)))
86
+ r1 = min(h, max(r0 + 1, int(math.ceil((cy + half) / s))))
87
+ sx = min(CORE_X_SHRINK * lh, 0.25 * (x1 - x0))
88
+ c0 = max(0, int(math.floor((x0 + sx) / s)))
89
+ c1 = min(w, max(c0 + 1, int(math.ceil((x1 - sx) / s))))
90
+ if r0 >= h or c0 >= w:
91
+ continue
92
+ core[r0:r1, c0:c1] = 1.0
93
+ dist[0, r0:r1, c0:c1] = (ys[r0:r1] - y0)[:, None]
94
+ dist[1, r0:r1, c0:c1] = (y1 - ys[r0:r1])[:, None]
95
+ # A line inside an ignore region is still a positive.
96
+ weight[r0:r1, c0:c1] = np.maximum(weight[r0:r1, c0:c1], 1.0)
97
+ return core, dist, weight, core > 0.5
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Decoding
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def decode(
106
+ out: np.ndarray,
107
+ thresh: float = 0.5,
108
+ min_area: int = 2,
109
+ grow_y: float = 0.0,
110
+ grow_x: float = 0.0,
111
+ ) -> np.ndarray:
112
+ """Decode one page. out: [3, h, w] (core logit, top dist, bottom dist).
113
+
114
+ ``grow_y`` / ``grow_x`` widen every box by that fraction of its height
115
+ (each side), to absorb the +-1 px error of the 1/2-resolution output.
116
+
117
+ Returns [N, 5] boxes in input pixels: x0, y0, x1, y1, score."""
118
+ import cv2
119
+
120
+ prob = 1.0 / (1.0 + np.exp(-out[0]))
121
+ mask = (prob > thresh).astype(np.uint8)
122
+ n, lab, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=4)
123
+ if n <= 1:
124
+ return np.zeros((0, 5), np.float32)
125
+ s = OUT_STRIDE
126
+ flat = lab.ravel()
127
+ keep = flat > 0
128
+ idx = flat[keep]
129
+ ys = (np.indices(lab.shape)[0].ravel()[keep].astype(np.float32) + 0.5) * s
130
+ top = ys - out[1].ravel()[keep]
131
+ bot = ys + out[2].ravel()[keep]
132
+ sc = prob.ravel()[keep]
133
+ cnt = np.bincount(idx, minlength=n).astype(np.float32)
134
+ # Mean is a robust enough summary here and is far cheaper than a median.
135
+ y0 = np.bincount(idx, weights=top, minlength=n) / np.maximum(cnt, 1)
136
+ y1 = np.bincount(idx, weights=bot, minlength=n) / np.maximum(cnt, 1)
137
+ score = np.bincount(idx, weights=sc, minlength=n) / np.maximum(cnt, 1)
138
+ boxes = []
139
+ for i in range(1, n):
140
+ if stats[i, cv2.CC_STAT_AREA] < min_area:
141
+ continue
142
+ lh = max(1.0, y1[i] - y0[i])
143
+ ex = min(CORE_X_SHRINK * lh, 0.5 * lh)
144
+ x0 = stats[i, cv2.CC_STAT_LEFT] * s - ex
145
+ x1 = (stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) * s + ex
146
+ boxes.append(
147
+ (
148
+ x0 - grow_x * lh,
149
+ y0[i] - grow_y * lh,
150
+ x1 + grow_x * lh,
151
+ y1[i] + grow_y * lh,
152
+ score[i],
153
+ )
154
+ )
155
+ return np.asarray(boxes, np.float32).reshape(-1, 5)
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/document.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whole documents: the PDF text layer where it can be trusted, OCR elsewhere.
2
+
3
+ For each PDF page (``mode="auto"``):
4
+
5
+ 1. Read the text layer with pdfium and check it (``textlayer.read_text_layer``). A page
6
+ without a sound layer (a scan, an OCR layer, a broken font map) goes to OCR.
7
+ 2. The detector finds the visual lines, as in OCR. Each text-layer character
8
+ goes to the detected line that contains its center. Inside a line, the
9
+ characters of each text-layer run (pdfium's segment between line breaks) keep the PDF order
10
+ and spaces, and runs are ordered by x, right to left in a right-to-left line. So inline math, sub- and
11
+ superscripts and footnote marks land in their line, and a run across two
12
+ table cells splits between them. A detected line with no text (text inside
13
+ an image) is OCR'd when the recognizer reads the page's script.
14
+ 3. The rest is the OCR path: lines split at wide gaps, reading order, layout
15
+ classes and markdown. The PDF content order is kept when it is coherent
16
+ (few upward jumps that are not column changes, and the order model agrees
17
+ with at least 85% of consecutive pairs).
18
+
19
+ ``mode="ocr"`` never reads the text layer; ``mode="text"`` uses it even when
20
+ the checks fail (and OCR only on pages with no text at all).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ import unicodedata
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+
31
+ from .pipeline import InputError, OrtOCR, Page, is_pdf, read_images
32
+ from .textlayer import BROKEN_FONT_MAP, page_turn, read_text_layer, vertical_layout
33
+
34
+ CHECK_DPI = 72.0 # resolution of the render for the text-layer ink checks
35
+ MAX_PAGE_PIXELS = 100e6 # a page larger than this at the set dpi renders at a lower dpi (large scans reach ~65 MP)
36
+
37
+ READABLE_MIN = 0.9 # share of the layer's letters the recognizer must know
38
+ AGREE_LINES = 6 # text-layer lines whose script is checked against the page image
39
+ AGREE_SHARE = 0.25 # under this share of agreeing lines, the layer is not the page's text
40
+ LATIN_CODES = 0.9 # a layer line this Latin while its image is another script: legacy font codes
41
+ NOT_THE_PAGE = "text layer does not match the page" # Page.note when the check sends a page to OCR
42
+
43
+
44
+ def latin_share(text: str) -> float:
45
+ """Share of the letters in text that are basic Latin (a-z, A-Z)."""
46
+ letters = [c for c in text if c.isalpha()]
47
+ return sum(c.isascii() for c in letters) / max(len(letters), 1)
48
+
49
+
50
+ def layer_agrees(agree: list) -> bool:
51
+ """Whether a text layer matches its page: at least AGREE_SHARE of the checked
52
+ lines agree. A legacy font (Kruti Dev and DevLys for Hindi, Bijoy for Bangla)
53
+ stores Latin codes for another script, so almost every line disagrees."""
54
+ return not agree or sum(agree) >= AGREE_SHARE * len(agree)
55
+
56
+
57
+ def readable(ocr: OrtOCR, runs: list) -> bool:
58
+ """Whether the recognizer can read the script of these text-layer runs."""
59
+ letters = [c for r in runs for c in r.text if c.isalpha()]
60
+ if not letters:
61
+ return True
62
+ vocabs = [ocr.vocab.index] + [v.index for _, v in ocr.groups.values()]
63
+ # A recognizer that reads Hangul as jamo knows a syllable by its jamo.
64
+ known = sum(
65
+ any(
66
+ c in v or all(j in v for j in unicodedata.normalize("NFD", c))
67
+ for v in vocabs
68
+ )
69
+ for c in letters
70
+ )
71
+ return known >= READABLE_MIN * len(letters)
72
+
73
+
74
+ def right_to_left(tokens: list) -> bool:
75
+ """Unicode bidi base direction: most strong characters are right-to-left."""
76
+ rtl = ltr = 0
77
+ for t in tokens:
78
+ b = unicodedata.bidirectional(t[0]) if len(t[0]) == 1 else ""
79
+ rtl += b in ("R", "AL")
80
+ ltr += b == "L"
81
+ return rtl > ltr
82
+
83
+
84
+ def _covered(box, boxes: np.ndarray, frac: float = 0.5) -> bool:
85
+ """Whether more than ``frac`` of ``box`` lies inside one of ``boxes``."""
86
+ ix = np.clip(
87
+ np.minimum(box[2], boxes[:, 2]) - np.maximum(box[0], boxes[:, 0]), 0, None
88
+ )
89
+ iy = np.clip(
90
+ np.minimum(box[3], boxes[:, 3]) - np.maximum(box[1], boxes[:, 1]), 0, None
91
+ )
92
+ area = max((box[2] - box[0]) * (box[3] - box[1]), 1e-6)
93
+ return bool(((ix * iy) / area > frac).any())
94
+
95
+
96
+ def _render(page, scale: float) -> np.ndarray:
97
+ arr = page.render(scale=scale, grayscale=True).to_numpy()
98
+ return np.ascontiguousarray(arr[..., 0] if arr.ndim == 3 else arr)
99
+
100
+
101
+ def assign_runs(runs: list, boxes: np.ndarray, k: float) -> tuple[list[list], int]:
102
+ """Tokens (char, x, content index) per detected line.
103
+
104
+ A run is one text-layer line piece (``textlayer.TextLine``), in content
105
+ order. Each character goes to the smallest detected box that contains its
106
+ center (a space follows the character before it), so a run that spans two
107
+ table cells splits between them. Inside a detected line, each run's part
108
+ keeps the PDF character order, and the parts are ordered by x. ``boxes``
109
+ are pixels, ``k`` pixels per point. Returns (tokens per detected line,
110
+ characters outside every box)."""
111
+ parts: list[dict] = [{} for _ in range(len(boxes))] # line -> run idx -> tokens
112
+ lost = 0
113
+ if not len(boxes):
114
+ return [], sum(not c.isspace() for r in runs for c, _ in r.chars)
115
+ area = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
116
+ for pos, run in enumerate(runs):
117
+ idx = run.seq if run.seq is not None else pos
118
+ j = None
119
+ for c, b in run.chars:
120
+ if not c.isspace():
121
+ cx, cy = (b[0] + b[2]) / 2 * k, (b[1] + b[3]) / 2 * k
122
+ inside = (
123
+ (boxes[:, 0] <= cx)
124
+ & (cx <= boxes[:, 2])
125
+ & (boxes[:, 1] <= cy)
126
+ & (cy <= boxes[:, 3])
127
+ )
128
+ if not inside.any():
129
+ lost += 1
130
+ j = None
131
+ continue
132
+ j = int(np.flatnonzero(inside)[np.argmin(area[inside])])
133
+ parts[j].setdefault(idx, []).append((c, cx, idx))
134
+ elif j is not None:
135
+ prev = parts[j][idx][-1]
136
+ parts[j][idx].append((c, prev[1], idx))
137
+ toks: list[list] = []
138
+ for line in parts:
139
+ tk: list = []
140
+ rtl = right_to_left([t for part in line.values() for t in part])
141
+ order = (
142
+ sorted(line, key=lambda i: -max(t[1] for t in line[i]))
143
+ if rtl
144
+ else sorted(line, key=lambda i: min(t[1] for t in line[i]))
145
+ )
146
+ for idx in order:
147
+ if tk:
148
+ tk.append((" ", tk[-1][1], idx)) # parts of different runs
149
+ tk += line[idx]
150
+ toks.append(tk)
151
+ return toks, lost
152
+
153
+
154
+ class Converter:
155
+ def __init__(
156
+ self,
157
+ ocr: OrtOCR,
158
+ dpi: float = 200.0,
159
+ mode: str = "auto",
160
+ image_ocr: bool = True,
161
+ ):
162
+ """mode: auto (text layer where sound, OCR elsewhere), ocr (always OCR,
163
+ never read the text layer), text (text layer even when the checks fail).
164
+ image_ocr: OCR detected lines that
165
+ hold no text-layer characters (False: drop them)."""
166
+ if mode not in ("auto", "ocr", "text"):
167
+ raise ValueError(f"mode must be auto, ocr or text, not {mode!r}")
168
+ self.ocr, self.dpi, self.mode = ocr, dpi, mode
169
+ self.image_ocr = image_ocr
170
+
171
+ def page_scale(self, page) -> float:
172
+ """Pixels per point for this page: the set dpi, lowered for pages so
173
+ large that the render would pass MAX_PAGE_PIXELS (posters, plans)."""
174
+ w, h = page.get_size()
175
+ k = self.dpi / 72.0
176
+ return min(k, (MAX_PAGE_PIXELS / max(w * h, 1.0)) ** 0.5)
177
+
178
+ def pdf_page(self, pdf_path: str, doc, index: int) -> Page:
179
+ page = doc[index]
180
+ t: dict = {}
181
+ note = "mode=ocr"
182
+ if self.mode != "ocr":
183
+ t0 = time.perf_counter()
184
+ s = CHECK_DPI / 72.0
185
+ check = _render(page, s)
186
+ tl = read_text_layer(page, check, s)
187
+ # A table set sideways: most characters share one quarter turn. Read
188
+ # the page turned upright, as the OCR path does with orient.onnx.
189
+ k = page_turn(tl.stats)
190
+ rot = page.get_rotation()
191
+ if k and (rot + 90 * k) % 360 == 0:
192
+ # The page's /Rotate alone turns the text: read the page without
193
+ # it. pdfium builds its text runs in the displayed frame, and on
194
+ # a turned page it can join a line's font runs out of order.
195
+ page.set_rotation(0)
196
+ upright = _render(page, s)
197
+ tl = read_text_layer(page, upright, s)
198
+ if tl.lines and (tl.trusted or self.mode == "text"):
199
+ tl.stats["turn"] = k
200
+ t["text_ms"] = 1000 * (time.perf_counter() - t0)
201
+ out = self._text_page(page, tl, t, check=self.mode == "auto")
202
+ if out is not None:
203
+ return out
204
+ page.set_rotation(rot)
205
+ tl = read_text_layer(
206
+ page, np.ascontiguousarray(np.rot90(check, -k)), s, k
207
+ )
208
+ elif k:
209
+ tl = read_text_layer(
210
+ page, np.ascontiguousarray(np.rot90(check, -k)), s, k
211
+ )
212
+ elif vertical_layout(tl.stats):
213
+ # Vertical writing: upright characters, read in the page turned a
214
+ # quarter turn counter-clockwise (columns become lines).
215
+ tl = read_text_layer(
216
+ page, np.ascontiguousarray(np.rot90(check, 1)), s, 0, 3
217
+ )
218
+ tl.stats["vertical"] = True
219
+ t["text_ms"] = 1000 * (time.perf_counter() - t0)
220
+ # A layer the recognizer cannot read beats OCR, unless its codes are junk.
221
+ unreadable = tl.reason != BROKEN_FONT_MAP and not readable(
222
+ self.ocr, tl.lines
223
+ )
224
+ note = tl.reason
225
+ if tl.lines and (tl.trusted or self.mode == "text" or unreadable):
226
+ # A trusted layer in a script the recognizer reads is checked
227
+ # against what the page shows (legacy fonts store other codes).
228
+ out = self._text_page(
229
+ page, tl, t, check=self.mode == "auto" and tl.trusted and not unreadable
230
+ )
231
+ if out is not None:
232
+ return out
233
+ note = NOT_THE_PAGE
234
+ # A layer with a broken font map still places its glyphs: when most
235
+ # run upright, the page is upright (orient.onnx is not asked).
236
+ st = tl.stats
237
+ turn = not (
238
+ tl.reason == BROKEN_FONT_MAP and st["rotated"] < 0.5 * st["chars"]
239
+ )
240
+ else:
241
+ turn = True
242
+ t0 = time.perf_counter()
243
+ gray = _render(page, self.page_scale(page))
244
+ t["render_ms"] = 1000 * (time.perf_counter() - t0)
245
+ out = self.ocr(gray, turn)
246
+ out.timings = {**t, **out.timings}
247
+ out.timings["total_ms"] = sum(
248
+ v for k, v in out.timings.items() if k != "total_ms"
249
+ )
250
+ out.note = f"{note}; {out.note}"
251
+ return out
252
+
253
+ def _text_page(self, page, tl, t: dict, check: bool = False) -> Page | None:
254
+ """The page from its text layer. check: first compare the script of up to
255
+ AGREE_LINES of the layer's lines with the script the line classifier sees
256
+ in their images; when they do not match (layer_agrees), return None, so OCR
257
+ reads the page."""
258
+ k = self.page_scale(page)
259
+ turn = tl.stats.get("turn", 0)
260
+ frame = tl.stats.get("frame", turn)
261
+ vertical = bool(tl.stats.get("vertical"))
262
+ t0 = time.perf_counter()
263
+ gray = _render(page, k)
264
+ if frame:
265
+ gray = np.ascontiguousarray(np.rot90(gray, -frame))
266
+ t["render_ms"] = 1000 * (time.perf_counter() - t0)
267
+ t0 = time.perf_counter()
268
+ boxes = self.ocr.detect(gray)
269
+ t["det_ms"] = 1000 * (time.perf_counter() - t0)
270
+ toks, lost = assign_runs(tl.lines, boxes[:, :4], k)
271
+ rec: list = [None] * len(boxes)
272
+ empty = []
273
+ for i, tk in enumerate(toks):
274
+ text = self.ocr._decode(tk) if tk else ""
275
+ if text:
276
+ rec[i] = (text, 1.0, tk)
277
+ else:
278
+ empty.append(i)
279
+ if check:
280
+ t0 = time.perf_counter()
281
+ full = [i for i, r in enumerate(rec) if r and sum(c.isalpha() for c in r[0]) >= 4]
282
+ pick = full[:: max(1, len(full) // AGREE_LINES)][:AGREE_LINES]
283
+ if pick:
284
+ # A line agrees unless its layer text is Latin while the router is
285
+ # sure its image is another script.
286
+ groups, sure = self.ocr.box_groups(gray, boxes[pick])
287
+ agree = [
288
+ not (latin_share(rec[i][0]) >= LATIN_CODES and g != "base" and s)
289
+ for i, g, s in zip(pick, groups, sure)
290
+ ]
291
+ t["check_ms"] = 1000 * (time.perf_counter() - t0)
292
+ if not layer_agrees(agree):
293
+ return None
294
+ source = "text"
295
+ # Sideways runs (a rotated table header, a chart's axis label) come from
296
+ # the layer as their own lines; detected lines on them are not OCR'd.
297
+ side = [r for r in tl.rotated if r.text.strip()]
298
+ side_boxes = np.array([[v * k for v in r.bbox] for r in side], np.float32)
299
+ if len(side) and empty:
300
+ empty = [i for i in empty if not _covered(boxes[i, :4], side_boxes)]
301
+ if empty and self.image_ocr and readable(self.ocr, tl.lines):
302
+ t0 = time.perf_counter()
303
+ for i, r in zip(
304
+ empty, self.ocr.recognize(gray, boxes[empty], vertical=vertical)
305
+ ):
306
+ if r[0].strip():
307
+ rec[i] = r
308
+ source = "hybrid"
309
+ t["rec_ms"] = 1000 * (time.perf_counter() - t0)
310
+ if getattr(self.ocr, "math", None) is not None:
311
+ t0 = time.perf_counter()
312
+ self._read_math(gray, boxes, rec)
313
+ t["math_ms"] = 1000 * (time.perf_counter() - t0)
314
+ keep = [i for i, r in enumerate(rec) if r is not None]
315
+ kept_boxes = boxes[keep].reshape(-1, boxes.shape[1])
316
+ kept_rec = [rec[i] for i in keep]
317
+ if len(side):
318
+ extra = np.zeros((len(side), boxes.shape[1]), np.float32)
319
+ extra[:, :4] = side_boxes
320
+ if boxes.shape[1] > 4:
321
+ extra[:, 4] = 1.0
322
+ kept_boxes = np.concatenate([kept_boxes, extra])
323
+ for r, b in zip(side, side_boxes):
324
+ cx = float(b[0] + b[2]) / 2 # one x: a sideways line is never cut
325
+ kept_rec.append((r.text, 1.0, [(c, cx, r.seq) for c in r.text]))
326
+ out = self.ocr.assemble(
327
+ (gray.shape[1], gray.shape[0]),
328
+ kept_boxes,
329
+ kept_rec,
330
+ t,
331
+ vertical=vertical,
332
+ )
333
+ out.source = source
334
+ out.rotation = 90 * turn
335
+ if vertical: # back on the page as it is: (x, y) -> (W - y, x)
336
+ W = gray.shape[0]
337
+
338
+ def back(b):
339
+ return [W - b[3], b[0], W - b[1], b[2]]
340
+
341
+ for ln in out.lines:
342
+ ln.box = back(ln.box)
343
+ for b in out.blocks:
344
+ b["bbox"] = back(b["bbox"])
345
+ out.vertical = True
346
+ out.note += f"; {lost} chars outside detected lines" if lost else ""
347
+ return out
348
+
349
+ def _read_math(self, gray, boxes, rec: list) -> None:
350
+ """Text-layer lines that hold math (the classifier, on their crops) are read
351
+ by MathRec instead: the layer has the symbols but not the LaTeX. The line
352
+ keeps its place in the PDF content order (its first character's index)."""
353
+ from .line_mask import crop_lines
354
+
355
+ idx = [
356
+ i
357
+ for i, r in enumerate(rec)
358
+ if r is not None
359
+ and r[2]
360
+ and isinstance(r[2][0][0], str)
361
+ and len(r[2][0]) > 2
362
+ ]
363
+ if not idx:
364
+ return
365
+ crops = crop_lines(gray, boxes[idx, :4], self.ocr.height, None)
366
+ math = [i for i, f in zip(idx, self.ocr.math.flags(crops)) if f]
367
+ if not math:
368
+ return
369
+ from .math import MATH_MIN_CONF
370
+
371
+ for i, (text, conf) in zip(math, self.ocr.math.read(gray, boxes[math])):
372
+ # A reading without math, or not confident, leaves the layer's text.
373
+ if "$" in text and conf >= MATH_MIN_CONF:
374
+ first = min(tk[2] for tk in rec[i][2])
375
+ xc = float(boxes[i, 0] + boxes[i, 2]) / 2
376
+ rec[i] = (text, 1.0, [(text, xc, first)])
377
+
378
+ def convert(
379
+ self, path: str | Path, max_pages: int = 0, password: str | None = None
380
+ ):
381
+ """Yield (page index, Page) for a PDF or an image file (the file's bytes
382
+ decide which). A page that fails yields an empty Page whose ``source``
383
+ is "error" and whose ``note`` says why; a file that cannot be opened
384
+ raises InputError. max_pages: 0 reads every page; a negative value
385
+ raises ValueError."""
386
+ if max_pages < 0:
387
+ raise ValueError(
388
+ f"max_pages must be 0 (all pages) or more, not {max_pages}"
389
+ )
390
+ path = Path(path)
391
+ if not path.is_file():
392
+ raise InputError(f"{path}: no such file")
393
+ if not is_pdf(path):
394
+ for i, gray in enumerate(read_images(path)[: max_pages or None]):
395
+ yield i, self._safe(lambda: self.ocr(gray))
396
+ return
397
+ import pypdfium2 as pdfium
398
+
399
+ try:
400
+ doc = pdfium.PdfDocument(str(path), password=password)
401
+ except pdfium.PdfiumError as e:
402
+ locked = "password" in str(e).lower()
403
+ raise InputError(
404
+ f"{path}: password-protected PDF (pass password=, or --password)"
405
+ if locked
406
+ else f"{path}: cannot open the PDF ({e})"
407
+ ) from e
408
+ try:
409
+ n = len(doc) if not max_pages else min(len(doc), max_pages)
410
+ for i in range(n):
411
+ yield i, self._safe(lambda: self.pdf_page(str(path), doc, i))
412
+ finally:
413
+ doc.close()
414
+
415
+ @staticmethod
416
+ def _safe(read) -> Page:
417
+ try:
418
+ return read()
419
+ except Exception as e: # noqa: BLE001 (one bad page must not end the document)
420
+ return Page(
421
+ [],
422
+ {"total_ms": 0.0},
423
+ markdown="",
424
+ source="error",
425
+ note=f"{type(e).__name__}: {e}",
426
+ )
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/layout_post.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Page text from the ordered lines and the OrderNet layout heads (torch-free).
2
+
3
+ OrderNet with ``layout`` gives every line a block class and every pair of lines
4
+ a "same block" logit. Walking the reading order, a new block starts wherever
5
+ the same-block logit between consecutive lines is negative. Each block is then
6
+ rendered by its class:
7
+
8
+ Section-Header "## " + joined text
9
+ Text, Caption, ... one paragraph; a word split by a hyphen at a line end is joined
10
+ List-Group one line per item (a new item starts at a bullet or number)
11
+ Table an HTML table assembled from the cell lines (rows by vertical
12
+ centre, columns anchored on the fullest rows)
13
+ Page-Header / Footer left out unless ``keep_furniture``
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html
19
+ import re
20
+
21
+ import numpy as np
22
+
23
+ from .order_post import BLOCK_CLASSES, _cluster
24
+
25
+ HEADER, FOOTER = BLOCK_CLASSES.index("Page-Header"), BLOCK_CLASSES.index("Page-Footer")
26
+ SECTION = BLOCK_CLASSES.index("Section-Header")
27
+ LIST = BLOCK_CLASSES.index("List-Group")
28
+ TABLE = BLOCK_CLASSES.index("Table")
29
+
30
+ _WHOLE_MATH = re.compile(r"^\$[^$]+\$$") # a line that is one formula
31
+ # A list marker, followed by text or alone on its line (the detector often cuts
32
+ # a bullet off as a line of its own; the next line then joins its item).
33
+ _ITEM = re.compile(r"^\s*([•\-–▪◦*·→]|\(?\d{1,3}[.)]|\(?[a-zA-Z][.)])(\s|$)")
34
+
35
+
36
+ def _cjk(c: str) -> bool:
37
+ """Han, kana, CJK punctuation and fullwidth forms: written without spaces
38
+ (Korean Hangul is not: Korean separates words with spaces)."""
39
+ o = ord(c)
40
+ return (
41
+ 0x3000 <= o <= 0x30FF
42
+ or 0x3400 <= o <= 0x9FFF
43
+ or 0xF900 <= o <= 0xFAFF
44
+ or 0xFF00 <= o <= 0xFFEF
45
+ or 0x20000 <= o <= 0x2FA1F
46
+ )
47
+
48
+
49
+ def join_lines(texts: list[str]) -> str:
50
+ """Join the lines of one paragraph. 'tumori-' + 'genesis' -> 'tumorigenesis';
51
+ a line break between two CJK characters takes no space."""
52
+ out = ""
53
+ for t in texts:
54
+ t = t.strip()
55
+ if not t:
56
+ continue
57
+ if (
58
+ len(out) > 1
59
+ and out.endswith("-")
60
+ and out[-2].isalpha()
61
+ and t[0].isalpha()
62
+ and t[0].islower()
63
+ ):
64
+ out = out[:-1] + t
65
+ elif out and _cjk(out[-1]) and _cjk(t[0]):
66
+ out += t
67
+ else:
68
+ out = f"{out} {t}" if out else t
69
+ return out
70
+
71
+
72
+ def split_blocks(order: list[int], same: np.ndarray) -> list[list[int]]:
73
+ """Runs of the reading order with a positive same-block logit between neighbours."""
74
+ blocks: list[list[int]] = []
75
+ for i in order:
76
+ if blocks and same[blocks[-1][-1], i] > 0:
77
+ blocks[-1].append(i)
78
+ else:
79
+ blocks.append([i])
80
+ return blocks
81
+
82
+
83
+ def _rows(boxes: np.ndarray) -> list[list[int]]:
84
+ """Group cell lines into rows by vertical centre, top to bottom, left to right."""
85
+ cy = (boxes[:, 1] + boxes[:, 3]) / 2
86
+ h = float(np.median(boxes[:, 3] - boxes[:, 1]))
87
+ rows: list[list[int]] = []
88
+ for i in np.argsort(cy):
89
+ if rows and abs(cy[i] - np.mean(cy[rows[-1]])) < 0.5 * h:
90
+ rows[-1].append(int(i))
91
+ else:
92
+ rows.append([int(i)])
93
+ return [sorted(r, key=lambda k: boxes[k, 0]) for r in rows]
94
+
95
+
96
+ def _merge_wrapped(rows: list[list[int]], boxes: np.ndarray) -> list[list[int]]:
97
+ """Join the line rows of wrapped cells into one table row.
98
+
99
+ Inside a wrapped cell the gap to the next line is the font's line gap; a new
100
+ table row adds cell padding. A row joins the row above when its gap is under
101
+ half the table's median row gap and under 0.6 line heights, and it has fewer
102
+ lines than the row above (a continuation fills only the wrapped columns)."""
103
+ if len(rows) < 3:
104
+ return rows
105
+ h = float(np.median(boxes[:, 3] - boxes[:, 1]))
106
+ gaps = [
107
+ float(boxes[nxt, 1].min() - boxes[cur, 3].max())
108
+ for cur, nxt in zip(rows[:-1], rows[1:])
109
+ ]
110
+ med = float(np.median(gaps))
111
+ out = [list(rows[0])]
112
+ prev = rows[0]
113
+ for g, r in zip(gaps, rows[1:]):
114
+ # A continuation fills only the wrapped columns: fewer lines than the row above.
115
+ if g < 0.5 * med and g < 0.6 * h and len(r) < len(prev):
116
+ out[-1] += r
117
+ else:
118
+ out.append(list(r))
119
+ prev = r
120
+ return out
121
+
122
+
123
+ def table_html(boxes: np.ndarray, texts: list[str]) -> str:
124
+ """An HTML table from cell lines (one text line per cell, as born-digital PDFs give).
125
+
126
+ A first row that is one line across most of the table is a title: it goes
127
+ before the table as text. The first remaining row is the header (<th>)."""
128
+ rows = _rows(boxes)
129
+ span_x = boxes[:, 2].max() - boxes[:, 0].min()
130
+ if (
131
+ len(rows) > 2
132
+ and len(rows[0]) == 1
133
+ and max(len(r) for r in rows) > 1
134
+ and boxes[rows[0][0], 2] - boxes[rows[0][0], 0] > 0.6 * span_x
135
+ ):
136
+ keep = [k for k in range(len(texts)) if k != rows[0][0]]
137
+ return (
138
+ texts[rows[0][0]]
139
+ + "\n\n"
140
+ + table_html(boxes[keep], [texts[k] for k in keep])
141
+ )
142
+ width = max(len(r) for r in rows)
143
+ full = [r for r in rows if len(r) == width]
144
+ rows = _merge_wrapped(rows, boxes)
145
+ # Column bands from the rows with the most cells: those rows rarely span.
146
+ bands = np.array(
147
+ [
148
+ [
149
+ np.median([boxes[r[c], 0] for r in full]),
150
+ np.median([boxes[r[c], 2] for r in full]),
151
+ ]
152
+ for c in range(width)
153
+ ]
154
+ )
155
+ # Widen each band to the midpoints between neighbours, so every x falls in one band.
156
+ edges = (
157
+ [-np.inf]
158
+ + [(bands[c, 1] + bands[c + 1, 0]) / 2 for c in range(width - 1)]
159
+ + [np.inf]
160
+ )
161
+ out = ["<table>"]
162
+ for ri, r in enumerate(rows):
163
+ tag = "th" if ri == 0 and len(rows) > 1 else "td"
164
+ cells: dict[int, tuple[int, str]] = {}
165
+ for k in sorted(r, key=lambda k: (boxes[k, 1], boxes[k, 0])):
166
+ x0, x1 = boxes[k, 0], boxes[k, 2]
167
+ first = int(np.searchsorted(edges, x0, side="right") - 1)
168
+ last = int(np.searchsorted(edges, x1, side="right") - 1)
169
+ # A cell spans every band it covers by more than half of the band.
170
+ span = [
171
+ c
172
+ for c in range(max(first, 0), min(last, width - 1) + 1)
173
+ if min(x1, bands[c, 1]) - max(x0, bands[c, 0])
174
+ > 0.5 * (bands[c, 1] - bands[c, 0])
175
+ ] or [min(max(first, 0), width - 1)]
176
+ c0 = span[0]
177
+ prev = cells.get(c0)
178
+ text = texts[k] if prev is None else prev[1] + " " + texts[k]
179
+ cells[c0] = (len(span) if prev is None else max(prev[0], len(span)), text)
180
+ tds, c = [], 0
181
+ while c < width:
182
+ if c in cells:
183
+ n, text = cells[c]
184
+ attr = f' colspan="{n}"' if n > 1 else ""
185
+ tds.append(f"<{tag}{attr}>{html.escape(text, quote=False)}</{tag}>")
186
+ c += n
187
+ else:
188
+ tds.append(f"<{tag}></{tag}>")
189
+ c += 1
190
+ out.append("<tr>" + "".join(tds) + "</tr>")
191
+ out.append("</table>")
192
+ return "\n".join(out)
193
+
194
+
195
+ def heads_plausible(boxes: np.ndarray, tab: np.ndarray) -> bool:
196
+ """The lines of one cell are stacked (they overlap horizontally). When the
197
+ cell head joins side-by-side lines in more than 10% of its cells, it has
198
+ failed on this table and the geometric assembly is used instead."""
199
+ cells = _cluster(tab[2])
200
+ bad = 0
201
+ for c in cells:
202
+ if len(c) < 2:
203
+ continue
204
+ x0, x1 = boxes[c, 0], boxes[c, 2]
205
+ if (np.minimum.outer(x1, x1) - np.maximum.outer(x0, x0)).min() < 0:
206
+ bad += 1
207
+ return bad <= 0.1 * len(cells)
208
+
209
+
210
+ def table_html_heads(boxes: np.ndarray, texts: list[str], tab: np.ndarray) -> str:
211
+ """An HTML table from the OrderNet table heads. tab [3, N, N] = logits
212
+ same row, same column, same cell, for the N lines of one table block."""
213
+ cells = _cluster(tab[2])
214
+ cb = np.array(
215
+ [
216
+ [boxes[c, 0].min(), boxes[c, 1].min(), boxes[c, 2].max(), boxes[c, 3].max()]
217
+ for c in cells
218
+ ]
219
+ )
220
+ ctext = [
221
+ " ".join(texts[k] for k in sorted(c, key=lambda k: (boxes[k, 1], boxes[k, 0])))
222
+ for c in cells
223
+ ]
224
+ m = len(cells)
225
+
226
+ def cell_logits(k: int) -> np.ndarray:
227
+ out = np.zeros((m, m), np.float32)
228
+ for a in range(m):
229
+ for b in range(a + 1, m):
230
+ out[a, b] = out[b, a] = tab[k][np.ix_(cells[a], cells[b])].mean()
231
+ return out
232
+
233
+ rows = sorted(_cluster(cell_logits(0)), key=lambda g: cb[g, 1].min())
234
+ cols = sorted(_cluster(cell_logits(1)), key=lambda g: cb[g, 0].min())
235
+ row_of = {c: r for r, g in enumerate(rows) for c in g}
236
+ col_of = {c: k for k, g in enumerate(cols) for c in g}
237
+ centers = [float(np.median((cb[g, 0] + cb[g, 2]) / 2)) for g in cols]
238
+ h = float(np.median(boxes[:, 3] - boxes[:, 1]))
239
+ grid: dict[tuple[int, int], tuple[int, str]] = {}
240
+ for c in range(m):
241
+ c0 = col_of[c]
242
+ span = 1
243
+ while (
244
+ c0 + span < len(cols) and cb[c, 0] - h <= centers[c0 + span] <= cb[c, 2] + h
245
+ ):
246
+ span += 1
247
+ key = (row_of[c], c0)
248
+ if key in grid: # two cells on one slot: keep both texts
249
+ grid[key] = (max(grid[key][0], span), grid[key][1] + " " + ctext[c])
250
+ else:
251
+ grid[key] = (span, ctext[c])
252
+ out = ["<table>"]
253
+ for r in range(len(rows)):
254
+ tag = "th" if r == 0 and len(rows) > 1 else "td"
255
+ tds, c = [], 0
256
+ while c < len(cols):
257
+ if (r, c) in grid:
258
+ span, text = grid[(r, c)]
259
+ attr = f' colspan="{span}"' if span > 1 else ""
260
+ tds.append(f"<{tag}{attr}>{html.escape(text, quote=False)}</{tag}>")
261
+ c += span
262
+ else:
263
+ tds.append(f"<{tag}></{tag}>")
264
+ c += 1
265
+ out.append("<tr>" + "".join(tds) + "</tr>")
266
+ out.append("</table>")
267
+ return "\n".join(out)
268
+
269
+
270
+ def page_blocks(
271
+ boxes: np.ndarray,
272
+ texts: list[str],
273
+ order: list[int],
274
+ cls_logits: np.ndarray,
275
+ same: np.ndarray,
276
+ ) -> list[dict]:
277
+ """The layout blocks behind ``page_markdown``, in reading order:
278
+ {"label" (a BLOCK_CLASSES name), "bbox" (pixels, the union of its lines),
279
+ "text" (its lines, newline-joined)}."""
280
+ out = []
281
+ for blk in split_blocks(order, same):
282
+ b = boxes[blk, :4]
283
+ out.append(
284
+ {
285
+ "label": BLOCK_CLASSES[int(cls_logits[blk].sum(0).argmax())],
286
+ "bbox": [
287
+ round(float(b[:, 0].min()), 1),
288
+ round(float(b[:, 1].min()), 1),
289
+ round(float(b[:, 2].max()), 1),
290
+ round(float(b[:, 3].max()), 1),
291
+ ],
292
+ "text": "\n".join(texts[i] for i in blk),
293
+ }
294
+ )
295
+ return out
296
+
297
+
298
+ def page_markdown(
299
+ boxes: np.ndarray,
300
+ texts: list[str],
301
+ order: list[int],
302
+ cls_logits: np.ndarray,
303
+ same: np.ndarray,
304
+ keep_furniture: bool = False,
305
+ cells: dict | None = None,
306
+ tab: np.ndarray | None = None,
307
+ pictures: np.ndarray | None = None,
308
+ ) -> str:
309
+ """Markdown for one page. boxes [N, 4], texts [N], order = reading order,
310
+ cls_logits [N, C], same [N, N] (logit "same block"). ``cells`` maps a line
311
+ to its table cells [(box, text)] when the line holds several. ``tab``
312
+ [3, N, N] (same row / column / cell logits) builds tables from the heads.
313
+ ``pictures`` [P, 4]: picture regions, each written as ``![](picture-<k>)``
314
+ before the first block below it that it overlaps horizontally."""
315
+ parts: list[str] = []
316
+ part_boxes: list = []
317
+ for blk in split_blocks(order, same):
318
+ n_parts = len(parts)
319
+ _block_part(parts, blk, boxes, texts, cls_logits, keep_furniture, cells, tab)
320
+ if len(parts) > n_parts:
321
+ b = boxes[blk, :4]
322
+ part_boxes.append(
323
+ (b[:, 0].min(), b[:, 1].min(), b[:, 2].max(), b[:, 3].max())
324
+ )
325
+ if pictures is not None and len(pictures):
326
+ slots: dict[int, list[str]] = {}
327
+ for k, (x0, y0, x1, y1) in enumerate(np.asarray(pictures)[:, :4]):
328
+ cy = (y0 + y1) / 2
329
+ at = next(
330
+ (
331
+ j
332
+ for j, (bx0, by0, bx1, by1) in enumerate(part_boxes)
333
+ if (by0 + by1) / 2 > cy and min(x1, bx1) > max(x0, bx0)
334
+ ),
335
+ len(parts),
336
+ )
337
+ slots.setdefault(at, []).append(f"![](picture-{k})")
338
+ merged = []
339
+ for j in range(len(parts) + 1):
340
+ merged.extend(slots.get(j, []))
341
+ if j < len(parts):
342
+ merged.append(parts[j])
343
+ parts = merged
344
+ return "\n\n".join(p for p in parts if p)
345
+
346
+
347
+ def _block_part(parts, blk, boxes, texts, cls_logits, keep_furniture, cells, tab):
348
+ """Append one block's markdown to ``parts`` (nothing for dropped furniture)."""
349
+ cls = int(cls_logits[blk].sum(0).argmax())
350
+ texts_b = [texts[i] for i in blk]
351
+ if cls in (HEADER, FOOTER) and not keep_furniture:
352
+ return
353
+ if all(_WHOLE_MATH.match(t.strip()) for t in texts_b): # display equations
354
+ parts.append("\n".join("$$" + t.strip()[1:-1] + "$$" for t in texts_b))
355
+ return
356
+ if cls == SECTION:
357
+ parts.append("## " + join_lines(texts_b))
358
+ elif (
359
+ cls == TABLE
360
+ and len(blk) > 1
361
+ and tab is not None
362
+ and heads_plausible(boxes[blk, :4], tab[:, blk][:, :, blk])
363
+ ):
364
+ parts.append(table_html_heads(boxes[blk, :4], texts_b, tab[:, blk][:, :, blk]))
365
+ elif cls == TABLE and len(blk) > 1:
366
+ cb, ct = [], []
367
+ for i in blk:
368
+ for bx, tx in (cells or {}).get(i) or [(boxes[i, :4], texts[i])]:
369
+ cb.append(bx)
370
+ ct.append(tx)
371
+ parts.append(table_html(np.array(cb, np.float32).reshape(-1, 4), ct))
372
+ elif cls == LIST:
373
+ items: list[list[str]] = []
374
+ for t in texts_b:
375
+ if not items or _ITEM.match(t):
376
+ items.append([t])
377
+ else:
378
+ items[-1].append(t)
379
+ parts.append("\n".join(join_lines(it) for it in items))
380
+ else:
381
+ parts.append(join_lines(texts_b))
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/line_mask.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mask a line window with CenterMask before the recognizer crops the line.
2
+
3
+ One function for training and inference: the recognizer must only ever see
4
+ crops that went through the same exported mask model (mask.onnx).
5
+
6
+ sess = load_mask("mask.onnx")
7
+ win, box_in_win = masked_window(gray, box, sess)
8
+ img = crop_line(win, box_in_win, height)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ WINDOW_H = 96 # window height in pixels; the centre line is about a third of it
16
+ WINDOW_ABOVE = 1.0 # box heights above and below the line box
17
+ WINDOW_SIDE = 0.5 # box heights left and right
18
+
19
+
20
+ def window_box(box, w_page: float, h_page: float) -> tuple[float, float, float, float]:
21
+ """The window around a line box (page pixels), clipped to the page."""
22
+ x0, y0, x1, y1 = (float(v) for v in box[:4])
23
+ h = max(1.0, y1 - y0)
24
+ return (
25
+ max(0.0, x0 - WINDOW_SIDE * h),
26
+ max(0.0, y0 - WINDOW_ABOVE * h),
27
+ min(w_page, x1 + WINDOW_SIDE * h),
28
+ min(h_page, y1 + WINDOW_ABOVE * h),
29
+ )
30
+
31
+
32
+ KEEP = 0.5 # pixels with a lower centre-line probability become paper
33
+
34
+
35
+ def resize_window(a: np.ndarray, size: tuple[int, int]) -> np.ndarray:
36
+ """Scale a window to ``size`` (w, h): linear down to half scale, area below
37
+ (anti-aliasing). INTER_AREA at a non-integer scale costs 20x more."""
38
+ import cv2
39
+
40
+ shrink = size[1] / max(1, a.shape[0])
41
+ interp = cv2.INTER_LINEAR if shrink >= 0.5 else cv2.INTER_AREA
42
+ return cv2.resize(a, size, interpolation=interp)
43
+
44
+
45
+ def load_mask(path: str, threads: int = 1):
46
+ import onnxruntime as ort
47
+
48
+ so = ort.SessionOptions()
49
+ so.intra_op_num_threads = threads
50
+ so.inter_op_num_threads = 1
51
+ return ort.InferenceSession(str(path), so, providers=["CPUExecutionProvider"])
52
+
53
+
54
+ def _window(gray: np.ndarray, box) -> tuple[np.ndarray, tuple[float, float]]:
55
+ H, W = gray.shape
56
+ x0, y0, x1, y1 = (int(round(v)) for v in window_box(box, W, H))
57
+ return gray[y0:y1, x0:x1], (x0, y0)
58
+
59
+
60
+ def mask_windows(sess, wins: list[np.ndarray]) -> list[np.ndarray]:
61
+ """Centre-line keep masks (bool, window resolution) for grayscale windows.
62
+
63
+ The probabilities are scaled back bilinearly before the threshold: a
64
+ threshold at mask resolution with a nearest-neighbour upscale breaks the
65
+ stroke edges (surya OCR 2 CER on M804 lines 0.30 -> 0.36)."""
66
+ import cv2
67
+
68
+ if not wins:
69
+ return []
70
+ xs, sizes = [], []
71
+ for w in wins:
72
+ h, wd = w.shape
73
+ W = max(32, int(round(wd * WINDOW_H / max(h, 1) / 8)) * 8)
74
+ ink = 1.0 - resize_window(w, (W, WINDOW_H)).astype(np.float32) / 255.0
75
+ xs.append(ink)
76
+ sizes.append((wd, h))
77
+ out: list = [None] * len(wins)
78
+ order = np.argsort([x.shape[1] for x in xs])
79
+ for k in range(0, len(order), 16): # similar widths together: less padding
80
+ idx = order[k : k + 16]
81
+ Wm = max(xs[i].shape[1] for i in idx)
82
+ batch = np.zeros((len(idx), 1, WINDOW_H, Wm), np.float32)
83
+ for j, i in enumerate(idx):
84
+ batch[j, 0, :, : xs[i].shape[1]] = xs[i]
85
+ p = sess.run(None, {"x": batch})[0]
86
+ for j, i in enumerate(idx):
87
+ pm = p[j, 0, :, : xs[i].shape[1]]
88
+ out[i] = cv2.resize(pm, sizes[i], interpolation=cv2.INTER_LINEAR) >= KEEP
89
+ return out
90
+
91
+
92
+ def masked_windows(
93
+ gray: np.ndarray, boxes, sess
94
+ ) -> list[tuple[np.ndarray, list[float]]]:
95
+ """For each line box: (window with foreign ink whitened, the box inside it)."""
96
+ wins, offs = zip(*[_window(gray, b) for b in boxes]) if len(boxes) else ((), ())
97
+ out = []
98
+ for w, (ox, oy), keep, b in zip(wins, offs, mask_windows(sess, list(wins)), boxes):
99
+ m = w.copy()
100
+ # paper level: 90th percentile grey on a 1/16 subsample of the window
101
+ m[~keep] = np.uint8(np.percentile(w[::4, ::4], 90)) if w.size else 255
102
+ out.append((m, [b[0] - ox, b[1] - oy, b[2] - ox, b[3] - oy]))
103
+ return out
104
+
105
+
106
+ def masked_window(gray: np.ndarray, box, sess) -> tuple[np.ndarray, list[float]]:
107
+ return masked_windows(gray, [box], sess)[0]
108
+
109
+
110
+ def crop_lines(
111
+ gray: np.ndarray,
112
+ boxes,
113
+ height: int,
114
+ sess=None,
115
+ window: int = 1,
116
+ pool=None,
117
+ dark: list | None = None,
118
+ ) -> list[np.ndarray]:
119
+ """Recognizer inputs for the line boxes of one page: ``crop_line`` of each
120
+ line, after CenterMask when ``sess`` is given (one batched call per page).
121
+ A tall window reads the page around the line, so it is never masked.
122
+ pool: a thread pool for the crops; dark: ``line_darkness`` of the boxes,
123
+ when already known (not used with CenterMask)."""
124
+ from .rec_preproc import crop_line
125
+
126
+ run = pool.map if pool is not None else map
127
+ if sess is None or window > 1:
128
+ d = dark if dark is not None else [None] * len(boxes)
129
+ return list(
130
+ run(lambda bd: crop_line(gray, bd[0], height, window=window, dark=bd[1]),
131
+ list(zip(boxes, d)))
132
+ )
133
+ return [crop_line(w, b, height) for w, b in masked_windows(gray, list(boxes), sess)]
134
+
135
+
136
+ def line_darkness(gray: np.ndarray, boxes, pool=None) -> list[bool]:
137
+ """``is_dark_line`` of each line box (light text on a dark ground)."""
138
+ from .rec_preproc import is_dark_line
139
+
140
+ run = pool.map if pool is not None else map
141
+ return list(run(lambda b: is_dark_line(gray, b), list(boxes)))
142
+
143
+
144
+ _SESSIONS: dict = {}
145
+
146
+
147
+ def session_for(path: str | None):
148
+ """One CPU session per process (dataloader workers call this lazily)."""
149
+ if not path:
150
+ return None
151
+ if path not in _SESSIONS:
152
+ _SESSIONS[path] = load_mask(path, threads=1)
153
+ return _SESSIONS[path]
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/math.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Math lines: a classifier finds lines that hold math, and MathRec reads them as
2
+ text with inline LaTeX ("we have $x _ { i } \\leq 1$").
3
+
4
+ The CTC recognizer reads one symbol per column, so it cannot write a fraction or
5
+ a sum with limits. MathRec reads a taller crop of the line with an attention
6
+ decoder, one token per step, with the decoder's keys and values cached.
7
+
8
+ Models (all optional; the pipeline skips math without them): mathcls.onnx,
9
+ math_enc.onnx, math_dec_init.onnx, math_dec_step.onnx and math_meta.json.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ from .line_mask import crop_lines
20
+ from .rec_preproc import pad_batch, width_batches
21
+
22
+ MATH_MIN = 0.5 # classifier probability above which a line is read as math
23
+ # A math reading replaces a line only at this mean token probability or more: on
24
+ # print the model has not seen (old scans) it writes confident-looking LaTeX
25
+ # that is wrong, at low probability.
26
+ MATH_MIN_CONF = 0.9
27
+ # A line whose mean token probability is this far below MATH_MIN_CONF after 8
28
+ # tokens stops decoding: it would be rejected anyway (garbage on unseen print).
29
+ EARLY_STOP_MARGIN = 0.1
30
+ BOS, EOS, N_SPECIAL = 1, 2, 4
31
+ FILES = ("mathcls.onnx", "math_enc.onnx", "math_dec_init.onnx", "math_dec_step.onnx")
32
+
33
+
34
+ def detokenize(tokens: list[str]) -> str:
35
+ """Tokens -> text; inside math (between "$" tokens) they are joined by spaces."""
36
+ out, math, in_math = [], [], False
37
+ for t in tokens:
38
+ if t == "$":
39
+ if in_math:
40
+ out.append("$" + " ".join(math) + "$")
41
+ math = []
42
+ in_math = not in_math
43
+ elif in_math:
44
+ math.append(t)
45
+ else:
46
+ out.append(t)
47
+ if in_math and math:
48
+ out.append("$" + " ".join(math) + "$")
49
+ return " ".join("".join(out).split())
50
+
51
+
52
+ class MathReader:
53
+ def __init__(self, onnx_dir: Path, so, providers):
54
+ import onnxruntime as ort
55
+
56
+ s = {
57
+ f: ort.InferenceSession(str(onnx_dir / f), so, providers=providers)
58
+ for f in FILES
59
+ }
60
+ self.cls, self.enc = s["mathcls.onnx"], s["math_enc.onnx"]
61
+ self.init, self.step = s["math_dec_init.onnx"], s["math_dec_step.onnx"]
62
+ meta = json.loads((onnx_dir / "math_meta.json").read_text())
63
+ cfg = meta["config"]
64
+ self.height = cfg["height"]
65
+ self.max_tokens = cfg["max_tokens"]
66
+ self.layers, self.heads = cfg["dec_layers"], cfg["dec_heads"]
67
+ self.head_dim = cfg["channels"] // cfg["dec_heads"]
68
+ self.itos = ["", "", "", ""] + meta["tokens"]
69
+ # Exports drop unused inputs (the init graph does not read the mask).
70
+ self.inputs = {f: {i.name for i in v.get_inputs()} for f, v in s.items()}
71
+
72
+ @classmethod
73
+ def load(cls, onnx_dir: Path, so, providers) -> "MathReader | None":
74
+ names = (*FILES, "math_meta.json")
75
+ if all((onnx_dir / f).exists() for f in names):
76
+ return cls(onnx_dir, so, providers)
77
+ return None
78
+
79
+ def flags(self, crops: list, batch: int = 64) -> list[bool]:
80
+ """Per line crop (the recognizer's crop): does it hold math?"""
81
+ out = [False] * len(crops)
82
+ for idx in width_batches([c.shape[1] for c in crops], batch):
83
+ arr, widths = pad_batch([crops[j] for j in idx])
84
+ p = self.cls.run(None, {"x": arr, "widths": widths})[0]
85
+ for k, j in enumerate(idx):
86
+ out[j] = bool(p[k] > MATH_MIN)
87
+ return out
88
+
89
+ def read(
90
+ self, gray: np.ndarray, boxes: np.ndarray, batch: int = 64
91
+ ) -> list[tuple[str, float]]:
92
+ """(text with inline LaTeX, mean token probability) for each box."""
93
+ crops = crop_lines(gray, boxes[:, :4], self.height, None)
94
+ out = [("", 0.0)] * len(crops)
95
+ # Few, wide batches: each batch is one decoding loop of up to max_tokens
96
+ # steps, and a step costs about the same for 8 lines as for 40.
97
+ widths = [c.shape[1] for c in crops]
98
+ for idx in width_batches(widths, batch, max_ratio=4.0, max_area=16 * 64 * 640):
99
+ arr, widths = pad_batch([crops[j] for j in idx])
100
+ mem, mask = self.enc.run(None, {"x": arr, "widths": widths})
101
+ feed = {"memory": mem, "mask": mask}
102
+ names = self.inputs["math_dec_init.onnx"]
103
+ ck, cv = self.init.run(None, {k: v for k, v in feed.items() if k in names})
104
+ B = len(idx)
105
+ sk = np.zeros((self.layers, B, self.heads, 0, self.head_dim), np.float32)
106
+ sv = sk
107
+ tok = np.full(B, BOS, np.int64)
108
+ step_in = self.inputs["math_dec_step.onnx"]
109
+ seqs: list[list[int]] = [[] for _ in range(B)]
110
+ probs: list[list[float]] = [[] for _ in range(B)]
111
+ done = np.zeros(B, bool)
112
+ for t in range(self.max_tokens - 1):
113
+ feed = {
114
+ "token": tok,
115
+ "pos": np.array(t, np.int64),
116
+ "self_k": sk,
117
+ "self_v": sv,
118
+ "cross_k": ck,
119
+ "cross_v": cv,
120
+ "mask": mask,
121
+ }
122
+ logits, sk, sv = self.step.run(
123
+ None, {k: v for k, v in feed.items() if k in step_in}
124
+ )
125
+ tok = logits.argmax(-1).astype(np.int64)
126
+ e = np.exp(logits - logits.max(-1, keepdims=True))
127
+ top = 1.0 / e.sum(-1) # softmax probability of the argmax token
128
+ for b in range(B):
129
+ if not done[b]:
130
+ probs[b].append(float(top[b]))
131
+ if tok[b] == EOS:
132
+ done[b] = True
133
+ elif (
134
+ len(probs[b]) >= 8
135
+ and np.mean(probs[b]) < MATH_MIN_CONF - EARLY_STOP_MARGIN
136
+ ):
137
+ done[b] = True # rejected: keeps a low mean probability
138
+ elif tok[b] >= N_SPECIAL:
139
+ seqs[b].append(int(tok[b]))
140
+ if done.all():
141
+ break
142
+ for k, j in enumerate(idx):
143
+ text = detokenize([self.itos[i] for i in seqs[k]])
144
+ out[j] = (text, float(np.mean(probs[k])) if probs[k] else 0.0)
145
+ return out
stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/models.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model files: a local folder, or a download from the Hugging Face repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ HF_REPO = "datalab-to/indra-ocr"
9
+ # Model set this package version was built and tested against (a tag in HF_REPO).
10
+ HF_REVISION = "v1.1.0"
11
+ FILES = ("det.onnx", "rec.onnx", "order.onnx", "meta.json")
12
+
13
+
14
+ class ModelError(OSError):
15
+ """The models are not available: a download failed (no network, no access)
16
+ or a local model folder lacks files."""
17
+
18
+
19
+ MATH_FILES = (
20
+ "mathcls.onnx",
21
+ "math_enc.onnx",
22
+ "math_dec_init.onnx",
23
+ "math_dec_step.onnx",
24
+ "math_meta.json",
25
+ )
26
+
27
+
28
+ def model_dir(
29
+ path: str | Path | None = None, revision: str | None = None, math: bool = False
30
+ ) -> Path:
31
+ """Folder with det.onnx, rec.onnx, meta.json and usually order.onnx (and,
32
+ with ``math``, the math models, which are downloaded only then). A local
33
+ folder without order.onnx works: reading order then falls back to
34
+ XY-cut, with no layout labels.
35
+
36
+ Order of lookup: ``path``, the INDRA_OCR_MODEL_DIR environment variable, then
37
+ a download of ``HF_REPO``. Downloads are cached by huggingface_hub."""
38
+ path = path or os.environ.get("INDRA_OCR_MODEL_DIR")
39
+ if path:
40
+ p = Path(path).expanduser()
41
+ missing = [f for f in FILES[:2] + FILES[3:] if not (p / f).exists()]
42
+ if math:
43
+ missing += [f for f in MATH_FILES if not (p / f).exists()]
44
+ if missing:
45
+ raise ModelError(f"the model folder {p} lacks {', '.join(missing)}")
46
+ return p
47
+ from huggingface_hub import snapshot_download
48
+
49
+ rev = revision or HF_REVISION
50
+ kw = dict(
51
+ revision=rev,
52
+ allow_patterns=[
53
+ "*.onnx",
54
+ "meta.json",
55
+ "lm.npz",
56
+ "lm_*.npz",
57
+ *(MATH_FILES if math else ()),
58
+ ],
59
+ ignore_patterns=[] if math else list(MATH_FILES),
60
+ )
61
+ try:
62
+ # A tag's files never change: a cached copy needs no network (and no
63
+ # progress bar on every run).
64
+ return Path(snapshot_download(HF_REPO, local_files_only=True, **kw))
65
+ except Exception: # noqa: BLE001 (not cached yet)
66
+ pass
67
+ try:
68
+ return Path(snapshot_download(HF_REPO, **kw))
69
+ except Exception as e: # noqa: BLE001 (network, access, disk: one clear message)
70
+ reason = (str(e).strip().splitlines() or [type(e).__name__])[0]
71
+ raise ModelError(
72
+ f"could not download the models from Hugging Face ({HF_REPO}, {rev}): "
73
+ f"{type(e).__name__}: {reason[:200]}. Check the network connection, or "
74
+ "point at a local model folder with --model-dir or INDRA_OCR_MODEL_DIR."
75
+ ) from e