crazylemonade commited on
Commit
0ad3f89
Β·
verified Β·
1 Parent(s): 4a1bc37

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +166 -0
  2. README.md +9 -0
  3. download_models.py +185 -0
  4. entrypoint.sh +40 -0
  5. main.py +1401 -0
  6. requirements.txt +54 -0
  7. validate.py +633 -0
Dockerfile ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # MinerU OCR Service β€” Hugging Face Docker Space (CPU / pipeline backend)
3
+ #
4
+ # Optimized for FREE tier: 2 vCPU Β· 16 GB RAM Β· 50 GB Disk Β· No GPU
5
+ #
6
+ # ── OCR ROUTING ARCHITECTURE ──────────────────────────────────────────────────
7
+ #
8
+ # FAST PATH (images: jpg/png/webp/bmp/heic/etc)
9
+ # rapidocr-onnxruntime β‰₯ 1.3.22
10
+ # - Pure ONNX inference β€” no PaddleOCR / paddlepaddle needed
11
+ # - Models bundled in the pip wheel (~50 MB); no first-use download
12
+ # - Target latency: 1–5 s on CPU
13
+ # Multi-pass: if RapidOCR confidence < 0.65 β†’ MinerU fallback automatically
14
+ #
15
+ # HEAVY PATH (PDFs, multi-page, forms with layout)
16
+ # MinerU (magic-pdf pipeline backend)
17
+ # - Layout detection (doclayout_yolo)
18
+ # - OCR (paddleocr2pytorch β€” PyTorch reimplementation bundled in wheel)
19
+ # - Markdown reconstruction
20
+ # - Target latency: 5–30 s on CPU
21
+ #
22
+ # ── ROOT CAUSE HISTORY ────────────────────────────────────────────────────────
23
+ #
24
+ # FAILURE 1: [full-cpu] is NOT a valid extra β†’ pip silently installs base only
25
+ # Fix: magic-pdf[full]==1.3.12
26
+ #
27
+ # FAILURE 2: opencv non-headless conflict
28
+ # Fix: Layer 4 force-reinstall of opencv-python-headless
29
+ #
30
+ # FAILURE 3: ch_PP-OCRv3_det_infer.pth not in HF repo (repo updated to v5)
31
+ # Fix: Layer 3.5 patches models_config.yml inside installed wheel:
32
+ # ch_PP-OCRv3_det β†’ ch_PP-OCRv5_det (all ch* langs)
33
+ # en_PP-OCRv3_det β†’ Multilingual_PP-OCRv3_det (en, latin)
34
+ # Arch safety: both replacement stems verified in arch_config.yaml
35
+ #
36
+ # ── System packages ────────────────────────────────────────────────────────────
37
+ # libgl1 β€” OpenCV needs libGL.so.1 for ALL image operations (not just GUI)
38
+ # libglib2.0-0 β€” GLib; required by OpenCV and many C extensions
39
+ # libgomp1 β€” OpenMP; required by ONNX Runtime and YOLO inference
40
+ # poppler-utils β€” pdfinfo/pdftoppm; used by MinerU PDF pre-processing
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ FROM python:3.10-slim
44
+
45
+ ENV PYTHONUNBUFFERED=1
46
+ ENV PYTHONDONTWRITEBYTECODE=1
47
+ ENV PORT=7860
48
+ ENV MINERU_DEVICE_MODE=cpu
49
+ ENV MINERU_BACKEND=pipeline
50
+
51
+ # ── System dependencies ────────────────────────────────────────────────────────
52
+ RUN apt-get update \
53
+ && apt-get install -y --no-install-recommends \
54
+ libgl1 \
55
+ libglib2.0-0 \
56
+ libgomp1 \
57
+ poppler-utils \
58
+ && rm -rf /var/lib/apt/lists/*
59
+
60
+ WORKDIR /app
61
+
62
+ # ── Layer 1: FastAPI + lightweight runtime deps ───────────────────────────────
63
+ # rapidocr-onnxruntime: ONNX-based fast OCR engine; models bundled in wheel.
64
+ # - requires onnxruntime (will be auto-resolved or overridden by magic-pdf deps)
65
+ # - requires numpy, pyclipper, shapely β€” all covered by magic-pdf[full]
66
+ # - ~50 MB wheel; zero first-use model download needed
67
+ # opencv-python-headless: placeholder; will be force-reinstalled in Layer 4
68
+ RUN pip install --no-cache-dir --timeout 300 \
69
+ "fastapi>=0.115.0" \
70
+ "uvicorn[standard]>=0.32.0" \
71
+ "python-multipart>=0.0.12" \
72
+ "Pillow>=10.0.0" \
73
+ "pillow-heif>=0.18.0" \
74
+ "huggingface_hub>=0.25.0" \
75
+ "opencv-python-headless>=4.8.0" \
76
+ "rapidocr-onnxruntime>=1.3.22" \
77
+ "python-docx>=1.1.0" \
78
+ "python-pptx>=0.6.23" \
79
+ "openpyxl>=3.1.0"
80
+
81
+ # ── Layer 2: CPU-only PyTorch β€” MUST precede magic-pdf ───────────────────────
82
+ # PyPI serves the CUDA-enabled torch wheel by default (~2.5 GB).
83
+ # Installing from the official CPU wheel index first causes pip to treat the
84
+ # already-installed CPU build as satisfying magic-pdf's torch requirement.
85
+ RUN pip install --no-cache-dir --timeout 600 \
86
+ --index-url https://download.pytorch.org/whl/cpu \
87
+ "torch>=2.2.2,!=2.5.0,!=2.5.1,<3" \
88
+ "torchvision>=0.15.2"
89
+
90
+ # ── Layer 3: magic-pdf with the CORRECT extras ────────────────────────────────
91
+ # [full] provides ultralytics, doclayout-yolo==0.0.2b1, rapid-table, shapely,
92
+ # pyclipper, omegaconf, matplotlib, ftfy, dill, PyYAML, openai, albumentations.
93
+ # doclayout-yolo==0.0.2b1 is ONLY on the myhloli index β€” not on PyPI.
94
+ # onnxruntime resolved automatically as transitive dep of rapid-table.
95
+ RUN pip install --no-cache-dir --timeout 600 \
96
+ --extra-index-url https://myhloli.github.io/wheels/ \
97
+ "magic-pdf[full]==1.3.12"
98
+
99
+ # ── Layer 3.5: Patch OCR model config ────────────────────────────────────────
100
+ # HF repo opendatalab/PDF-Extract-Kit-1.0 was updated to v5 det models.
101
+ # magic-pdf 1.3.12 models_config.yml still references v3 det files (absent).
102
+ # This patch runs at build time so download_models.py fetches correct files.
103
+ RUN python3 - <<'PYEOF'
104
+ import sys, yaml
105
+ from pathlib import Path
106
+ import magic_pdf
107
+
108
+ pkg = Path(magic_pdf.__file__).parent
109
+ cfg_path = pkg / 'model/sub_modules/ocr/paddleocr2pytorch/pytorchocr/utils/resources/models_config.yml'
110
+ arch_path = pkg / 'model/sub_modules/ocr/paddleocr2pytorch/pytorchocr/utils/resources/arch_config.yaml'
111
+
112
+ print(f"Patching: {cfg_path}")
113
+
114
+ with open(cfg_path) as f:
115
+ config = yaml.safe_load(f)
116
+ with open(arch_path) as f:
117
+ arch_text = f.read()
118
+
119
+ DET_MAP = {
120
+ 'ch_PP-OCRv3_det_infer.pth': 'ch_PP-OCRv5_det_infer.pth',
121
+ 'en_PP-OCRv3_det_infer.pth': 'Multilingual_PP-OCRv3_det_infer.pth',
122
+ }
123
+
124
+ patched = 0
125
+ for lang, files in config['lang'].items():
126
+ old = files.get('det', '')
127
+ if old in DET_MAP:
128
+ new = DET_MAP[old]
129
+ arch_key = new[:-4]
130
+ if (arch_key + ':') not in arch_text:
131
+ print(f"ERROR: arch key '{arch_key}' not found in arch_config.yaml", file=sys.stderr)
132
+ sys.exit(1)
133
+ files['det'] = new
134
+ print(f" [{lang}] det: {old} -> {new}")
135
+ patched += 1
136
+
137
+ with open(cfg_path, 'w') as f:
138
+ yaml.dump(config, f, default_flow_style=False, allow_unicode=True)
139
+
140
+ print(f"Patched {patched} language entries. models_config.yml updated.")
141
+ PYEOF
142
+
143
+ # ── Layer 4: Restore headless OpenCV ─────────────────────────────────────────
144
+ # Layer 3 pulled opencv-python (non-headless) via doclayout-yolo/ultralytics/
145
+ # rapid-table. Force-reinstall headless build so cv2 works on this slim image.
146
+ RUN pip install --no-cache-dir --timeout 300 \
147
+ --force-reinstall \
148
+ "opencv-python-headless>=4.8.0"
149
+
150
+ # ── Application code ──────────────────────────────────────────────────────────
151
+ COPY download_models.py .
152
+ COPY validate.py .
153
+ COPY main.py .
154
+ COPY entrypoint.sh .
155
+ RUN chmod +x entrypoint.sh
156
+
157
+ # ── Download models at build time ─────────────────────────────────────────────
158
+ # MFR (formula recognition, ~1-2 GB) excluded β€” disabled in config.
159
+ # rapidocr-onnxruntime models are BUNDLED in the pip wheel; no download needed.
160
+ RUN python download_models.py
161
+
162
+ RUN mkdir -p /app/config && cp /root/magic-pdf.json /app/config/magic-pdf.json
163
+
164
+ # ── Runtime ───────────────────────────────────────────────────────────────────
165
+ EXPOSE 7860
166
+ ENTRYPOINT ["/app/entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OpenSkill OCR
3
+ emoji: πŸ“„
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
download_models.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download MinerU pipeline models from Hugging Face Hub.
3
+
4
+ Called once during Docker build: python download_models.py
5
+
6
+ Optimizations vs original:
7
+ - Skip-if-exists: if models are already present (Docker layer cache reuse),
8
+ the entire download is skipped without re-downloading anything.
9
+ - MFR excluded: formula recognition models (unimernet, ~1-2 GB) are not
10
+ downloaded because formula recognition is disabled in the config. Excluding
11
+ them cuts download time and image size significantly.
12
+ - layoutreader optional: if download fails (network issue, repo unavailable),
13
+ the script logs a warning and continues. MinerU falls back to its built-in
14
+ layout ordering without layoutreader.
15
+
16
+ ── FORENSIC: OCR MODEL AVAILABILITY IN opendatalab/PDF-Extract-Kit-1.0 ───────
17
+
18
+ models_config.yml in magic-pdf 1.3.12 was patched in Dockerfile Layer 3.5 to
19
+ use the weight files that ARE present in the HF repo. After the patch:
20
+
21
+ DEFAULT CPU path (ch_lite):
22
+ det: ch_PP-OCRv5_det_infer.pth ← present in HF repo βœ“
23
+ rec: ch_PP-OCRv5_rec_infer.pth ← present in HF repo βœ“
24
+
25
+ en / latin det (patched):
26
+ det: Multilingual_PP-OCRv3_det_infer.pth ← present in HF repo βœ“
27
+
28
+ NOT IN REPO (these files do NOT exist and are not needed for default usage):
29
+ ch_PP-OCRv3_det_infer.pth ← absent; patched to v5
30
+ en_PP-OCRv3_det_infer.pth ← absent; patched to multilingual
31
+ en_PP-OCRv4_rec_infer.pth ← absent; en rec unavailable
32
+ korean/japan/chinese_cht/etc v3 rec ← absent; those langs need explicit
33
+ lang= arg which is not default
34
+
35
+ BUNDLED IN WHEEL (not downloaded here):
36
+ magic_pdf/resources/slanet_plus/slanet-plus.onnx ← table model, in wheel
37
+ magic_pdf/resources/fasttext-langdetect/lid.176.ftz
38
+ magic_pdf/resources/yolov11-langdetect/yolo_v11_ft.pt
39
+
40
+ Models saved to /app/models/
41
+ Config written to /root/magic-pdf.json
42
+ """
43
+
44
+ import json
45
+ import os
46
+ import sys
47
+
48
+ MODELS_DIR = "/app/models"
49
+ EXTRACT_KIT_DIR = os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0")
50
+ LAYOUTREADER_DIR = os.path.join(MODELS_DIR, "layoutreader")
51
+
52
+ # Canary files: both must exist for the skip-if-exists check to pass.
53
+ # Using the OCR det weight (not just the Layout dir) ensures a stale cache
54
+ # that predates the v3β†’v5 model rename cannot produce a false positive.
55
+ _CANARY_DIR = os.path.join(EXTRACT_KIT_DIR, "models", "Layout")
56
+ _CANARY_FILE = os.path.join(
57
+ EXTRACT_KIT_DIR, "models", "OCR", "paddleocr_torch",
58
+ "ch_PP-OCRv5_det_infer.pth" # patched det; absent in v3-era cache
59
+ )
60
+
61
+
62
+ def _models_present() -> bool:
63
+ """Return True only when BOTH the Layout directory AND the OCR v5 det weight
64
+ exist. This prevents stale Docker layer caches (built before the v3β†’v5 patch)
65
+ from reporting models as present when the required file is missing."""
66
+ return os.path.isdir(_CANARY_DIR) and os.path.isfile(_CANARY_FILE)
67
+
68
+
69
+ def _write_config(layoutreader_dir: str) -> None:
70
+ config = {
71
+ "bucket_info": {},
72
+ "models-dir": os.path.join(EXTRACT_KIT_DIR, "models"),
73
+ "layoutreader-model-dir": layoutreader_dir,
74
+ "device-mode": "cpu",
75
+ "layout-config": {
76
+ "model": "doclayout_yolo"
77
+ },
78
+ "formula-config": {
79
+ "mfd_model": "yolo_v8_mfd",
80
+ "mfr_model": "unimernet_small",
81
+ "enable": False
82
+ },
83
+ "table-config": {
84
+ "model": "rapid_table",
85
+ "enable": True,
86
+ "max_time": 400
87
+ },
88
+ "backend": "pipeline"
89
+ }
90
+ config_path = os.path.expanduser("~/magic-pdf.json")
91
+ with open(config_path, "w") as f:
92
+ json.dump(config, f, indent=2)
93
+ print(f"Config written β†’ {config_path}")
94
+ return config_path
95
+
96
+
97
+ def download() -> None:
98
+ try:
99
+ from huggingface_hub import snapshot_download
100
+ except ImportError:
101
+ print("ERROR: huggingface_hub not installed", file=sys.stderr)
102
+ sys.exit(1)
103
+
104
+ # ── Skip-if-exists ────────────────────────────────────────────────────────
105
+ if _models_present():
106
+ print("Models already present β€” skipping download (Docker layer cache).")
107
+ # Config may still need writing if this is a fresh container from cached layer
108
+ lr_dir = LAYOUTREADER_DIR if os.path.isdir(LAYOUTREADER_DIR) else ""
109
+ _write_config(lr_dir)
110
+ return
111
+
112
+ os.makedirs(MODELS_DIR, exist_ok=True)
113
+
114
+ # ── PDF-Extract-Kit-1.0 ───────────────────────────────────────────────────
115
+ # Excluded via ignore_patterns:
116
+ # models/MFR β€” formula recognition (unimernet). Disabled in config.
117
+ # Saves ~1-2 GB of download and disk.
118
+ #
119
+ # OCR models (models/OCR/paddleocr_torch/) ARE included (not ignored).
120
+ # After the Layer 3.5 patch, the files models_config.yml references match
121
+ # what is actually present in the repo:
122
+ # ch_PP-OCRv5_det_infer.pth ← present βœ“
123
+ # ch_PP-OCRv5_rec_infer.pth ← present βœ“
124
+ # Multilingual_PP-OCRv3_det_infer.pth ← present βœ“
125
+ print("=" * 60)
126
+ print("Downloading PDF-Extract-Kit-1.0 ...")
127
+ print(" (MFR/formula-recognition excluded β€” disabled in config)")
128
+ print("=" * 60)
129
+ snapshot_download(
130
+ repo_id="opendatalab/PDF-Extract-Kit-1.0",
131
+ local_dir=EXTRACT_KIT_DIR,
132
+ ignore_patterns=[
133
+ "*.git*",
134
+ ".gitattributes",
135
+ "models/MFR*",
136
+ "models/MFR/*",
137
+ ],
138
+ )
139
+ print(f" β†’ {EXTRACT_KIT_DIR}")
140
+
141
+ # Verify canary file landed correctly
142
+ if not os.path.isfile(_CANARY_FILE):
143
+ print(
144
+ f"\nERROR: Expected OCR model not found after download:\n"
145
+ f" {_CANARY_FILE}\n"
146
+ f"The HF repo may have changed its file structure.\n"
147
+ f"Run: python3 -c \"from huggingface_hub import list_repo_files; "
148
+ f"[print(f) for f in list_repo_files('opendatalab/PDF-Extract-Kit-1.0')]\"\n"
149
+ f"to inspect the current repo contents.",
150
+ file=sys.stderr,
151
+ )
152
+ sys.exit(1)
153
+ print(f" Canary verified: {os.path.basename(_CANARY_FILE)} βœ“")
154
+
155
+ # ── layoutreader (optional) ───────────────────────────────────────────────
156
+ # Improves reading-order accuracy. If unavailable, MinerU uses fallback.
157
+ layoutreader_dir = ""
158
+ print("=" * 60)
159
+ print("Downloading layoutreader (optional, improves reading order) ...")
160
+ print("=" * 60)
161
+ try:
162
+ snapshot_download(
163
+ repo_id="hantian/layoutreader",
164
+ local_dir=LAYOUTREADER_DIR,
165
+ ignore_patterns=["*.git*", ".gitattributes"],
166
+ )
167
+ layoutreader_dir = LAYOUTREADER_DIR
168
+ print(f" β†’ {LAYOUTREADER_DIR}")
169
+ except Exception as exc:
170
+ print(f" WARNING: layoutreader download failed ({exc})")
171
+ print(" Continuing without layoutreader β€” MinerU will use fallback ordering.")
172
+
173
+ # ── Write config ──────────────────────────────────────────────────────────
174
+ _write_config(layoutreader_dir)
175
+
176
+ print("\nβœ“ Model setup complete.")
177
+ print(f" models-dir : {os.path.join(EXTRACT_KIT_DIR, 'models')}")
178
+ print(f" layoutreader-dir : {layoutreader_dir or '(not available)'}")
179
+ print(f" device-mode : cpu")
180
+ print(f" formula recognition : disabled (MFR models excluded)")
181
+ print(f" table recognition : enabled (slanet-plus.onnx bundled in wheel)")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ download()
entrypoint.sh ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # MinerU OCR Service β€” container entrypoint
4
+ #
5
+ # Sequence:
6
+ # 1. Restore magic-pdf.json if wiped (HF container restart)
7
+ # 2. Run validate.py (pre-flight dependency check)
8
+ # β†’ exits 1 and kills container if any critical dep is missing
9
+ # β†’ this surfaces a clear error in HF logs instead of a silent bad start
10
+ # 3. Start uvicorn (single worker β€” CPU Basic, no RAM contention)
11
+ # ─────────────────────────────────────────────────────────────────────────────
12
+ set -e
13
+
14
+ CONFIG_FILE="${HOME}/magic-pdf.json"
15
+
16
+ # ── 1. Restore config if /root was wiped (HF container restart) ───────────────
17
+ if [ ! -f "$CONFIG_FILE" ]; then
18
+ echo "[entrypoint] magic-pdf.json missing β€” restoring from baked copy..."
19
+ cp /app/config/magic-pdf.json "$CONFIG_FILE"
20
+ fi
21
+
22
+ echo "[entrypoint] Config : $CONFIG_FILE"
23
+ echo "[entrypoint] Port : ${PORT:-7860}"
24
+
25
+ # ── 2. Pre-flight validation ──────────────────────────────────────────────────
26
+ # validate.py exits 0 on pass, exits 1 on any CRITICAL failure.
27
+ # 'set -e' above ensures a non-zero exit from validate.py aborts this script,
28
+ # preventing a broken uvicorn from starting and appearing healthy.
29
+ echo "[entrypoint] Running pre-flight validation..."
30
+ python /app/validate.py
31
+ echo "[entrypoint] Validation passed."
32
+
33
+ # ── 3. Start API server ───────────────────────────────────────────────────────
34
+ echo "[entrypoint] Starting uvicorn on 0.0.0.0:${PORT:-7860}..."
35
+ exec uvicorn main:app \
36
+ --host 0.0.0.0 \
37
+ --port "${PORT:-7860}" \
38
+ --workers 1 \
39
+ --timeout-keep-alive 120 \
40
+ --log-level info
main.py ADDED
@@ -0,0 +1,1401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenSkill OCR Service β€” v4.0
3
+ FastAPI application for Hugging Face Docker Space (CPU / pipeline backend)
4
+
5
+ ═══════════════════════════════════════════════════════════════════════════════
6
+ ARCHITECTURE (v4.0 β€” OCR-only, AI-first)
7
+ ═══════════════════════════════════════════════════════════════════════════════
8
+
9
+ This service is an extraction layer only. It does NOT:
10
+ - classify documents
11
+ - extract named entities
12
+ - validate fields
13
+ - generate summaries
14
+ - perform board/marksheet/JEE-specific logic
15
+
16
+ All document understanding is delegated to the AI layer downstream.
17
+
18
+ PATH A β€” Fast OCR (images: jpg / png / webp / bmp / heic / heif / avif)
19
+ Engine : rapidocr-onnxruntime β‰₯ 1.3.22
20
+ Models : Bundled in pip wheel β€” zero first-use download, ~50 MB
21
+ Resize : images capped at MAX_OCR_SIDE px (default 1600) before inference
22
+ Target : 1–4 s (acceptable < 8 s)
23
+ Fallback: if confidence < FAST_CONFIDENCE_THRESHOLD β†’ MinerU fallback
24
+
25
+ PATH B β€” Full pipeline (PDFs, multi-page, layout-sensitive docs)
26
+ Engine : MinerU magic-pdf pipeline backend
27
+ Models : opendatalab/PDF-Extract-Kit-1.0 (downloaded at build time)
28
+ Target : 5–20 s (acceptable < 30 s)
29
+
30
+ ═══════════════════════════════════════════════════════════════════════════════
31
+ RESPONSE FORMAT (v4.0)
32
+ ═══════════════════════════════════════════════════════════════════════════════
33
+
34
+ {
35
+ "success": true,
36
+ "filename": "scan.jpg",
37
+ "engine": "rapidocr",
38
+ "confidence": 0.91,
39
+ "text": "...",
40
+ "markdown": "...",
41
+ "pageCount": 1,
42
+ "cached": false,
43
+ "processingTimeMs": 1840,
44
+ "timings": {
45
+ "uploadMs": 12,
46
+ "hashMs": 4,
47
+ "memCheckMs": 8,
48
+ "decodeMs": 55,
49
+ "resizeMs": 18,
50
+ "detectMs": 610,
51
+ "recognizeMs": 980,
52
+ "postProcessMs": 14,
53
+ "totalMs": 1840
54
+ },
55
+ "metadata": {
56
+ "imgW": 3024,
57
+ "imgH": 4032,
58
+ "imgWResized": 1200,
59
+ "imgHResized": 1600,
60
+ "textBlocks": 47,
61
+ "passesUsed": 1,
62
+ "backend": "rapidocr"
63
+ }
64
+ }
65
+
66
+ ═══════════════════════════════════════════════════════════════════════════════
67
+ API ENDPOINTS
68
+ ═══════════════════════════════════════════════════════════════════════════════
69
+
70
+ GET /health Liveness (always fast)
71
+ GET /status Node status: memory, uptime, cache, engine state
72
+ GET /warmup Pre-load both OCR engines (also called at startup)
73
+ GET /diagnostics Full environment + model inventory
74
+ POST /benchmark Multi-size RapidOCR timing benchmark (small/medium/large)
75
+ POST /extract Single file β€” PDF or image β€” with SHA256 cache
76
+ POST /batch Up to 8 files, sequential, per-file error isolation
77
+ """
78
+
79
+ import hashlib
80
+ import io
81
+ import os
82
+ import re
83
+ import shutil
84
+ import sys
85
+ import tempfile
86
+ import threading
87
+ import time
88
+ import traceback
89
+ import logging
90
+ from importlib.metadata import version as pkg_version
91
+ from typing import Any, Optional
92
+
93
+ import fitz # PyMuPDF
94
+ import numpy as np
95
+ from PIL import Image
96
+
97
+ from fastapi import FastAPI, File, UploadFile
98
+ from fastapi.middleware.cors import CORSMiddleware
99
+ from fastapi.responses import JSONResponse
100
+
101
+ # ── Logging ───────────────────────────────────────────────────────────────────
102
+ logging.basicConfig(
103
+ level=logging.INFO,
104
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
105
+ )
106
+ logger = logging.getLogger("ocr-service")
107
+
108
+ # ── Start time ────────────────────────────────────────────────────────────────
109
+ _START_TIME: float = time.time()
110
+
111
+ # ── Upload / batch limits ─────────────────────────────────────────────────��───
112
+ MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
113
+ BATCH_MAX_FILES = 8
114
+
115
+ # ── File type sets ────────────────────────────────────────────────────────────
116
+ PDF_EXTENSIONS = {"pdf"}
117
+ NATIVE_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png"}
118
+ PILLOW_IMAGE_EXTENSIONS = {"webp", "bmp", "tiff", "tif", "gif", "heic", "heif", "avif"}
119
+ IMAGE_EXTENSIONS = NATIVE_IMAGE_EXTENSIONS | PILLOW_IMAGE_EXTENSIONS
120
+ OFFICE_EXTENSIONS = {"docx", "pptx", "xlsx"}
121
+ ALLOWED_EXTENSIONS = PDF_EXTENSIONS | IMAGE_EXTENSIONS | OFFICE_EXTENSIONS
122
+
123
+ # ── OCR tuning ────────────────────────────────────────────────────────────────
124
+ FAST_CONFIDENCE_THRESHOLD = 0.65 # below this β†’ MinerU fallback
125
+ MAX_OCR_SIDE = 1600 # pixels β€” longest side cap before OCR
126
+ # # General-purpose safe value. Lowering to 1280 gains ~20%
127
+ # # speed but risks losing small text in UI/code screenshots:
128
+ # # a 1913px-wide screen at 1280px canvas β†’ 11 px fonts scale
129
+ # # to ~8 px, which is the CRNN recognition floor.
130
+ # # Performance table (119 blocks, measured calibration 967 ms/batch):
131
+ # # 1600 px / batch=6 (pre-optimisation): ~19 300 ms
132
+ # # 1600 px / batch=24 (v4.1, this build): ~4 800 ms (βˆ’75%)
133
+ # # 1280 px / batch=24 (marksheet-only): ~3 900 ms (βˆ’80%)
134
+ # # Set to 1280 only if all inputs are printed A4 documents.
135
+
136
+ REC_BATCH_NUM = 24 # recognition batch size (default in RapidOCR wheel: 6)
137
+ # # Higher β†’ fewer sequential ONNX calls β†’ faster.
138
+ # # 119 blocks / 6 = 20 calls β†’ 119 / 24 = 5 calls
139
+ # # Accuracy impact: NONE β€” same model, same crops, same CTC decode.
140
+ # # Memory impact: negligible on 16 GB HF free tier.
141
+
142
+ DET_BOX_THRESH = 0.50 # detection confidence threshold (RapidOCR default: 0.50)
143
+ # # Keep at 0.50 for general-purpose use. Raising to 0.60 drops
144
+ # # ~15% of blocks (noise) and saves one ONNX call on dense docs,
145
+ # # but risks missing low-contrast text in UI/code screenshots
146
+ # # (dark-background text can score in the 0.50–0.65 range).
147
+ # # Safe to raise to 0.60–0.65 only for printed-document pipelines.
148
+
149
+ # ── Memory safety ─────────────────────────────────────────────────────────────
150
+ BYTES_PER_OCR_PAGE = 100 * 1024 * 1024
151
+ IMAGE_MEMORY_FACTOR = 4
152
+ # 100 MB floor β€” was 1024. psutil reads HOST RAM on HF Spaces (not the
153
+ # container cgroup), so the floor must be small enough to pass on a busy
154
+ # host that has only a few hundred MB of host-level free memory. The
155
+ # per-file estimate already encodes the request's working-memory cost;
156
+ # this floor is purely a last-resort guard against near-empty headroom.
157
+ MEM_SAFETY_FLOOR_MB = 100
158
+
159
+ # ── SHA256 extraction cache ───────────────────────────────────────────────────
160
+ _cache: dict[str, dict[str, Any]] = {}
161
+ _cache_lock = threading.Lock()
162
+
163
+ # ── Active-request counter ────────────────────────────────────────────────────
164
+ _active_requests: int = 0
165
+ _active_lock = threading.Lock()
166
+
167
+ # ── Engine state ──────────────────────────────────────────────────────────────
168
+ _rapidocr_engine: Any = None
169
+ _rapidocr_lock = threading.Lock()
170
+ _rapidocr_load_ms: int = 0
171
+ _rapidocr_ready: bool = False
172
+
173
+ _pipeline_ready: bool = False
174
+ _pipeline_lock = threading.Lock()
175
+ _pipeline_load_ms: int = 0
176
+
177
+ # ── Startup issues ────────────────────────────────────────────────────────────
178
+ _startup_issues: list[str] = []
179
+ _startup_done: bool = False
180
+
181
+
182
+ # ═════════════════════════════════════════════════════════════════════════════
183
+ # Structured error
184
+ # ═════════════════════════════════════════════════════════════════════════════
185
+ class ExtractionError(Exception):
186
+ def __init__(
187
+ self,
188
+ stage: str,
189
+ code: str,
190
+ message: str,
191
+ http_status: int = 422,
192
+ root_cause: str = "",
193
+ recommendation: str = "",
194
+ ) -> None:
195
+ self.stage = stage
196
+ self.code = code
197
+ self.message = message
198
+ self.http_status = http_status
199
+ self.root_cause = root_cause or message
200
+ self.recommendation = recommendation
201
+ super().__init__(message)
202
+
203
+ def to_dict(self) -> dict[str, Any]:
204
+ return {
205
+ "success": False,
206
+ "stage": self.stage,
207
+ "errorCode": self.code,
208
+ "rootCause": self.root_cause,
209
+ "recommendation": self.recommendation,
210
+ "message": self.message,
211
+ }
212
+
213
+
214
+ def _err(
215
+ stage: str,
216
+ code: str,
217
+ msg: str,
218
+ status: int = 422,
219
+ root_cause: str = "",
220
+ recommendation: str = "",
221
+ ) -> ExtractionError:
222
+ return ExtractionError(stage, code, msg, status, root_cause, recommendation)
223
+
224
+
225
+ # ═════════════════════════════════════════════════════════════════════════════
226
+ # Active-request helpers
227
+ # ═════════════════════════════════════════════════════════════════════════════
228
+ def _inc_active() -> None:
229
+ global _active_requests
230
+ with _active_lock:
231
+ _active_requests += 1
232
+
233
+
234
+ def _dec_active() -> None:
235
+ global _active_requests
236
+ with _active_lock:
237
+ _active_requests = max(0, _active_requests - 1)
238
+
239
+
240
+ # ═════════════════════════════════════════════════════════════════════════════
241
+ # Engine loaders
242
+ # ═════════════════════════════════════════════════════════════════════════════
243
+ def _ensure_rapidocr() -> Any:
244
+ """Load the RapidOCR engine once; return the singleton on every subsequent call."""
245
+ global _rapidocr_engine, _rapidocr_ready, _rapidocr_load_ms
246
+ if _rapidocr_ready:
247
+ return _rapidocr_engine
248
+ with _rapidocr_lock:
249
+ if _rapidocr_ready:
250
+ return _rapidocr_engine
251
+ t0 = time.perf_counter()
252
+ try:
253
+ from rapidocr_onnxruntime import RapidOCR
254
+ _rapidocr_engine = RapidOCR(
255
+ det_limit_side_len=MAX_OCR_SIDE,
256
+ det_limit_type="max",
257
+ # ── Recognition batch size ───────────────────────────────────
258
+ # Default in RapidOCR wheel is 6; 24 reduces ONNX calls by ~4Γ—
259
+ # for typical documents (76 blocks β†’ 4 calls instead of 13).
260
+ # Accuracy impact: zero β€” same CRNN model, same crops, same CTC.
261
+ rec_batch_num=REC_BATCH_NUM,
262
+ # ── Angle classifier disabled ────────────────────────────────
263
+ # Classifier (ch_ppocr_mobile_v2.0_cls_infer.onnx) runs a full
264
+ # ONNX pass on every crop to detect 180Β° rotation. For straight
265
+ # document scans (marksheets, certificates) this is pure overhead.
266
+ # Saves ~1 300 ms on 119 blocks (cls_batch_num=6 Γ— ~65 ms/call).
267
+ # Re-enable if the service receives upside-down images.
268
+ use_cls=False,
269
+ )
270
+ _rapidocr_load_ms = int((time.perf_counter() - t0) * 1000)
271
+ _rapidocr_ready = True
272
+ logger.info("RapidOCR engine ready load_ms=%d", _rapidocr_load_ms)
273
+ except Exception as exc:
274
+ raise _err(
275
+ "model_load", "RAPIDOCR_LOAD_FAILED",
276
+ f"RapidOCR failed to load: {exc}", 503,
277
+ root_cause=str(exc),
278
+ recommendation="Check that rapidocr-onnxruntime is installed.",
279
+ ) from exc
280
+ return _rapidocr_engine
281
+
282
+
283
+ def _ensure_pipeline() -> None:
284
+ """Import and verify the MinerU pipeline once."""
285
+ global _pipeline_ready, _pipeline_load_ms
286
+ if _pipeline_ready:
287
+ return
288
+ with _pipeline_lock:
289
+ if _pipeline_ready:
290
+ return
291
+ config_path = os.path.expanduser("~/magic-pdf.json")
292
+ if not os.path.exists(config_path):
293
+ raise _err(
294
+ "model_load", "CONFIG_MISSING",
295
+ f"magic-pdf.json not found at {config_path}.", 503,
296
+ root_cause="download_models.py did not run or /root was wiped.",
297
+ recommendation="Check Docker build log for download_models.py output.",
298
+ )
299
+ t0 = time.perf_counter()
300
+ try:
301
+ from magic_pdf.data.dataset import PymuDocDataset, ImageDataset # noqa
302
+ from magic_pdf.data.data_reader_writer import ( # noqa
303
+ FileBasedDataReader, FileBasedDataWriter)
304
+ except ImportError as exc:
305
+ raise _err(
306
+ "model_load", "IMPORT_FAILED",
307
+ f"magic_pdf not importable: {exc}", 503,
308
+ root_cause=str(exc),
309
+ recommendation="Check that magic-pdf[full]==1.3.12 is installed.",
310
+ ) from exc
311
+ _pipeline_load_ms = int((time.perf_counter() - t0) * 1000)
312
+ _pipeline_ready = True
313
+ logger.info("MinerU pipeline ready load_ms=%d", _pipeline_load_ms)
314
+
315
+
316
+ # ═════════════════════════════════════════════════════════════════════════════
317
+ # FastAPI app
318
+ # ═════════════════════════════════════════════════════════════════════════════
319
+ app = FastAPI(
320
+ title="OpenSkill OCR Service",
321
+ description="OCR-only text extraction. Document understanding is handled by the AI layer.",
322
+ version="4.0.0",
323
+ )
324
+
325
+ app.add_middleware(
326
+ CORSMiddleware,
327
+ allow_origins=["*"],
328
+ allow_methods=["GET", "POST"],
329
+ allow_headers=["*"],
330
+ )
331
+
332
+
333
+ # ─────────────────────────────────────────────────────────────────────────────
334
+ # Startup β€” pre-load RapidOCR so first request has zero cold-start cost
335
+ # ─────────────────────────────────────────────────────────────────────────────
336
+ @app.on_event("startup")
337
+ async def startup_warmup() -> None:
338
+ """
339
+ Pre-load the RapidOCR engine at container start.
340
+
341
+ Without this, the first /extract request pays 600–2 500 ms for ONNX model
342
+ loading on top of normal inference time. Loading here moves that cost to
343
+ startup where it is invisible to the user.
344
+ """
345
+ global _startup_done
346
+ issues: list[str] = []
347
+
348
+ # ── Dependency smoke-check ────────────────────────────────────────────────
349
+ checks = [
350
+ ("cv2", lambda: __import__("cv2").__version__),
351
+ ("torch", lambda: __import__("torch").__version__),
352
+ ("rapidocr", lambda: pkg_version("rapidocr-onnxruntime")),
353
+ ("magic_pdf", lambda: __import__("magic_pdf").__version__),
354
+ ]
355
+ for name, fn in checks:
356
+ try:
357
+ ver = fn()
358
+ logger.info("startup βœ“ %-12s %s", name, ver)
359
+ except Exception as exc:
360
+ msg = f"{name} unavailable: {exc}"
361
+ issues.append(msg)
362
+ logger.critical("startup FAIL %s", msg)
363
+
364
+ if not os.path.exists(os.path.expanduser("~/magic-pdf.json")):
365
+ issues.append("magic-pdf.json missing")
366
+ if not os.path.isdir("/app/models/PDF-Extract-Kit-1.0/models"):
367
+ issues.append("Models directory missing: /app/models/PDF-Extract-Kit-1.0/models")
368
+
369
+ # ── Pre-load RapidOCR ─────────────────────────────────────────────────────
370
+ try:
371
+ _ensure_rapidocr()
372
+ logger.info("startup: RapidOCR pre-loaded load_ms=%d", _rapidocr_load_ms)
373
+ except Exception as exc:
374
+ msg = f"RapidOCR warmup failed: {exc}"
375
+ issues.append(msg)
376
+ logger.error("startup: %s", msg)
377
+
378
+ _startup_issues.extend(issues)
379
+ _startup_done = True
380
+ if issues:
381
+ logger.error("Startup completed with %d issue(s): %s", len(issues), issues)
382
+ else:
383
+ logger.info("Startup complete β€” all systems ready.")
384
+
385
+
386
+ # ═════════════════════════════════════════════════════════════════════════════
387
+ # GET /health
388
+ # ═════════════════════════════════════════════════════════════════════════════
389
+ @app.get("/health")
390
+ def health() -> dict[str, Any]:
391
+ return {"status": "healthy", "version": "4.0.0"}
392
+
393
+
394
+ # ═════════════════════════════════════════════════════════════════════════════
395
+ # GET /status
396
+ # ═════════════════════════════════════════════════════════════════════════════
397
+ @app.get("/status")
398
+ def status() -> dict[str, Any]:
399
+ used_mb, total_mb = _mem_mb()
400
+ return {
401
+ "status": "healthy" if not _startup_issues else "degraded",
402
+ "version": "4.0.0",
403
+ "architecture": "ocr-only",
404
+ "engines": {
405
+ "rapidocr": {
406
+ "ready": _rapidocr_ready,
407
+ "loadMs": _rapidocr_load_ms,
408
+ "purpose": "images (1–4 s)",
409
+ },
410
+ "mineru": {
411
+ "ready": _pipeline_ready,
412
+ "loadMs": _pipeline_load_ms,
413
+ "purpose": "PDFs + fallback",
414
+ },
415
+ },
416
+ "config": {
417
+ "maxOcrSidePx": MAX_OCR_SIDE,
418
+ "confidenceThreshold": FAST_CONFIDENCE_THRESHOLD,
419
+ "maxUploadMb": MAX_UPLOAD_BYTES // (1024 * 1024),
420
+ },
421
+ "startupIssues": _startup_issues,
422
+ "uptimeSeconds": int(time.time() - _START_TIME),
423
+ "memoryUsedMB": used_mb,
424
+ "memoryTotalMB": total_mb,
425
+ "activeRequests": _active_requests,
426
+ "cacheEntries": len(_cache),
427
+ }
428
+
429
+
430
+ # ═════════════════════════════════════════════════════════════════════════════
431
+ # GET /warmup
432
+ # ═════════════════════════════════════════════════════════════════════════════
433
+ @app.get("/warmup")
434
+ def warmup() -> dict[str, Any]:
435
+ """Explicitly pre-load engines. Idempotent β€” safe to call repeatedly."""
436
+ results: dict[str, Any] = {}
437
+ t0 = time.perf_counter()
438
+ try:
439
+ _ensure_rapidocr()
440
+ results["rapidocr"] = {"status": "ready", "loadMs": _rapidocr_load_ms}
441
+ except Exception as exc:
442
+ results["rapidocr"] = {"status": "failed", "error": str(exc)}
443
+ try:
444
+ _ensure_pipeline()
445
+ results["mineru"] = {"status": "ready", "loadMs": _pipeline_load_ms}
446
+ except Exception as exc:
447
+ results["mineru"] = {"status": "failed", "error": str(exc)}
448
+ results["totalElapsedMs"] = int((time.perf_counter() - t0) * 1000)
449
+ results["allReady"] = _rapidocr_ready and _pipeline_ready
450
+ return results
451
+
452
+
453
+ # ═════════════════════════════════════════════════════════════════════════════
454
+ # GET /diagnostics
455
+ # ═════════════════════════════════════════════════════════════════════════════
456
+ @app.get("/diagnostics")
457
+ def diagnostics() -> dict[str, Any]:
458
+ import platform
459
+ pkgs: dict[str, str] = {}
460
+ for name in (
461
+ "magic-pdf", "rapidocr-onnxruntime", "torch", "torchvision",
462
+ "ultralytics", "doclayout-yolo", "rapid-table", "onnxruntime",
463
+ "opencv-python-headless", "Pillow", "fastapi", "uvicorn",
464
+ ):
465
+ try:
466
+ pkgs[name] = pkg_version(name)
467
+ except Exception:
468
+ pkgs[name] = "not found"
469
+
470
+ models_root = "/app/models/PDF-Extract-Kit-1.0/models"
471
+ model_files: dict[str, str] = {}
472
+ for rel in [
473
+ "OCR/paddleocr_torch/ch_PP-OCRv5_det_infer.pth",
474
+ "OCR/paddleocr_torch/ch_PP-OCRv5_rec_infer.pth",
475
+ "Layout/YOLO/doclayout_yolo_docstructbench_imgsz1280_2501.pt",
476
+ ]:
477
+ full = os.path.join(models_root, rel)
478
+ model_files[rel] = (
479
+ f"{os.path.getsize(full) / (1024 * 1024):.1f} MB"
480
+ if os.path.isfile(full) else "MISSING"
481
+ )
482
+
483
+ used_mb, total_mb = _mem_mb()
484
+ return {
485
+ "python": platform.python_version(),
486
+ "packages": pkgs,
487
+ "modelFiles": model_files,
488
+ "memory": {"usedMB": used_mb, "totalMB": total_mb},
489
+ "engines": {
490
+ "rapidocr": {"ready": _rapidocr_ready, "loadMs": _rapidocr_load_ms},
491
+ "mineru": {"ready": _pipeline_ready, "loadMs": _pipeline_load_ms},
492
+ },
493
+ "config": {
494
+ "maxOcrSidePx": MAX_OCR_SIDE,
495
+ "confidenceThreshold": FAST_CONFIDENCE_THRESHOLD,
496
+ },
497
+ "uptime": int(time.time() - _START_TIME),
498
+ "cacheEntries": len(_cache),
499
+ }
500
+
501
+
502
+ # ═════════════════════════════════════════════════════════════════════════════
503
+ # GET /benchmark
504
+ # Runs RapidOCR on three synthetic images (small / medium / large) and returns
505
+ # full stage timings for each. Use this to measure the resize optimisation.
506
+ # ═════════════════════════════════════════════════════════════════════════════
507
+ @app.get("/benchmark")
508
+ async def benchmark() -> JSONResponse:
509
+ import cv2
510
+
511
+ def _make_test_image(width: int, height: int) -> "np.ndarray":
512
+ img = np.ones((height, width, 3), dtype=np.uint8) * 255
513
+ lines = [
514
+ "184 ENGLISH LNG & LIT. 073 020 093",
515
+ "085 HINDI COURSE-B 075 020 095",
516
+ "041 MATHEMATICS STD 063 020 083",
517
+ "086 SCIENCE 065 020 085",
518
+ "087 SOCIAL SCIENCE 057 020 077",
519
+ "Roll No: 28169763 Name: TEST STUDENT",
520
+ "Total: 433 / 500 Percentage: 86.6%",
521
+ ]
522
+ line_h = max(20, height // (len(lines) + 2))
523
+ scale = max(0.5, min(1.5, width / 900))
524
+ for i, text in enumerate(lines):
525
+ y = line_h * (i + 1)
526
+ if y < height - 10:
527
+ cv2.putText(img, text, (20, y),
528
+ cv2.FONT_HERSHEY_SIMPLEX, scale, (0, 0, 0), 2)
529
+ return img
530
+
531
+ SIZES = [
532
+ ("small", 800, 1200),
533
+ ("medium", 1600, 2400),
534
+ ("large", 3000, 4000),
535
+ ]
536
+ results: dict[str, Any] = {}
537
+
538
+ engine = _ensure_rapidocr()
539
+
540
+ for label, w, h in SIZES:
541
+ img = _make_test_image(w, h)
542
+ orig_h, orig_w = img.shape[:2]
543
+
544
+ # Resize
545
+ t_resize = time.perf_counter()
546
+ img_resized, was_resized = _resize_for_ocr(img)
547
+ resize_ms = int((time.perf_counter() - t_resize) * 1000)
548
+ new_h, new_w = img_resized.shape[:2]
549
+
550
+ # OCR
551
+ t_ocr = time.perf_counter()
552
+ ocr_result, elapse = engine(img_resized, box_thresh=DET_BOX_THRESH)
553
+ ocr_ms = int((time.perf_counter() - t_ocr) * 1000)
554
+
555
+ det_ms, rec_ms = _split_elapse(elapse, ocr_ms)
556
+ texts = [item[1] for item in (ocr_result or []) if len(item) > 1]
557
+ scores = [item[2] for item in (ocr_result or []) if len(item) > 2 and item[2] is not None]
558
+ conf = round(sum(scores) / len(scores), 4) if scores else 0.0
559
+
560
+ results[label] = {
561
+ "originalDimensions": f"{orig_w}Γ—{orig_h}",
562
+ "resizedDimensions": f"{new_w}Γ—{new_h}",
563
+ "wasResized": was_resized,
564
+ "resizeMs": resize_ms,
565
+ "detectMs": det_ms,
566
+ "recognizeMs": rec_ms,
567
+ "ocrTotalMs": ocr_ms,
568
+ "textBlocks": len(texts),
569
+ "confidence": conf,
570
+ }
571
+
572
+ used_mb, total_mb = _mem_mb()
573
+ return JSONResponse(content={
574
+ "results": results,
575
+ "memory": {"usedMB": used_mb, "totalMB": total_mb},
576
+ "maxOcrSide": MAX_OCR_SIDE,
577
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
578
+ })
579
+
580
+
581
+ # ═════════════════════════════════════════════════════════════════════════════
582
+ # POST /extract
583
+ # ═════════════════════════════════════════════════════════════════════════════
584
+ @app.post("/extract")
585
+ async def extract(file: UploadFile = File(...)) -> JSONResponse:
586
+ t_upload_start = time.perf_counter()
587
+ try:
588
+ raw, filename, ext = await _read_upload(file)
589
+ upload_ms = int((time.perf_counter() - t_upload_start) * 1000)
590
+ result = _run_extraction(raw, filename, ext, upload_ms=upload_ms)
591
+ return JSONResponse(content=result)
592
+ except ExtractionError as exc:
593
+ logger.warning("/extract [%s/%s]: %s", exc.stage, exc.code, exc.message)
594
+ return JSONResponse(status_code=exc.http_status, content=exc.to_dict())
595
+ except Exception as exc:
596
+ logger.exception("/extract unhandled error")
597
+ return JSONResponse(
598
+ status_code=500,
599
+ content={
600
+ "success": False,
601
+ "stage": "unknown",
602
+ "errorCode": "INTERNAL_ERROR",
603
+ "rootCause": str(exc),
604
+ "recommendation": "Check HF Space logs for full traceback.",
605
+ "message": str(exc),
606
+ "traceback": traceback.format_exc()[-3000:],
607
+ },
608
+ )
609
+
610
+
611
+ # ═════════════════════════════════════════════════════════════════════════════
612
+ # POST /batch
613
+ # ═════════════════════════════════════════════════════════════════════════════
614
+ @app.post("/batch")
615
+ async def batch(files: list[UploadFile] = File(...)) -> JSONResponse:
616
+ candidates = files[:BATCH_MAX_FILES]
617
+ results: list[dict[str, Any]] = []
618
+ for upload in candidates:
619
+ t0 = time.perf_counter()
620
+ try:
621
+ raw, filename, ext = await _read_upload(upload)
622
+ result = _run_extraction(
623
+ raw, filename, ext,
624
+ upload_ms=int((time.perf_counter() - t0) * 1000),
625
+ )
626
+ except ExtractionError as exc:
627
+ result = exc.to_dict()
628
+ result["filename"] = _sanitize_filename(upload.filename or "upload")
629
+ except Exception as exc:
630
+ fname = _sanitize_filename(upload.filename or "upload")
631
+ logger.exception("Batch item failed: %s", fname)
632
+ result = {
633
+ "success": False,
634
+ "filename": fname,
635
+ "stage": "unknown",
636
+ "errorCode": "INTERNAL_ERROR",
637
+ "rootCause": str(exc),
638
+ "recommendation": "Check HF Space logs.",
639
+ "message": str(exc),
640
+ }
641
+ results.append(result)
642
+ return JSONResponse(content={
643
+ "success": True,
644
+ "processed": len(results),
645
+ "results": results,
646
+ })
647
+
648
+
649
+ # ═════════════════════════════════════════════════════════════════════════════
650
+ # Upload reader
651
+ # ═════════════════════════════════════════════════════════════════════════════
652
+ async def _read_upload(upload: UploadFile) -> tuple[bytes, str, str]:
653
+ filename = _sanitize_filename(upload.filename or "upload")
654
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
655
+
656
+ if ext not in ALLOWED_EXTENSIONS:
657
+ raise _err(
658
+ "validation", "UNSUPPORTED_TYPE",
659
+ f"Unsupported file type '.{ext}'. "
660
+ f"Supported: {sorted(ALLOWED_EXTENSIONS)}",
661
+ 415,
662
+ root_cause=f"Extension '{ext}' is not in the allowed set.",
663
+ recommendation="Convert to PDF, JPG, PNG, or WEBP before uploading.",
664
+ )
665
+ raw = await upload.read(MAX_UPLOAD_BYTES + 1)
666
+ if len(raw) > MAX_UPLOAD_BYTES:
667
+ raise _err(
668
+ "upload", "FILE_TOO_LARGE",
669
+ f"'{filename}' exceeds {MAX_UPLOAD_BYTES // 1024 // 1024} MB.", 413,
670
+ root_cause=f"File is {len(raw) // 1024 // 1024} MB.",
671
+ recommendation="Compress or split the file.",
672
+ )
673
+ if len(raw) == 0:
674
+ raise _err("upload", "EMPTY_FILE", f"'{filename}' is empty.", 400,
675
+ root_cause="Zero bytes received.",
676
+ recommendation="Check the file before uploading.")
677
+ return raw, filename, ext
678
+
679
+
680
+ # ═════════════════════════════════════════════════════════════════════════════
681
+ # Extraction dispatcher
682
+ # ═════════════════════════════════════════════════════════════════════════════
683
+ def _run_extraction(
684
+ raw: bytes, filename: str, ext: str, upload_ms: int = 0
685
+ ) -> dict[str, Any]:
686
+ logger.info("request_received file=%s size=%d ext=%s", filename, len(raw), ext)
687
+
688
+ # ── Hash + cache lookup ───────────────────────────────────────────────────
689
+ t_hash = time.perf_counter()
690
+ file_hash = hashlib.sha256(raw).hexdigest()
691
+ hash_ms = int((time.perf_counter() - t_hash) * 1000)
692
+ logger.info("cache_lookup sha256=%.12s… hash_ms=%d", file_hash, hash_ms)
693
+
694
+ with _cache_lock:
695
+ cached = _cache.get(file_hash)
696
+ if cached is not None:
697
+ logger.info("cache_hit sha256=%.12s… file=%s", file_hash, filename)
698
+ out = {**cached}
699
+ out["cached"] = True
700
+ out["processingTimeMs"] = 0
701
+ out["timings"] = {**cached.get("timings", {}), "totalMs": 0}
702
+ return out
703
+
704
+ logger.info("cache_miss sha256=%.12s…", file_hash)
705
+
706
+ # ── Memory safety ─────���───────────────────────────────────────────────────
707
+ t_mem = time.perf_counter()
708
+ _assert_memory_safe(raw, ext)
709
+ mem_check_ms = int((time.perf_counter() - t_mem) * 1000)
710
+
711
+ _inc_active()
712
+ work_dir = tempfile.mkdtemp(prefix="ocr_")
713
+ t0 = time.perf_counter()
714
+ try:
715
+ if ext in PDF_EXTENSIONS:
716
+ logger.info("engine_selected engine=mineru file=%s", filename)
717
+ _ensure_pipeline()
718
+ result = _process_pdf(raw, filename, work_dir, upload_ms=upload_ms)
719
+ elif ext in OFFICE_EXTENSIONS:
720
+ logger.info("engine_selected engine=office_text file=%s ext=%s", filename, ext)
721
+ result = _process_office(raw, filename, ext, upload_ms=upload_ms)
722
+ else:
723
+ logger.info("engine_selected engine=rapidocr file=%s", filename)
724
+ result = _process_image(raw, filename, ext, work_dir, upload_ms=upload_ms)
725
+
726
+ total_ms = int((time.perf_counter() - t0) * 1000)
727
+ result["timings"]["uploadMs"] = upload_ms
728
+ result["timings"]["hashMs"] = hash_ms
729
+ result["timings"]["memCheckMs"] = mem_check_ms
730
+ result["timings"]["totalMs"] = total_ms
731
+ result["processingTimeMs"] = total_ms
732
+ result["cached"] = False
733
+
734
+ # Store in cache (strip per-request fields that change on replay)
735
+ entry = {k: v for k, v in result.items()
736
+ if k not in ("cached", "processingTimeMs", "timings")}
737
+ entry["timings"] = {k: v for k, v in result["timings"].items()
738
+ if k not in ("totalMs", "hashMs", "memCheckMs", "uploadMs")}
739
+ with _cache_lock:
740
+ _cache[file_hash] = entry
741
+
742
+ logger.info(
743
+ "response_sent file=%s engine=%s conf=%.3f total_ms=%d",
744
+ filename, result.get("engine", "?"), result.get("confidence", 0), total_ms,
745
+ )
746
+ return result
747
+
748
+ except ExtractionError:
749
+ raise
750
+ except Exception as exc:
751
+ logger.exception("extraction_failed file=%s", filename)
752
+ raise _err(
753
+ "unknown", "INTERNAL_ERROR", f"Unexpected error: {exc}", 500,
754
+ root_cause=str(exc),
755
+ recommendation="Check HF Space logs for full traceback.",
756
+ ) from exc
757
+ finally:
758
+ _dec_active()
759
+ shutil.rmtree(work_dir, ignore_errors=True)
760
+
761
+
762
+ # ═════════════════════════════════════════════════════════════════════════════
763
+ # Image processor β€” RapidOCR fast path + MinerU fallback
764
+ # ═════════════════════════════════════════════════════════════════════════════
765
+ def _process_image(
766
+ raw: bytes, filename: str, ext: str, work_dir: str, upload_ms: int = 0
767
+ ) -> dict[str, Any]:
768
+ import cv2
769
+
770
+ # ── Decode ────────────────────────────────────────────────────────────────
771
+ t_decode = time.perf_counter()
772
+ img_bgr = _decode_image_to_bgr(raw, ext)
773
+ decode_ms = int((time.perf_counter() - t_decode) * 1000)
774
+ orig_h, orig_w = img_bgr.shape[:2]
775
+ logger.info("image_decoded file=%s dims=%dx%d decode_ms=%d",
776
+ filename, orig_w, orig_h, decode_ms)
777
+
778
+ # ── Resize ────────────────────────────────────────────────────────────────
779
+ t_resize = time.perf_counter()
780
+ img_ocr, was_resized = _resize_for_ocr(img_bgr)
781
+ resize_ms = int((time.perf_counter() - t_resize) * 1000)
782
+ new_h, new_w = img_ocr.shape[:2]
783
+ logger.info("image_resized file=%s original=%dx%d resized=%dx%d"
784
+ " was_resized=%s resize_ms=%d",
785
+ filename, orig_w, orig_h, new_w, new_h, was_resized, resize_ms)
786
+
787
+ # ── RapidOCR ──────────────────────────────────────────────────────────────
788
+ logger.info("ocr_started file=%s engine=rapidocr dims=%dx%d",
789
+ filename, new_w, new_h)
790
+ t_ocr = time.perf_counter()
791
+ try:
792
+ engine = _ensure_rapidocr()
793
+ # box_thresh: drops detection boxes below this confidence BEFORE recognition.
794
+ # Zero recognition cost for dropped boxes. See DET_BOX_THRESH constant.
795
+ ocr_result, elapse = engine(img_ocr, box_thresh=DET_BOX_THRESH)
796
+ except ExtractionError:
797
+ raise
798
+ except Exception as exc:
799
+ raise _err(
800
+ "ocr", "OCR_ENGINE_FAILED", f"RapidOCR failed: {exc}", 500,
801
+ root_cause=str(exc),
802
+ recommendation="Check rapidocr-onnxruntime in Dockerfile Layer 1.",
803
+ ) from exc
804
+ ocr_ms = int((time.perf_counter() - t_ocr) * 1000)
805
+ det_ms, rec_ms = _split_elapse(elapse, ocr_ms)
806
+ logger.info("ocr_finished file=%s engine=rapidocr ocr_ms=%d"
807
+ " det_ms=%d rec_ms=%d", filename, ocr_ms, det_ms, rec_ms)
808
+
809
+ # ── Parse output ──────────────────────────────────────────────────────────
810
+ t_post = time.perf_counter()
811
+ plain_text, confidence = _parse_rapidocr_output(ocr_result)
812
+ post_ms = int((time.perf_counter() - t_post) * 1000)
813
+ logger.info("post_process file=%s conf=%.3f text_len=%d blocks=%d post_ms=%d",
814
+ filename, confidence, len(plain_text),
815
+ len(ocr_result) if ocr_result else 0, post_ms)
816
+
817
+ # ── MinerU fallback if confidence is low ──────────────────────────────────
818
+ passes_used = 1
819
+ engine_name = "rapidocr"
820
+ if confidence < FAST_CONFIDENCE_THRESHOLD and plain_text.strip():
821
+ logger.info(
822
+ "fallback_triggered conf=%.3f < %.2f file=%s trying mineru",
823
+ confidence, FAST_CONFIDENCE_THRESHOLD, filename,
824
+ )
825
+ try:
826
+ _ensure_pipeline()
827
+ mr = _process_image_mineru(raw, filename, ext, work_dir)
828
+ if len(mr.get("text", "")) > len(plain_text) * 0.8:
829
+ mr["engine"] = "mineru_fallback"
830
+ mr["metadata"]["passesUsed"] = 2
831
+ mr["timings"]["pass1RapidOCRMs"] = ocr_ms
832
+ mr["timings"]["decodeMs"] = decode_ms
833
+ mr["timings"]["resizeMs"] = resize_ms
834
+ logger.info("fallback_used file=%s mineru result accepted", filename)
835
+ return mr
836
+ except Exception as exc:
837
+ logger.warning("fallback_failed file=%s error=%s using rapidocr result", filename, exc)
838
+ passes_used = 2
839
+ else:
840
+ logger.info("fallback_not_needed conf=%.3f file=%s", confidence, filename)
841
+
842
+ return {
843
+ "success": True,
844
+ "filename": filename,
845
+ "engine": engine_name,
846
+ "confidence": confidence,
847
+ "text": plain_text,
848
+ "markdown": plain_text,
849
+ "pageCount": 1,
850
+ "timings": {
851
+ "uploadMs": upload_ms,
852
+ "hashMs": 0,
853
+ "memCheckMs": 0,
854
+ "decodeMs": decode_ms,
855
+ "resizeMs": resize_ms,
856
+ "detectMs": det_ms,
857
+ "recognizeMs": rec_ms,
858
+ "postProcessMs": post_ms,
859
+ "totalMs": 0,
860
+ },
861
+ "metadata": {
862
+ "imgW": orig_w,
863
+ "imgH": orig_h,
864
+ "imgWResized": new_w,
865
+ "imgHResized": new_h,
866
+ "wasResized": was_resized,
867
+ "textBlocks": len(ocr_result) if ocr_result else 0,
868
+ "passesUsed": passes_used,
869
+ "backend": "rapidocr",
870
+ },
871
+ }
872
+
873
+
874
+ def _process_image_mineru(
875
+ raw: bytes, filename: str, ext: str, work_dir: str
876
+ ) -> dict[str, Any]:
877
+ from magic_pdf.data.data_reader_writer import (
878
+ FileBasedDataReader, FileBasedDataWriter)
879
+ from magic_pdf.data.dataset import ImageDataset
880
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
881
+
882
+ images_dir = os.path.join(work_dir, "images_mineru")
883
+ os.makedirs(images_dir, exist_ok=True)
884
+
885
+ if ext in PILLOW_IMAGE_EXTENSIONS:
886
+ raw = _convert_to_png(raw, ext)
887
+ save_ext = "png"
888
+ else:
889
+ save_ext = ext
890
+
891
+ img_path = os.path.join(work_dir, f"input_mineru.{save_ext}")
892
+ with open(img_path, "wb") as fh:
893
+ fh.write(raw)
894
+
895
+ t_ocr = time.perf_counter()
896
+ try:
897
+ reader = FileBasedDataReader(work_dir)
898
+ image_bytes = reader.read(f"input_mineru.{save_ext}")
899
+ ds = ImageDataset(image_bytes)
900
+ infer_result = ds.apply(doc_analyze, ocr=True)
901
+ pipe_result = infer_result.pipe_ocr_mode(FileBasedDataWriter(images_dir))
902
+ except Exception as exc:
903
+ raise _err(
904
+ "ocr", "OCR_PIPELINE_FAILED",
905
+ f"MinerU image pipeline failed: {exc}", 500,
906
+ root_cause=str(exc),
907
+ recommendation="Check magic-pdf installation and model files.",
908
+ ) from exc
909
+ ocr_ms = int((time.perf_counter() - t_ocr) * 1000)
910
+
911
+ t_md = time.perf_counter()
912
+ try:
913
+ markdown = pipe_result.get_markdown(images_dir)
914
+ except Exception as exc:
915
+ raise _err("markdown", "MARKDOWN_FAILED", f"get_markdown failed: {exc}") from exc
916
+ md_ms = int((time.perf_counter() - t_md) * 1000)
917
+
918
+ plain_text = _markdown_to_plain(markdown)
919
+
920
+ return {
921
+ "success": True,
922
+ "filename": filename,
923
+ "engine": "mineru",
924
+ "confidence": 0.85,
925
+ "text": plain_text,
926
+ "markdown": markdown,
927
+ "pageCount": 1,
928
+ "timings": {
929
+ "uploadMs": 0,
930
+ "hashMs": 0,
931
+ "memCheckMs": 0,
932
+ "decodeMs": 0,
933
+ "resizeMs": 0,
934
+ "detectMs": 0,
935
+ "recognizeMs": ocr_ms,
936
+ "postProcessMs": md_ms,
937
+ "totalMs": 0,
938
+ },
939
+ "metadata": {
940
+ "imgW": 0, "imgH": 0,
941
+ "imgWResized": 0, "imgHResized": 0,
942
+ "wasResized": False,
943
+ "textBlocks": 0,
944
+ "passesUsed": 1,
945
+ "backend": "pipeline",
946
+ },
947
+ }
948
+
949
+
950
+ # ═════════════════════════════════════════════════════════════════════════════
951
+ # Office document processor β€” DOCX / PPTX / XLSX (text extraction, no OCR)
952
+ # No image rendering or OCR is performed. Text is read directly from the
953
+ # structured XML inside the Office Open XML container.
954
+ # ═════════════════════════════════════════════════════════════════════════════
955
+ def _process_office(
956
+ raw: bytes, filename: str, ext: str, upload_ms: int = 0
957
+ ) -> dict[str, Any]:
958
+ t0 = time.perf_counter()
959
+ logger.info("ocr_started file=%s engine=office_text ext=%s", filename, ext)
960
+
961
+ try:
962
+ if ext == "docx":
963
+ plain_text, page_count = _extract_docx(raw)
964
+ elif ext == "pptx":
965
+ plain_text, page_count = _extract_pptx(raw)
966
+ elif ext == "xlsx":
967
+ plain_text, page_count = _extract_xlsx(raw)
968
+ else:
969
+ raise _err("decode", "UNSUPPORTED_OFFICE_TYPE",
970
+ f"Unrecognised office extension: {ext}", 415)
971
+ except ExtractionError:
972
+ raise
973
+ except Exception as exc:
974
+ raise _err(
975
+ "ocr", "OFFICE_EXTRACT_FAILED",
976
+ f"Could not extract text from {ext.upper()}: {exc}", 422,
977
+ root_cause=str(exc),
978
+ recommendation=f"Ensure the file is a valid, non-password-protected {ext.upper()}.",
979
+ ) from exc
980
+
981
+ extract_ms = int((time.perf_counter() - t0) * 1000)
982
+ logger.info("ocr_finished file=%s engine=office_text extract_ms=%d text_len=%d",
983
+ filename, extract_ms, len(plain_text))
984
+
985
+ return {
986
+ "success": True,
987
+ "filename": filename,
988
+ "engine": f"office_text_{ext}",
989
+ "confidence": 1.0,
990
+ "text": plain_text,
991
+ "markdown": plain_text,
992
+ "pageCount": page_count,
993
+ "timings": {
994
+ "uploadMs": upload_ms,
995
+ "hashMs": 0,
996
+ "memCheckMs": 0,
997
+ "decodeMs": 0,
998
+ "resizeMs": 0,
999
+ "detectMs": 0,
1000
+ "recognizeMs": extract_ms,
1001
+ "postProcessMs": 0,
1002
+ "totalMs": 0,
1003
+ },
1004
+ "metadata": {
1005
+ "imgW": 0, "imgH": 0,
1006
+ "imgWResized": 0, "imgHResized": 0,
1007
+ "wasResized": False,
1008
+ "textBlocks": plain_text.count("\n") + 1,
1009
+ "passesUsed": 1,
1010
+ "backend": f"office_text_{ext}",
1011
+ },
1012
+ }
1013
+
1014
+
1015
+ def _extract_docx(raw: bytes) -> tuple[str, int]:
1016
+ """Extract plain text from a DOCX file. Returns (text, page_estimate)."""
1017
+ try:
1018
+ import docx as _docx
1019
+ except ImportError as exc:
1020
+ raise _err("decode", "DOCX_DEPS_MISSING",
1021
+ "python-docx is not installed.", 503,
1022
+ recommendation="Add python-docx to Dockerfile Layer 1.") from exc
1023
+ doc = _docx.Document(io.BytesIO(raw))
1024
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
1025
+ # Tables
1026
+ for table in doc.tables:
1027
+ for row in table.rows:
1028
+ row_text = " | ".join(
1029
+ cell.text.strip() for cell in row.cells if cell.text.strip()
1030
+ )
1031
+ if row_text:
1032
+ paragraphs.append(row_text)
1033
+ text = "\n".join(paragraphs)
1034
+ # Rough page estimate: ~3 000 chars per page
1035
+ pages = max(1, len(text) // 3000)
1036
+ return text, pages
1037
+
1038
+
1039
+ def _extract_pptx(raw: bytes) -> tuple[str, int]:
1040
+ """Extract plain text from a PPTX file. Returns (text, slide_count)."""
1041
+ try:
1042
+ from pptx import Presentation as _Presentation
1043
+ except ImportError as exc:
1044
+ raise _err("decode", "PPTX_DEPS_MISSING",
1045
+ "python-pptx is not installed.", 503,
1046
+ recommendation="Add python-pptx to Dockerfile Layer 1.") from exc
1047
+ prs = _Presentation(io.BytesIO(raw))
1048
+ lines: list[str] = []
1049
+ for slide_num, slide in enumerate(prs.slides, 1):
1050
+ lines.append(f"--- Slide {slide_num} ---")
1051
+ for shape in slide.shapes:
1052
+ if hasattr(shape, "text") and shape.text.strip():
1053
+ lines.append(shape.text.strip())
1054
+ return "\n".join(lines), len(prs.slides)
1055
+
1056
+
1057
+ def _extract_xlsx(raw: bytes) -> tuple[str, int]:
1058
+ """Extract plain text from an XLSX file. Returns (text, sheet_count)."""
1059
+ try:
1060
+ import openpyxl as _openpyxl
1061
+ except ImportError as exc:
1062
+ raise _err("decode", "XLSX_DEPS_MISSING",
1063
+ "openpyxl is not installed.", 503,
1064
+ recommendation="Add openpyxl to Dockerfile Layer 1.") from exc
1065
+ wb = _openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
1066
+ lines: list[str] = []
1067
+ for sheet in wb.worksheets:
1068
+ lines.append(f"--- Sheet: {sheet.title} ---")
1069
+ for row in sheet.iter_rows(values_only=True):
1070
+ row_text = " | ".join(
1071
+ str(cell) for cell in row if cell is not None and str(cell).strip()
1072
+ )
1073
+ if row_text:
1074
+ lines.append(row_text)
1075
+ wb.close()
1076
+ return "\n".join(lines), len(wb.worksheets)
1077
+
1078
+
1079
+ # ═════════════════════════════════════════════════════════════════════════════
1080
+ # PDF processor β€” MinerU
1081
+ # ═════════════════════════════════════════════════════════════════════════════
1082
+ def _process_pdf(
1083
+ raw: bytes, filename: str, work_dir: str, upload_ms: int = 0
1084
+ ) -> dict[str, Any]:
1085
+ from magic_pdf.data.data_reader_writer import FileBasedDataWriter
1086
+ from magic_pdf.data.dataset import PymuDocDataset
1087
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
1088
+ from magic_pdf.config.enums import SupportedPdfParseMethod
1089
+
1090
+ images_dir = os.path.join(work_dir, "images")
1091
+ os.makedirs(images_dir, exist_ok=True)
1092
+ page_count = _pdf_page_count(raw)
1093
+
1094
+ logger.info("pdf_classify file=%s pages=%d", filename, page_count)
1095
+ t_classify = time.perf_counter()
1096
+ try:
1097
+ ds = PymuDocDataset(raw)
1098
+ method = ds.classify()
1099
+ except Exception as exc:
1100
+ raise _err(
1101
+ "decode", "PDF_PARSE_FAILED", f"Could not parse PDF: {exc}", 422,
1102
+ root_cause=str(exc),
1103
+ recommendation="Ensure the file is a valid, non-encrypted PDF.",
1104
+ ) from exc
1105
+ classify_ms = int((time.perf_counter() - t_classify) * 1000)
1106
+
1107
+ logger.info("ocr_started file=%s engine=mineru method=%s", filename, method)
1108
+ t_ocr = time.perf_counter()
1109
+ try:
1110
+ image_writer = FileBasedDataWriter(images_dir)
1111
+ if method == SupportedPdfParseMethod.TXT:
1112
+ infer_result = ds.apply(doc_analyze, ocr=False)
1113
+ pipe_result = infer_result.pipe_txt_mode(image_writer)
1114
+ parse_method = "txt"
1115
+ else:
1116
+ infer_result = ds.apply(doc_analyze, ocr=True)
1117
+ pipe_result = infer_result.pipe_ocr_mode(image_writer)
1118
+ parse_method = "ocr"
1119
+ except Exception as exc:
1120
+ raise _err(
1121
+ "ocr", "OCR_PIPELINE_FAILED", f"doc_analyze/pipe failed: {exc}", 500,
1122
+ root_cause=str(exc),
1123
+ recommendation="Check model files in /app/models and validate.py output.",
1124
+ ) from exc
1125
+ ocr_ms = int((time.perf_counter() - t_ocr) * 1000)
1126
+ logger.info("ocr_finished file=%s engine=mineru ocr_ms=%d", filename, ocr_ms)
1127
+
1128
+ t_md = time.perf_counter()
1129
+ try:
1130
+ markdown = pipe_result.get_markdown(images_dir)
1131
+ except Exception as exc:
1132
+ raise _err("markdown", "MARKDOWN_FAILED", f"get_markdown failed: {exc}") from exc
1133
+ md_ms = int((time.perf_counter() - t_md) * 1000)
1134
+
1135
+ plain_text = _markdown_to_plain(markdown)
1136
+
1137
+ return {
1138
+ "success": True,
1139
+ "filename": filename,
1140
+ "engine": "mineru",
1141
+ "confidence": 0.9 if parse_method == "txt" else 0.85,
1142
+ "text": plain_text,
1143
+ "markdown": markdown,
1144
+ "pageCount": page_count,
1145
+ "timings": {
1146
+ "uploadMs": upload_ms,
1147
+ "hashMs": 0,
1148
+ "memCheckMs": 0,
1149
+ "decodeMs": classify_ms,
1150
+ "resizeMs": 0,
1151
+ "detectMs": 0,
1152
+ "recognizeMs": ocr_ms,
1153
+ "postProcessMs": md_ms,
1154
+ "totalMs": 0,
1155
+ },
1156
+ "metadata": {
1157
+ "imgW": 0, "imgH": 0,
1158
+ "imgWResized": 0, "imgHResized": 0,
1159
+ "wasResized": False,
1160
+ "textBlocks": 0,
1161
+ "passesUsed": 1,
1162
+ "backend": "pipeline",
1163
+ "parseMethod": parse_method,
1164
+ "pages": page_count,
1165
+ },
1166
+ }
1167
+
1168
+
1169
+ # ═════════════════════════════════════════════════════════════════════════════
1170
+ # Image helpers
1171
+ # ════════��════════════════════════════════════════════════════════════════════
1172
+ def _resize_for_ocr(img: "np.ndarray") -> tuple["np.ndarray", bool]:
1173
+ """
1174
+ Resize image so the longest side is at most MAX_OCR_SIDE pixels.
1175
+
1176
+ Returns (resized_img, was_resized).
1177
+
1178
+ Uses cv2.INTER_AREA which is the correct algorithm for downscaling:
1179
+ it averages pixels (anti-aliasing) rather than sampling individual pixels,
1180
+ preserving text legibility at smaller sizes.
1181
+
1182
+ No upscaling: images smaller than MAX_OCR_SIDE are returned unchanged.
1183
+ """
1184
+ import cv2
1185
+ h, w = img.shape[:2]
1186
+ longest = max(h, w)
1187
+ if longest <= MAX_OCR_SIDE:
1188
+ return img, False
1189
+ scale = MAX_OCR_SIDE / longest
1190
+ new_w = int(w * scale)
1191
+ new_h = int(h * scale)
1192
+ resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
1193
+ return resized, True
1194
+
1195
+
1196
+ def _decode_image_to_bgr(raw: bytes, ext: str) -> "np.ndarray":
1197
+ import cv2
1198
+ if ext in {"heic", "heif"}:
1199
+ try:
1200
+ from pillow_heif import register_heif_opener
1201
+ register_heif_opener()
1202
+ except ImportError:
1203
+ raise _err(
1204
+ "decode", "HEIF_NOT_SUPPORTED",
1205
+ "HEIC/HEIF requires pillow-heif.", 415,
1206
+ recommendation="Add pillow-heif to Dockerfile Layer 1.",
1207
+ )
1208
+ try:
1209
+ pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
1210
+ buf = io.BytesIO()
1211
+ pil_img.save(buf, format="PNG")
1212
+ raw = buf.getvalue()
1213
+ except Exception as exc:
1214
+ raise _err("decode", "HEIF_DECODE_FAILED",
1215
+ f"HEIF decode error: {exc}") from exc
1216
+
1217
+ arr = np.frombuffer(raw, np.uint8)
1218
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
1219
+ if img is None:
1220
+ try:
1221
+ pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
1222
+ img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
1223
+ except Exception as exc:
1224
+ raise _err(
1225
+ "decode", "IMAGE_DECODE_FAILED",
1226
+ f"Could not decode image: {exc}", 422,
1227
+ root_cause=str(exc),
1228
+ recommendation="Ensure the file is a valid, non-corrupted image.",
1229
+ ) from exc
1230
+ return img
1231
+
1232
+
1233
+ def _convert_to_png(raw: bytes, ext: str) -> bytes:
1234
+ if ext in {"heic", "heif"}:
1235
+ try:
1236
+ from pillow_heif import register_heif_opener
1237
+ register_heif_opener()
1238
+ except ImportError:
1239
+ raise _err("decode", "HEIF_NOT_SUPPORTED",
1240
+ "HEIC/HEIF requires pillow-heif.", 415)
1241
+ try:
1242
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
1243
+ buf = io.BytesIO()
1244
+ img.save(buf, format="PNG")
1245
+ return buf.getvalue()
1246
+ except Exception as exc:
1247
+ raise _err("decode", "IMAGE_DECODE_FAILED",
1248
+ f"Pillow could not open image: {exc}", 422) from exc
1249
+
1250
+
1251
+ # ═════════════════════════════════════════════════════════════════════════════
1252
+ # RapidOCR output parser
1253
+ # Returns (plain_text, mean_confidence)
1254
+ # ═════════════════════════════════════════════════════════════════════════════
1255
+ def _parse_rapidocr_output(result: Any) -> tuple[str, float]:
1256
+ if not result:
1257
+ return "", 0.0
1258
+
1259
+ def _avg_y(item: Any) -> float:
1260
+ box = item[0]
1261
+ try:
1262
+ return sum(pt[1] for pt in box) / 4
1263
+ except Exception:
1264
+ return 0.0
1265
+
1266
+ def _avg_x(item: Any) -> float:
1267
+ box = item[0]
1268
+ try:
1269
+ return sum(pt[0] for pt in box) / 4
1270
+ except Exception:
1271
+ return 0.0
1272
+
1273
+ sorted_items = sorted(result, key=_avg_y)
1274
+
1275
+ LINE_GAP = 20
1276
+ lines: list[list[Any]] = []
1277
+ if sorted_items:
1278
+ current: list[Any] = [sorted_items[0]]
1279
+ for item in sorted_items[1:]:
1280
+ if abs(_avg_y(item) - _avg_y(current[-1])) < LINE_GAP:
1281
+ current.append(item)
1282
+ else:
1283
+ lines.append(current)
1284
+ current = [item]
1285
+ lines.append(current)
1286
+
1287
+ text_lines: list[str] = []
1288
+ for line in lines:
1289
+ words = sorted(line, key=_avg_x)
1290
+ text_lines.append(" ".join(str(item[1]) for item in words if len(item) > 1))
1291
+
1292
+ plain_text = "\n".join(text_lines)
1293
+ scores = [item[2] for item in result if len(item) > 2 and item[2] is not None]
1294
+ mean_conf = float(sum(scores) / len(scores)) if scores else 0.5
1295
+ return plain_text, round(mean_conf, 4)
1296
+
1297
+
1298
+ def _split_elapse(elapse: Any, total_ms: int) -> tuple[int, int]:
1299
+ """
1300
+ Extract det_ms / rec_ms from RapidOCR's elapse return value.
1301
+
1302
+ rapidocr-onnxruntime β‰₯ 1.3 returns a dict: {"det": s, "rec": s, "cls": s}.
1303
+ Older versions return a scalar total. We handle both.
1304
+ """
1305
+ if isinstance(elapse, dict):
1306
+ det_ms = int(elapse.get("det", 0) * 1000)
1307
+ rec_ms = int(elapse.get("rec", 0) * 1000)
1308
+ return det_ms, rec_ms
1309
+ # Scalar fallback β€” measured total, no reliable split available
1310
+ return 0, total_ms
1311
+
1312
+
1313
+ # ═════════════════════════════════════════════════════════════════════════════
1314
+ # Misc helpers
1315
+ # ═════════════════════════════════════════════════════════════════════════════
1316
+ def _sanitize_filename(name: str) -> str:
1317
+ name = os.path.basename(name)
1318
+ name = re.sub(r"[^\w.\-]", "_", name)
1319
+ return name[:200] or "upload"
1320
+
1321
+
1322
+ def _markdown_to_plain(markdown: str) -> str:
1323
+ text = re.sub(r"!\[.*?\]\(.*?\)", "", markdown)
1324
+ text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
1325
+ text = re.sub(r"#{1,6}\s*", "", text)
1326
+ text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", text)
1327
+ text = re.sub(r"`{1,3}[^`]*`{1,3}", "", text)
1328
+ text = re.sub(r"\|", " ", text)
1329
+ text = re.sub(r"-{3,}", "", text)
1330
+ text = re.sub(r"\n{3,}", "\n\n", text)
1331
+ return text.strip()
1332
+
1333
+
1334
+ def _pdf_page_count(raw: bytes) -> int:
1335
+ try:
1336
+ doc = fitz.open(stream=raw, filetype="pdf")
1337
+ count = doc.page_count
1338
+ doc.close()
1339
+ return count
1340
+ except Exception:
1341
+ return 1
1342
+
1343
+
1344
+ def _mem_mb() -> tuple[int, int]:
1345
+ try:
1346
+ import psutil
1347
+ vm = psutil.virtual_memory()
1348
+ return (vm.total - vm.available) // (1024 * 1024), vm.total // (1024 * 1024)
1349
+ except Exception:
1350
+ pass
1351
+ try:
1352
+ info: dict[str, int] = {}
1353
+ with open("/proc/meminfo") as f:
1354
+ for line in f:
1355
+ parts = line.split()
1356
+ if len(parts) >= 2:
1357
+ info[parts[0].rstrip(":")] = int(parts[1])
1358
+ total_kb = info.get("MemTotal", 0)
1359
+ avail_kb = info.get("MemAvailable", 0)
1360
+ return (total_kb - avail_kb) // 1024, total_kb // 1024
1361
+ except Exception:
1362
+ return 0, 0
1363
+
1364
+
1365
+ def _assert_memory_safe(raw: bytes, ext: str) -> None:
1366
+ """
1367
+ Reject requests that would likely exhaust available RAM.
1368
+
1369
+ For images: estimate from raw byte count only (no Pillow decode needed β€”
1370
+ avoids the double-decode that existed in v3.0). Raw JPEG at 3 MP β‰ˆ 1–3 MB;
1371
+ the decompressed BGR array is w*h*3 bytes. We conservatively multiply by
1372
+ IMAGE_MEMORY_FACTOR to cover both the decode buffer and OCR working memory.
1373
+ """
1374
+ used_mb, total_mb = _mem_mb()
1375
+ if total_mb == 0:
1376
+ return
1377
+ available_mb = total_mb - used_mb
1378
+ if ext in PDF_EXTENSIONS:
1379
+ page_count = max(1, _pdf_page_count(raw))
1380
+ estimated_mb = (page_count * BYTES_PER_OCR_PAGE) // (1024 * 1024)
1381
+ else:
1382
+ # Estimate from compressed size β€” no Pillow decode required.
1383
+ # Compressed-to-raw expansion ratio for JPEG β‰ˆ 10–20Γ—; we use 20Γ— and
1384
+ # multiply by IMAGE_MEMORY_FACTOR for working memory overhead.
1385
+ estimated_mb = len(raw) * 20 * IMAGE_MEMORY_FACTOR // (1024 * 1024)
1386
+
1387
+ free_after = available_mb - estimated_mb
1388
+ logger.info(
1389
+ "memory_check avail_mb=%d est_mb=%d free_after_mb=%d",
1390
+ available_mb, estimated_mb, free_after,
1391
+ )
1392
+ if free_after < MEM_SAFETY_FLOOR_MB:
1393
+ raise _err(
1394
+ "validation", "LOW_MEMORY",
1395
+ f"Insufficient memory. Available: {available_mb} MB, "
1396
+ f"Estimated needed: {estimated_mb} MB.", 507,
1397
+ root_cause=f"Container has {available_mb} MB free; "
1398
+ f"pipeline needs ~{estimated_mb} MB.",
1399
+ recommendation="Wait for active requests to complete, "
1400
+ "or use a smaller file.",
1401
+ )
requirements.txt ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reference only β€” dependencies are installed directly in the Dockerfile
2
+ # to allow layered pip caching. See Dockerfile for the authoritative install order.
3
+ #
4
+ # ── Layer 1 β€” FastAPI + lightweight runtime deps ──────────────────────────────
5
+ fastapi>=0.115.0
6
+ uvicorn[standard]>=0.32.0
7
+ python-multipart>=0.0.12
8
+ Pillow>=10.0.0
9
+ pillow-heif>=0.18.0
10
+ huggingface_hub>=0.25.0
11
+ opencv-python-headless>=4.8.0 # placeholder; force-reinstalled in Layer 4
12
+ #
13
+ # ── Layer 2 β€” CPU-only PyTorch ────────────────────────────────────────────────
14
+ # pip install --index-url https://download.pytorch.org/whl/cpu torch torchvision
15
+ # MUST come before magic-pdf. PyPI serves the CUDA wheel by default (~2.5 GB).
16
+ # Pre-installing the CPU build causes pip to skip the CUDA wheel when resolving
17
+ # magic-pdf's `torch` requirement.
18
+ torch>=2.2.2,!=2.5.0,!=2.5.1,<3
19
+ torchvision>=0.15.2
20
+ #
21
+ # ── Layer 3 β€” magic-pdf with CORRECT extras ───────────────────────────────────
22
+ # pip install --extra-index-url https://myhloli.github.io/wheels/ magic-pdf[full]==1.3.12
23
+ #
24
+ # FORENSIC SUMMARY:
25
+ #
26
+ # [full-cpu] is NOT a valid extra in magic-pdf 1.3.12.
27
+ # Valid extras: [full], [full_old_linux], [lite]
28
+ # Using an invalid extra causes pip to install base-only β€” omitting
29
+ # ultralytics, doclayout-yolo, rapid-table, and OCR support entirely.
30
+ #
31
+ # [full] provides:
32
+ # ultralytics >=8.3.48 YOLO framework, required by doclayout-yolo
33
+ # doclayout-yolo ==0.0.2b1 layout detection (myhloli index only)
34
+ # rapid-table >=1.0.5 table detection (onnxruntime pulled as transitive dep)
35
+ # shapely, pyclipper used by paddleocr2pytorch (baked into magic-pdf wheel)
36
+ # omegaconf, matplotlib, ftfy, dill, PyYAML, openai, albumentations
37
+ #
38
+ # [lite] is NOT used because:
39
+ # - paddlepaddle==3.0.0b1 pinned by [lite] does not exist on PyPI (removed beta)
40
+ # - paddleocr==2.7.3 requires opencv-python <=4.6.0.66, incompatible with
41
+ # ultralytics (>=4.6.0) and doclayout-yolo (>=4.6.0)
42
+ # - paddlepaddle/paddleocr are NOT needed: pipeline backend uses paddleocr2pytorch,
43
+ # a self-contained PyTorch reimplementation bundled inside the magic-pdf wheel
44
+ #
45
+ # --extra-index-url https://myhloli.github.io/wheels/ is required because
46
+ # doclayout-yolo==0.0.2b1 is only on the myhloli wheel server, not on PyPI.
47
+ magic-pdf[full]==1.3.12
48
+ #
49
+ # ── Layer 4 β€” Restore headless OpenCV (MUST be last) ─────────────────────────
50
+ # pip install --force-reinstall opencv-python-headless>=4.8.0
51
+ # Layer 3 installs opencv-python (non-headless) via doclayout-yolo + ultralytics
52
+ # + rapid-table transitive deps. Force-reinstalling headless overwrites cv2 with
53
+ # the build that works on this container without X11 libs.
54
+ opencv-python-headless>=4.8.0 # force-reinstalled in Layer 4
validate.py ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pre-flight validation script for MinerU OCR Service.
4
+
5
+ Run by entrypoint.sh BEFORE uvicorn starts.
6
+ Exits 0 if all checks pass.
7
+ Exits 1 if any CRITICAL check fails β€” this crashes the container loudly
8
+ so Hugging Face logs show an actionable error instead of a silent crash
9
+ or a healthy-looking service that fails on every request.
10
+
11
+ Usage:
12
+ python validate.py # run all checks, exit 0/1
13
+ python validate.py --soft # run all checks, always exit 0 (log only)
14
+
15
+ ── FORENSIC NOTES (2025-06) ──────────────────────────────────────────────────
16
+
17
+ OCR engine:
18
+ The pipeline (full) backend uses paddleocr2pytorch β€” a self-contained
19
+ PyTorch reimplementation of PaddleOCR bundled inside the magic-pdf wheel.
20
+ It uses: torch, cv2, numpy, pyclipper, shapely, yaml.
21
+ paddlepaddle and paddleocr packages are NOT installed and NOT needed.
22
+
23
+ pp_structure_v2.py (which imports paddleocr) is only loaded in 'lite' model
24
+ mode. Pipeline backend always uses 'full' mode (CustomPEKModel). That file is
25
+ never imported at runtime.
26
+
27
+ OCR model path resolution (from pytorch_paddle.py):
28
+ ocr_models_dir = os.path.join(get_local_models_dir(), 'OCR', 'paddleocr_torch')
29
+ det_model_path = os.path.join(ocr_models_dir, det_filename)
30
+ where det_filename comes from models_config.yml keyed by language.
31
+
32
+ Default CPU path: lang='ch' β†’ forced to 'ch_lite' on CPU device.
33
+ After Dockerfile Layer 3.5 patch:
34
+ ch_lite.det = ch_PP-OCRv5_det_infer.pth (was ch_PP-OCRv3 β€” not in HF repo)
35
+ ch_lite.rec = ch_PP-OCRv5_rec_infer.pth (unchanged β€” already in HF repo)
36
+
37
+ Arch config lookup (from pytorchocr_utility.py):
38
+ get_arch_config(model_path) uses Path(model_path).stem as the key into
39
+ arch_config.yaml (bundled in magic-pdf wheel). Both replacement filenames
40
+ have entries in arch_config.yaml β€” verified before patch was written.
41
+
42
+ OpenCV conflict handling:
43
+ doclayout-yolo, ultralytics, and rapid-table all declare opencv-python
44
+ (non-headless) as a required dep. pip installs the full build in Layer 3.
45
+ Layer 4 force-reinstalls opencv-python-headless to overwrite cv2. Both
46
+ packages expose an identical cv2 API so all callers work correctly at
47
+ runtime. pip-check shows warnings but they are harmless.
48
+
49
+ onnxruntime:
50
+ rapid-table declares onnxruntime>1.17.0 as a required (non-optional) dep.
51
+ pip resolves it automatically when magic-pdf[full] is installed in Layer 3.
52
+
53
+ slanet-plus.onnx (table model):
54
+ Bundled inside the magic-pdf wheel at:
55
+ magic_pdf/resources/slanet_plus/slanet-plus.onnx
56
+ NOT downloaded from HF Hub β€” no separate download needed.
57
+ """
58
+
59
+ import importlib
60
+ import json
61
+ import os
62
+ import shutil
63
+ import sys
64
+ import tempfile
65
+ import traceback
66
+
67
+ SOFT_MODE = "--soft" in sys.argv # never exit 1, just print
68
+
69
+ MODELS_DIR = "/app/models"
70
+ EXTRACT_KIT_MODELS = os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0", "models")
71
+ LAYOUT_MARKER = os.path.join(EXTRACT_KIT_MODELS, "Layout") # canary directory
72
+ CONFIG_PATH = os.path.expanduser("~/magic-pdf.json")
73
+
74
+
75
+ # ── helpers ────────────────────────────────────────────────────────────────────
76
+ def ok(label: str, detail: str = "") -> None:
77
+ suffix = f" ({detail})" if detail else ""
78
+ print(f" βœ“ {label}{suffix}", flush=True)
79
+
80
+
81
+ def fail(label: str, detail: str, critical: bool = True) -> None:
82
+ tag = "CRITICAL" if critical else "WARNING"
83
+ print(f" βœ— [{tag}] {label}: {detail}", flush=True)
84
+
85
+
86
+ def section(title: str) -> None:
87
+ print(f"\n{'─' * 60}", flush=True)
88
+ print(f" {title}", flush=True)
89
+ print(f"{'─' * 60}", flush=True)
90
+
91
+
92
+ # ── check registry ─────────────────────────────────────────────────────────────
93
+ failures: list[tuple[str, str]] = []
94
+ warnings: list[tuple[str, str]] = []
95
+
96
+
97
+ def record_fail(label: str, detail: str, critical: bool = True) -> None:
98
+ fail(label, detail, critical)
99
+ if critical:
100
+ failures.append((label, detail))
101
+ else:
102
+ warnings.append((label, detail))
103
+
104
+
105
+ # ═══════════════════════════════════════════════════════════════════════════════
106
+ print("\n" + "═" * 60, flush=True)
107
+ print(" MinerU OCR Service β€” Pre-flight Validation", flush=True)
108
+ print("═" * 60, flush=True)
109
+
110
+ # ── 1. Python version ──────────────────────────────────────────────────────────
111
+ section("1. Python runtime")
112
+ pv = sys.version_info
113
+ if pv >= (3, 10):
114
+ ok("Python version", f"{pv.major}.{pv.minor}.{pv.micro}")
115
+ else:
116
+ record_fail("Python version",
117
+ f"{pv.major}.{pv.minor} detected β€” magic-pdf requires >= 3.10")
118
+
119
+ # ── 2. cv2 ─────────────────────────────────────────────────────────────────────
120
+ section("2. OpenCV (cv2)")
121
+ try:
122
+ import cv2
123
+ ok("cv2 import", f"version {cv2.__version__}")
124
+ build = cv2.getBuildInformation()
125
+ if "GTK" in build or "Qt" in build:
126
+ record_fail("cv2 build", "GUI backend detected β€” use opencv-python-headless",
127
+ critical=False)
128
+ else:
129
+ ok("cv2 headless", "no GUI backend detected")
130
+ except ImportError as exc:
131
+ record_fail(
132
+ "cv2 import",
133
+ f"{exc}. "
134
+ "Layer 4 force-reinstall of opencv-python-headless may have failed. "
135
+ "Check Docker build log for the 'pip install --force-reinstall opencv-python-headless' step.",
136
+ )
137
+ except Exception as exc:
138
+ record_fail("cv2 import", f"unexpected error: {exc}")
139
+
140
+ # ── 3. PyTorch ─────────────────────────────────────────────────────────────────
141
+ section("3. PyTorch + TorchVision")
142
+ try:
143
+ import torch
144
+ ok("torch import", f"version {torch.__version__}")
145
+ if torch.cuda.is_available():
146
+ record_fail("torch CUDA", "CUDA detected on CPU-only space β€” unexpected",
147
+ critical=False)
148
+ else:
149
+ ok("torch device", "CPU-only (expected for free tier)")
150
+ except ImportError as exc:
151
+ record_fail(
152
+ "torch import",
153
+ f"{exc}. "
154
+ "Install from PyTorch CPU index BEFORE magic-pdf in Dockerfile Layer 2: "
155
+ "pip install --index-url https://download.pytorch.org/whl/cpu torch torchvision",
156
+ )
157
+ except Exception as exc:
158
+ record_fail("torch import", f"unexpected: {exc}")
159
+
160
+ try:
161
+ import torchvision
162
+ ok("torchvision import", f"version {torchvision.__version__}")
163
+ except ImportError as exc:
164
+ record_fail("torchvision import", str(exc))
165
+ except Exception as exc:
166
+ record_fail("torchvision import", f"unexpected: {exc}")
167
+
168
+ # ── 4. ultralytics ─────────────────────────────────────────────────────────────
169
+ section("4. ultralytics (YOLO β€” required by doclayout_yolo)")
170
+ try:
171
+ import ultralytics
172
+ ok("ultralytics import", f"version {ultralytics.__version__}")
173
+ except ImportError as exc:
174
+ record_fail(
175
+ "ultralytics import",
176
+ f"{exc}. "
177
+ "Provided by magic-pdf[full]. "
178
+ "ROOT CAUSE: [full-cpu] is NOT a valid extra in magic-pdf 1.3.12 β€” "
179
+ "pip silently installed only the base package when given an unknown extra. "
180
+ "Dockerfile Layer 3 must use magic-pdf[full]==1.3.12 (not [full-cpu]).",
181
+ )
182
+ except Exception as exc:
183
+ record_fail("ultralytics import", f"unexpected: {exc}")
184
+
185
+ # ── 5. doclayout_yolo ──────────────────────────────────────────────────────────
186
+ section("5. doclayout_yolo (layout detection model)")
187
+ try:
188
+ import doclayout_yolo
189
+ ok("doclayout_yolo import", f"version {getattr(doclayout_yolo, '__version__', 'unknown')}")
190
+ except ImportError as exc:
191
+ record_fail(
192
+ "doclayout_yolo import",
193
+ f"{exc}. "
194
+ "Provided by magic-pdf[full] (version 0.0.2b1). "
195
+ "doclayout-yolo==0.0.2b1 is only on the myhloli custom wheel index β€” "
196
+ "Dockerfile Layer 3 must include: "
197
+ "--extra-index-url https://myhloli.github.io/wheels/",
198
+ )
199
+ except Exception as exc:
200
+ record_fail("doclayout_yolo import", f"unexpected: {exc}")
201
+
202
+ # ── 6. rapid_table ─────────────────────────────────────────────────────────────
203
+ section("6. rapid_table (table extraction)")
204
+ try:
205
+ import rapid_table
206
+ ok("rapid_table import", f"version {getattr(rapid_table, '__version__', 'unknown')}")
207
+ except ImportError as exc:
208
+ record_fail(
209
+ "rapid_table import",
210
+ f"{exc}. Provided by magic-pdf[full]. Check Layer 3 install.",
211
+ )
212
+ except Exception as exc:
213
+ record_fail("rapid_table import", f"unexpected: {exc}")
214
+
215
+ # ── 7. onnxruntime ─────────────────────────────────────────────────────────────
216
+ section("7. onnxruntime (required by rapid-table for table model inference)")
217
+ # onnxruntime is a required (non-optional) dep of rapid-table>=1.0.5.
218
+ # pip resolves it automatically when magic-pdf[full] is installed in Layer 3.
219
+ # If it is missing it means rapid-table itself failed to install.
220
+ try:
221
+ import onnxruntime
222
+ ok("onnxruntime import", f"version {onnxruntime.__version__}")
223
+ except ImportError as exc:
224
+ record_fail(
225
+ "onnxruntime import",
226
+ f"{exc}. "
227
+ "onnxruntime is a required dep of rapid-table>=1.0.5. "
228
+ "Its absence means rapid-table failed to install in Layer 3. "
229
+ "Check Docker build log for rapid-table install errors.",
230
+ )
231
+ except Exception as exc:
232
+ record_fail("onnxruntime import", f"unexpected: {exc}")
233
+
234
+ # ── 8. magic_pdf core imports ──────────────────────────────────────────────────
235
+ section("8. magic_pdf core imports")
236
+
237
+ REQUIRED_IMPORTS = [
238
+ ("magic_pdf.data.dataset", ["PymuDocDataset", "ImageDataset"]),
239
+ ("magic_pdf.data.data_reader_writer", ["FileBasedDataReader", "FileBasedDataWriter"]),
240
+ ("magic_pdf.model.doc_analyze_by_custom_model", ["doc_analyze"]),
241
+ ("magic_pdf.config.enums", ["SupportedPdfParseMethod"]),
242
+ ]
243
+
244
+ for module_path, symbols in REQUIRED_IMPORTS:
245
+ try:
246
+ mod = importlib.import_module(module_path)
247
+ missing = [s for s in symbols if not hasattr(mod, s)]
248
+ if missing:
249
+ record_fail(f"{module_path}", f"missing symbols: {missing}")
250
+ else:
251
+ ok(module_path, ", ".join(symbols))
252
+ except ImportError as exc:
253
+ record_fail(module_path, str(exc))
254
+ except Exception as exc:
255
+ record_fail(module_path, f"unexpected: {exc}")
256
+
257
+ # ── 8b. paddleocr2pytorch (OCR engine bundled inside magic-pdf wheel) ──────────
258
+ section("8b. paddleocr2pytorch (PyTorch OCR β€” bundled in magic-pdf wheel)")
259
+ # This is the actual OCR engine for the pipeline backend.
260
+ # It is NOT a separate pip package β€” it lives inside the magic-pdf wheel at
261
+ # magic_pdf/model/sub_modules/ocr/paddleocr2pytorch/
262
+ # If it is missing, the entire magic-pdf package did not install correctly.
263
+ try:
264
+ from magic_pdf.model.sub_modules.ocr.paddleocr2pytorch.pytorch_paddle import PytorchPaddleOCR
265
+ ok("PytorchPaddleOCR (paddleocr2pytorch)", "bundled inside magic-pdf wheel β€” no paddlepaddle pkg needed")
266
+ except ImportError as exc:
267
+ record_fail(
268
+ "PytorchPaddleOCR import",
269
+ f"{exc}. "
270
+ "This module is bundled inside magic_pdf/model/sub_modules/ocr/paddleocr2pytorch/. "
271
+ "If missing, magic-pdf itself did not install correctly.",
272
+ )
273
+ except Exception as exc:
274
+ record_fail("PytorchPaddleOCR import", f"unexpected: {exc}")
275
+
276
+ # ── 8c. Deprecated API check ───────────────────────────────────────────────────
277
+ section("8c. Deprecated API check (should NOT exist)")
278
+ OBSOLETE = [
279
+ "magic_pdf.pipe.UNIPipe",
280
+ "magic_pdf.rw.DiskReaderWriter",
281
+ ]
282
+ for mod_path in OBSOLETE:
283
+ try:
284
+ importlib.import_module(mod_path)
285
+ record_fail(mod_path, "still importable β€” code may use old API", critical=False)
286
+ except ImportError:
287
+ ok(f"{mod_path} (correctly absent)")
288
+
289
+ # ── 9. End-to-end pipeline smoke test ─────────────────────────────────────────
290
+ section("9. End-to-end pipeline smoke test")
291
+ try:
292
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze # noqa: F401
293
+ import ultralytics # noqa: F401
294
+ from magic_pdf.data.dataset import ImageDataset # noqa: F401
295
+ from magic_pdf.data.data_reader_writer import FileBasedDataReader, FileBasedDataWriter # noqa: F401
296
+ ok("Pipeline imports (doc_analyze + ultralytics + ImageDataset + readers)", "all OK")
297
+ except ImportError as exc:
298
+ record_fail(
299
+ "Pipeline smoke test",
300
+ f"Full pipeline import chain failed: {exc}. "
301
+ "This means POST /extract will fail on every request.",
302
+ )
303
+ except Exception as exc:
304
+ record_fail("Pipeline smoke test", f"unexpected: {exc}")
305
+
306
+ # ── 10. Config file ────────────────────────────────────────────────────────────
307
+ section("10. MinerU config (magic-pdf.json)")
308
+ _cfg: dict = {}
309
+ if os.path.exists(CONFIG_PATH):
310
+ try:
311
+ with open(CONFIG_PATH) as f:
312
+ _cfg = json.load(f)
313
+ required_keys = ["models-dir", "device-mode"]
314
+ missing_keys = [k for k in required_keys if k not in _cfg]
315
+ if missing_keys:
316
+ record_fail("Config keys", f"missing: {missing_keys}")
317
+ else:
318
+ ok("Config file", CONFIG_PATH)
319
+ ok("device-mode", _cfg.get("device-mode", "?"))
320
+ ok("models-dir", _cfg.get("models-dir", "?"))
321
+ ok("formula-enable", str(_cfg.get("formula-config", {}).get("enable", "?")))
322
+ ok("table-enable", str(_cfg.get("table-config", {}).get("enable", "?")))
323
+ except json.JSONDecodeError as exc:
324
+ record_fail("Config file", f"invalid JSON: {exc}")
325
+ except Exception as exc:
326
+ record_fail("Config file", str(exc))
327
+ else:
328
+ record_fail(
329
+ "Config file",
330
+ f"not found at {CONFIG_PATH}. "
331
+ "Run download_models.py or check Docker build log.",
332
+ )
333
+
334
+ # ── 11. Model directory structure ─────────────────────────────────────────────
335
+ section("11. Model directory structure")
336
+
337
+ model_dir_checks = [
338
+ ("PDF-Extract-Kit-1.0 root", os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0")),
339
+ ("Layout models", os.path.join(EXTRACT_KIT_MODELS, "Layout")),
340
+ ("Layout/YOLO", os.path.join(EXTRACT_KIT_MODELS, "Layout", "YOLO")),
341
+ ("OCR models", os.path.join(EXTRACT_KIT_MODELS, "OCR")),
342
+ ("OCR/paddleocr_torch", os.path.join(EXTRACT_KIT_MODELS, "OCR", "paddleocr_torch")),
343
+ ("Table models (TabRec)", os.path.join(EXTRACT_KIT_MODELS, "TabRec")),
344
+ ]
345
+
346
+ for label, path in model_dir_checks:
347
+ if os.path.isdir(path):
348
+ try:
349
+ n = sum(1 for _ in os.scandir(path))
350
+ ok(label, f"{n} entries [{path}]")
351
+ except OSError:
352
+ ok(label, path)
353
+ else:
354
+ record_fail(label, f"directory not found: {path}")
355
+
356
+ lr_dir = os.path.join(MODELS_DIR, "layoutreader")
357
+ if os.path.isdir(lr_dir):
358
+ ok("layoutreader (optional)", lr_dir)
359
+ else:
360
+ record_fail("layoutreader (optional)",
361
+ "not found β€” MinerU will use fallback ordering (non-critical)",
362
+ critical=False)
363
+
364
+ # ── 11b. Critical model weight files ──────────────────────────────────────────
365
+ section("11b. Critical model weight files")
366
+ #
367
+ # These are the EXACT files MinerU will try to open when processing a document
368
+ # on a CPU deployment (default language = ch β†’ forced to ch_lite on CPU).
369
+ #
370
+ # After Dockerfile Layer 3.5 patch, models_config.yml now references:
371
+ # ch_lite.det = ch_PP-OCRv5_det_infer.pth (patched from v3 β€” v3 NOT in repo)
372
+ # ch_lite.rec = ch_PP-OCRv5_rec_infer.pth (unchanged β€” always in repo)
373
+ #
374
+ # Layout uses doclayout_yolo (from magic-pdf.json layout-config).
375
+ # Table (rapid_table) uses slanet-plus.onnx BUNDLED IN THE WHEEL β€” not here.
376
+ # Formula is DISABLED β€” MFD/MFR files not required.
377
+ #
378
+ # Any CRITICAL failure here = service boots but crashes on first document.
379
+
380
+ _ocr_dir = os.path.join(EXTRACT_KIT_MODELS, "OCR", "paddleocr_torch")
381
+
382
+ CRITICAL_WEIGHT_FILES: list[tuple[str, str, str]] = [
383
+ # (label, relative-to-EXTRACT_KIT_MODELS, reason)
384
+ (
385
+ "OCR det weight (ch_lite, default CPU lang)",
386
+ os.path.join("OCR", "paddleocr_torch", "ch_PP-OCRv5_det_infer.pth"),
387
+ "Patched from ch_PP-OCRv3_det_infer.pth (absent in HF repo). "
388
+ "Missing = all OCR will crash at model load time."
389
+ ),
390
+ (
391
+ "OCR rec weight (ch_lite)",
392
+ os.path.join("OCR", "paddleocr_torch", "ch_PP-OCRv5_rec_infer.pth"),
393
+ "Recognition model for ch_lite. "
394
+ "Missing = OCR loads det but crashes at recognition."
395
+ ),
396
+ (
397
+ "OCR cls weight (angle classifier)",
398
+ os.path.join("OCR", "paddleocr_torch", "ch_ptocr_mobile_v2.0_cls_infer.pth"),
399
+ "Used when use_angle_cls=True. Default is False so non-critical, "
400
+ "but its absence causes crash if angle classification is enabled."
401
+ ),
402
+ (
403
+ "Layout YOLO weight (doclayout_yolo)",
404
+ os.path.join("Layout", "YOLO", "doclayout_yolo_docstructbench_imgsz1280_2501.pt"),
405
+ "Layout detection model. Missing = layout detection crashes on every document."
406
+ ),
407
+ (
408
+ "Layout LayoutLMv3 weight",
409
+ os.path.join("Layout", "LayoutLMv3", "model_final.pth"),
410
+ "Alternative layout model. Required even when doclayout_yolo is primary "
411
+ "because model_configs.yaml always lists it."
412
+ ),
413
+ (
414
+ "Multilingual OCR det (en/latin fallback)",
415
+ os.path.join("OCR", "paddleocr_torch", "Multilingual_PP-OCRv3_det_infer.pth"),
416
+ "Patched det for en and latin languages. Missing = crash if lang=en/latin."
417
+ ),
418
+ ]
419
+
420
+ # cls weight is only critical if use_angle_cls=True (default False)
421
+ NON_CRITICAL_LABELS = {"OCR cls weight (angle classifier)"}
422
+
423
+ for label, rel_path, reason in CRITICAL_WEIGHT_FILES:
424
+ full_path = os.path.join(EXTRACT_KIT_MODELS, rel_path)
425
+ is_critical = label not in NON_CRITICAL_LABELS
426
+ if os.path.isfile(full_path):
427
+ size_mb = os.path.getsize(full_path) / (1024 * 1024)
428
+ ok(label, f"{size_mb:.1f} MB [{full_path}]")
429
+ else:
430
+ record_fail(
431
+ label,
432
+ f"FILE NOT FOUND: {full_path}\n"
433
+ f" Reason: {reason}",
434
+ critical=is_critical,
435
+ )
436
+
437
+ # ── 11c. models_config.yml consistency check ──────────────────────────────────
438
+ section("11c. models_config.yml consistency check")
439
+ #
440
+ # Reads the installed models_config.yml (inside magic_pdf package) and verifies
441
+ # that every det/rec file it references for the default CPU language (ch_lite)
442
+ # actually exists on disk in the expected location.
443
+ #
444
+ # This catches future version drift between the magic-pdf package and the HF repo
445
+ # BEFORE the service starts, rather than mid-request.
446
+
447
+ try:
448
+ import magic_pdf
449
+ import yaml as _yaml
450
+ from pathlib import Path as _Path
451
+
452
+ _pkg = _Path(magic_pdf.__file__).parent
453
+ _mcfg = _pkg / 'model/sub_modules/ocr/paddleocr2pytorch/pytorchocr/utils/resources/models_config.yml'
454
+
455
+ if not _mcfg.exists():
456
+ record_fail("models_config.yml", f"not found at expected path: {_mcfg}")
457
+ else:
458
+ with open(_mcfg) as _f:
459
+ _mc = _yaml.safe_load(_f)
460
+
461
+ _ocr_torch = os.path.join(EXTRACT_KIT_MODELS, "OCR", "paddleocr_torch")
462
+
463
+ # Check the two languages actually used on this CPU deployment
464
+ _check_langs = ["ch_lite", "ch"]
465
+ _mc_ok = True
466
+ for _lang in _check_langs:
467
+ _entry = _mc.get("lang", {}).get(_lang, {})
468
+ for _field in ("det", "rec"):
469
+ _fname = _entry.get(_field)
470
+ if not _fname:
471
+ continue
472
+ _fpath = os.path.join(_ocr_torch, _fname)
473
+ if os.path.isfile(_fpath):
474
+ ok(f"models_config[{_lang}].{_field}", _fname)
475
+ else:
476
+ record_fail(
477
+ f"models_config[{_lang}].{_field}",
478
+ f"Config references '{_fname}' but file not found at:\n"
479
+ f" {_fpath}\n"
480
+ f" Dockerfile Layer 3.5 patch may not have run, "
481
+ f"or HF repo changed its file structure again.",
482
+ critical=True,
483
+ )
484
+ _mc_ok = False
485
+
486
+ if _mc_ok:
487
+ ok("models_config.yml consistency", "all referenced det/rec files exist on disk")
488
+
489
+ except Exception as _exc:
490
+ record_fail("models_config.yml consistency check", f"unexpected error: {_exc}", critical=False)
491
+
492
+ # ── 11d. Bundled wheel resources ──────────────────────────────────────────────
493
+ section("11d. Bundled wheel resources (inside magic_pdf package)")
494
+ #
495
+ # These files are shipped inside the magic-pdf wheel itself.
496
+ # They do NOT come from the HF download. Their absence means the wheel
497
+ # installed incorrectly or was corrupted.
498
+
499
+ try:
500
+ import magic_pdf as _mp
501
+ from pathlib import Path as _P
502
+
503
+ _pkg_root = _P(_mp.__file__).parent
504
+ _bundled = [
505
+ ("slanet-plus.onnx (table model)",
506
+ _pkg_root / "resources" / "slanet_plus" / "slanet-plus.onnx"),
507
+ ("fasttext langdetect model",
508
+ _pkg_root / "resources" / "fasttext-langdetect" / "lid.176.ftz"),
509
+ ("YOLO langdetect model",
510
+ _pkg_root / "resources" / "yolov11-langdetect" / "yolo_v11_ft.pt"),
511
+ ("model_configs.yaml (weight path map)",
512
+ _pkg_root / "resources" / "model_config" / "model_configs.yaml"),
513
+ ]
514
+ for _lbl, _p in _bundled:
515
+ if _p.exists():
516
+ _sz = _p.stat().st_size / (1024 * 1024)
517
+ ok(_lbl, f"{_sz:.2f} MB")
518
+ else:
519
+ record_fail(_lbl, f"expected inside wheel at {_p} β€” magic-pdf install may be corrupted")
520
+
521
+ except Exception as _exc:
522
+ record_fail("Bundled wheel resources check", f"unexpected: {_exc}", critical=False)
523
+
524
+ # ── 12. Temp storage ───────────────────────────────────────────────────────────
525
+ section("12. Temp storage")
526
+ try:
527
+ td = tempfile.mkdtemp(prefix="mineru_validate_")
528
+ test_file = os.path.join(td, "write_test.bin")
529
+ with open(test_file, "wb") as f:
530
+ f.write(b"x" * 4096)
531
+ assert os.path.getsize(test_file) == 4096
532
+ shutil.rmtree(td)
533
+ ok("Temp write + delete", tempfile.gettempdir())
534
+ except Exception as exc:
535
+ record_fail("Temp storage", str(exc))
536
+
537
+ # ── 13. System memory (cgroups) ────────────────────────────────────────────────
538
+ section("13. System memory (cgroups)")
539
+ mem_source = "unknown"
540
+ total_mb = used_mb = 0
541
+
542
+ try:
543
+ with open("/sys/fs/cgroup/memory.max") as f:
544
+ raw = f.read().strip()
545
+ if raw != "max":
546
+ total_mb = int(raw) // (1024 * 1024)
547
+ with open("/sys/fs/cgroup/memory.current") as f:
548
+ used_mb = int(f.read().strip()) // (1024 * 1024)
549
+ mem_source = "cgroups v2"
550
+ except (FileNotFoundError, ValueError):
551
+ pass
552
+
553
+ if total_mb == 0:
554
+ try:
555
+ with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
556
+ limit = int(f.read().strip())
557
+ with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f:
558
+ used_bytes = int(f.read().strip())
559
+ if limit < 128 * 1024 * 1024 * 1024:
560
+ total_mb = limit // (1024 * 1024)
561
+ used_mb = used_bytes // (1024 * 1024)
562
+ mem_source = "cgroups v1"
563
+ except (FileNotFoundError, ValueError):
564
+ pass
565
+
566
+ if total_mb == 0:
567
+ try:
568
+ info: dict[str, int] = {}
569
+ with open("/proc/meminfo") as f:
570
+ for line in f:
571
+ parts = line.split()
572
+ if len(parts) >= 2:
573
+ info[parts[0].rstrip(":")] = int(parts[1])
574
+ total_mb = info.get("MemTotal", 0) // 1024
575
+ used_mb = (info.get("MemTotal", 0) - info.get("MemAvailable", 0)) // 1024
576
+ mem_source = "/proc/meminfo (may show host RAM)"
577
+ except Exception:
578
+ pass
579
+
580
+ ok("Memory source", mem_source)
581
+ ok("Total memory", f"{total_mb} MB")
582
+ ok("Used memory", f"{used_mb} MB")
583
+ ok("Free memory", f"{total_mb - used_mb} MB")
584
+
585
+ if total_mb > 32 * 1024:
586
+ record_fail(
587
+ "Memory total",
588
+ f"{total_mb} MB seems too large β€” cgroups may not be available; "
589
+ "/proc/meminfo showing host RAM. Memory guard in main.py will be conservative.",
590
+ critical=False,
591
+ )
592
+
593
+ # ── 14. /proc/meminfo sanity ───────────────────────────────────────────────────
594
+ section("14. /proc/meminfo (reference)")
595
+ try:
596
+ with open("/proc/meminfo") as f:
597
+ lines = f.readlines()[:5]
598
+ for line in lines:
599
+ parts = line.split()
600
+ if len(parts) >= 2:
601
+ kb = int(parts[1])
602
+ ok(parts[0].rstrip(":"), f"{kb // 1024} MB")
603
+ except Exception as exc:
604
+ record_fail("/proc/meminfo", str(exc), critical=False)
605
+
606
+ # ═══════════════════════════════════════════════════════════════════════════════
607
+ # Summary
608
+ # ═══════════════════════════════════════════════════════════════════════════════
609
+ print("\n" + "═" * 60, flush=True)
610
+ print(" Validation Summary", flush=True)
611
+ print("═" * 60, flush=True)
612
+
613
+ if warnings:
614
+ print(f"\n ⚠ {len(warnings)} warning(s):", flush=True)
615
+ for label, detail in warnings:
616
+ print(f" β€’ {label}: {detail}", flush=True)
617
+
618
+ if failures:
619
+ print(f"\n βœ— {len(failures)} CRITICAL failure(s):", flush=True)
620
+ for label, detail in failures:
621
+ print(f" β€’ {label}: {detail}", flush=True)
622
+ print("\n Service will NOT start until these are resolved.", flush=True)
623
+ print(" Check Dockerfile pip layers and Docker build log.", flush=True)
624
+ print("═" * 60 + "\n", flush=True)
625
+ if not SOFT_MODE:
626
+ sys.exit(1)
627
+ else:
628
+ print(f"\n βœ“ All critical checks passed", flush=True)
629
+ if warnings:
630
+ print(f" ⚠ {len(warnings)} non-critical warning(s) β€” see above", flush=True)
631
+ print("\n Service is ready to start.", flush=True)
632
+ print("═" * 60 + "\n", flush=True)
633
+ sys.exit(0)