Commit ·
4b917c4
0
Parent(s):
Bina 0.4 Rizeh: full 95k production pipeline
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +35 -0
- MANIFEST.json +653 -0
- README.md +111 -0
- examples/demo.png +0 -0
- requirements.txt +17 -0
- rizeh.py +64 -0
- stallion/bina04-assembly-audit-95k-20261104/graphic_blocks.py +51 -0
- stallion/bina04-assembly-audit-95k-20261104/production.py +8 -0
- stallion/bina04-block-fields-95k-20261103/assembly_fix.py +56 -0
- stallion/bina04-block-fields-95k-20261103/production.py +12 -0
- stallion/bina04-cell-rowcores-95k-20261103/candidate.py +41 -0
- stallion/bina04-cell-rowcores-95k-20261103/production.py +17 -0
- stallion/bina04-column-assembly-95k-20261103/column_order.py +39 -0
- stallion/bina04-column-assembly-95k-20261103/production.py +11 -0
- stallion/bina04-crop-bandseg-95k-20261102/bandseg.py +277 -0
- stallion/bina04-crop-bandseg-95k-20261102/consumer_runtime.py +51 -0
- stallion/bina04-crop-picture-cells-95k-20261102/CALIBRATION.json +612 -0
- stallion/bina04-crop-picture-cells-95k-20261102/candidate.py +62 -0
- stallion/bina04-crop-picture-cells-95k-20261102/cell_reader.py +95 -0
- stallion/bina04-crop-picture-cells-95k-20261102/junk_filter.py +54 -0
- stallion/bina04-crop-picture-cells-95k-20261102/pen-classifier.joblib +3 -0
- stallion/bina04-crop-picture-cells-95k-20261102/pen_classifier.py +22 -0
- stallion/bina04-detector-role-isolation-95k-20261103/production.py +21 -0
- stallion/bina04-document-handoff-95k-20261103/document.py +80 -0
- stallion/bina04-document-handoff-95k-20261103/numeric_repairs.py +106 -0
- stallion/bina04-document-handoff-95k-20261103/production.py +38 -0
- stallion/bina04-document-handoff-95k-20261103/region_reads.py +59 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/learned_backend.py +30 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/run/checkpoints/step-001000.pt +3 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/crop_line_guard.py +39 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/input_policy_v3.py +96 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/pipeline_v3.py +216 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/baseline/table_client.py +24 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/line_contract_reader.py +69 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/pipeline_crop_fix.py +76 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/snapshot/runtime/table_cells.py +111 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_builder.py +102 -0
- stallion/bina04-e2e-batch-95k-20261101/candidates/combined/versions/combined/row_pipeline.py +42 -0
- stallion/bina04-header-marks-95k-20261103/CALIBRATION.json +1 -0
- stallion/bina04-header-marks-95k-20261103/candidate.py +76 -0
- stallion/bina04-header-marks-95k-20261103/production.py +16 -0
- stallion/bina04-header-marks-95k-20261103/serializer.py +82 -0
- stallion/bina04-indra-pilot-20261027/models/det.onnx +3 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/__init__.py +49 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/det_post.py +155 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/document.py +426 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/layout_post.py +381 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/line_mask.py +153 -0
- stallion/bina04-indra-pilot-20261027/upstream/indra_ocr/math.py +145 -0
- 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 ````
|
| 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"")
|
| 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
|