sfdghsdvxfbgn commited on
Commit
d42d358
Β·
verified Β·
1 Parent(s): e7b4fb0

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +84 -0
  2. README.md +131 -0
  3. download_models.py +130 -0
  4. entrypoint.sh +40 -0
  5. main.py +885 -0
  6. requirements.txt +19 -0
  7. validate.py +315 -0
Dockerfile ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # System packages β€” what was removed and why:
7
+ # libreoffice β€” 1.5 GB installed; caused build timeouts/OOM.
8
+ # libsm6 libxext6
9
+ # libxrender-dev β€” X11 display stubs; only needed for cv2.imshow() GUI.
10
+ # Headless server never opens a display.
11
+ # libmagic1 β€” Only needed by python-magic, which is not used.
12
+ # wget curl β€” Runtime testing tools, not needed inside container.
13
+ #
14
+ # System packages β€” what was kept and why:
15
+ # libgl1 β€” OpenCV requires libGL.so.1 for all image ops (not GUI).
16
+ # libglib2.0-0 β€” GLib; required by OpenCV and many C extensions.
17
+ # libgomp1 β€” OpenMP; required by ONNX Runtime and YOLO inference.
18
+ # poppler-utils β€” pdfinfo / pdftoppm used by MinerU PDF pre-processing.
19
+ #
20
+ # Pip strategy β€” TWO separate RUN layers for cache granularity:
21
+ # Layer 1: small/fast packages + opencv-python-headless.
22
+ # opencv-python-headless MUST be in this layer so that when
23
+ # magic-pdf resolves cv2 in layer 2, the headless wheel is already
24
+ # present and pip keeps it (avoids pulling in the full X11 build).
25
+ # Layer 2: magic-pdf[full-cpu] β€” large, slow, custom wheel index.
26
+ # Separate layer so code-only rebuilds don't re-download 2+ GB.
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ FROM python:3.10-slim
30
+
31
+ ENV PYTHONUNBUFFERED=1
32
+ ENV PYTHONDONTWRITEBYTECODE=1
33
+ ENV PORT=7860
34
+ ENV MINERU_DEVICE_MODE=cpu
35
+ ENV MINERU_BACKEND=pipeline
36
+
37
+ # ── System dependencies (minimal confirmed set) ───────────────────────────────
38
+ RUN apt-get update \
39
+ && apt-get install -y --no-install-recommends \
40
+ libgl1 \
41
+ libglib2.0-0 \
42
+ libgomp1 \
43
+ poppler-utils \
44
+ && rm -rf /var/lib/apt/lists/*
45
+
46
+ WORKDIR /app
47
+
48
+ # ── Layer 1: small + opencv-headless (cached unless versions change) ──────────
49
+ # opencv-python-headless installed HERE so layer-2 magic-pdf install sees cv2
50
+ # already satisfied and does not pull in the full X11-dependent opencv-python.
51
+ RUN pip install --no-cache-dir --timeout 300 \
52
+ "fastapi>=0.115.0" \
53
+ "uvicorn[standard]>=0.32.0" \
54
+ "python-multipart>=0.0.12" \
55
+ "Pillow>=10.0.0" \
56
+ "pillow-heif>=0.18.0" \
57
+ "huggingface_hub>=0.25.0" \
58
+ "opencv-python-headless>=4.8.0"
59
+
60
+ # ── Layer 2: magic-pdf (large; cached unless version pin changes) ─────────────
61
+ RUN pip install --no-cache-dir --timeout 300 \
62
+ --extra-index-url https://myhloli.github.io/wheels/ \
63
+ "magic-pdf[full-cpu]==1.3.12"
64
+
65
+ # ── Application code ──────────────────────────────────────────────────────────
66
+ COPY download_models.py .
67
+ COPY validate.py .
68
+ COPY main.py .
69
+ COPY entrypoint.sh .
70
+ RUN chmod +x entrypoint.sh
71
+
72
+ # ── Download models at build time ─────────────────────────────────────────────
73
+ # Baked into image = zero cold-start download delay.
74
+ # Skip-if-exists logic in download_models.py gives Docker layer-cache reuse:
75
+ # code-only rebuilds skip the 15-minute model download entirely.
76
+ # MFR (formula recognition, ~1-2 GB) is excluded β€” disabled in config.
77
+ RUN python download_models.py
78
+
79
+ # Persist config; entrypoint.sh restores it if /root is wiped on restart.
80
+ RUN mkdir -p /app/config && cp /root/magic-pdf.json /app/config/magic-pdf.json
81
+
82
+ # ── Runtime ───────────────────────────────────────────────────────────────────
83
+ EXPOSE 7860
84
+ ENTRYPOINT ["/app/entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MinerU OCR Service
3
+ emoji: πŸ“„
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # MinerU OCR & Document Extraction Service
12
+
13
+ Production-quality OCR and document extraction API powered by **MinerU** (`magic-pdf` package), running on Hugging Face Docker Spaces β€” free CPU tier.
14
+
15
+ ---
16
+
17
+ ## Root Cause Analysis β€” Previous Build Failures
18
+
19
+ | Cause | Detail | Fix Applied |
20
+ |---|---|---|
21
+ | `libreoffice` in apt | ~1.5 GB installed; caused disk/OOM during build | **Removed** β€” DOCX/PPTX/XLSX dropped |
22
+ | X11 libs (`libsm6`, `libxext6`, `libxrender-dev`) | Only needed for OpenCV GUI windows (cv2.imshow); headless server never uses a display | **Removed** |
23
+ | `libmagic1` | C dep for python-magic, which was never imported | **Removed** |
24
+ | `python-magic` in pip | Listed in requirements but never used in code | **Removed** |
25
+ | `wget` / `curl` in apt | Runtime testing tools, not needed in container | **Removed** |
26
+ | Single pip layer | Any failure re-downloads all packages including 2 GB magic-pdf | **Split into 2 cached layers** |
27
+ | MFR models downloaded | Formula recognition (unimernet, ~1-2 GB) downloaded even though disabled | **Excluded via ignore_patterns** |
28
+ | `ModuleNotFoundError: magic_pdf.pipe.UNIPipe` | API removed in magic-pdf >= 1.0 | **Replaced** with current API |
29
+
30
+ ---
31
+
32
+ ## Supported File Types
33
+
34
+ | Category | Extensions |
35
+ |---|---|
36
+ | PDF | `.pdf` (searchable, scanned, mixed) |
37
+ | Images | `.jpg` `.jpeg` `.png` `.webp` `.bmp` `.tiff` `.tif` `.gif` `.heic` `.heif` `.avif` |
38
+
39
+ > DOCX / PPTX / XLSX were removed because they required LibreOffice, which caused build failures on free-tier hardware. Add them back once the base deployment is stable.
40
+
41
+ ---
42
+
43
+ ## API Endpoints
44
+
45
+ ### `GET /health`
46
+ ```json
47
+ { "status": "healthy" }
48
+ ```
49
+
50
+ ### `GET /status`
51
+ ```json
52
+ {
53
+ "status": "healthy",
54
+ "provider": "mineru",
55
+ "version": "1.3.12",
56
+ "modelsLoaded": true,
57
+ "uptimeSeconds": 3742,
58
+ "memoryUsedMB": 5200,
59
+ "memoryTotalMB": 16384,
60
+ "activeRequests": 0,
61
+ "cacheEntries": 3
62
+ }
63
+ ```
64
+
65
+ ### `POST /extract`
66
+ ```json
67
+ {
68
+ "success": true,
69
+ "filename": "invoice.pdf",
70
+ "docType": "invoice",
71
+ "pageCount": 2,
72
+ "confidence": 0.95,
73
+ "markdown": "# Invoice\n\n...",
74
+ "metadata": {
75
+ "parseMethod": "txt",
76
+ "backend": "pipeline",
77
+ "cached": false,
78
+ "processingTimeMs": 4200,
79
+ "docTypeClassification": "invoice",
80
+ "imageCount": 0,
81
+ "tableCount": 1,
82
+ "formulaCount": 0
83
+ }
84
+ }
85
+ ```
86
+
87
+ ### `POST /batch`
88
+ ```json
89
+ {
90
+ "success": true,
91
+ "processed": 3,
92
+ "results": [...]
93
+ }
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Deployment
99
+
100
+ 1. Create a new Hugging Face Space β†’ **Docker** SDK
101
+ 2. Upload all files from this directory to the Space repo root
102
+ 3. Space builds automatically (~20–30 min for first build; subsequent code-only rebuilds are faster due to Docker layer cache)
103
+ 4. Test with curl:
104
+
105
+ ```bash
106
+ curl https://<your-space>.hf.space/health
107
+ curl -X POST https://<your-space>.hf.space/extract -F "file=@doc.pdf"
108
+ curl -X POST https://<your-space>.hf.space/batch \
109
+ -F "files=@a.pdf" -F "files=@b.jpg" -F "files=@c.png"
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Resource Budget (free CPU Basic)
115
+
116
+ | Resource | Budget | Usage |
117
+ |---|---|---|
118
+ | Disk | 50 GB | ~8–10 GB (packages ~3 GB + models ~4–5 GB) |
119
+ | RAM | 16 GB | ~6–8 GB at load; peaks ~10 GB during extraction |
120
+ | vCPU | 2 | Single uvicorn worker; sequential batch processing |
121
+
122
+ ---
123
+
124
+ ## Known Limitations
125
+
126
+ 1. **No DOCX/PPTX/XLSX** β€” dropped to make the build reliable. Re-add with LibreOffice on paid hardware.
127
+ 2. **Formula OCR disabled** β€” MFR models excluded to save ~1-2 GB. Set `"enable": true` in `~/magic-pdf.json` and re-download MFR models to enable.
128
+ 3. **First-request latency** β€” models are lazy-loaded into RAM on the first request (~30–60 s).
129
+ 4. **30 MB file size limit** β€” enforced on both `/extract` and `/batch`.
130
+ 5. **In-process cache** β€” SHA256 cache lives in RAM, cleared on container restart.
131
+ 6. **HEIC/HEIF** β€” requires `pillow-heif` (included); may fail on unusual encoder variants.
download_models.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Models saved to /app/models/
17
+ Config written to /root/magic-pdf.json
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+
24
+ MODELS_DIR = "/app/models"
25
+ EXTRACT_KIT_DIR = os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0")
26
+ LAYOUTREADER_DIR = os.path.join(MODELS_DIR, "layoutreader")
27
+
28
+
29
+ def _models_present() -> bool:
30
+ """Return True if the key layout-model directory already exists."""
31
+ marker = os.path.join(EXTRACT_KIT_DIR, "models", "Layout")
32
+ return os.path.isdir(marker)
33
+
34
+
35
+ def _write_config(layoutreader_dir: str) -> None:
36
+ config = {
37
+ "bucket_info": {},
38
+ "models-dir": os.path.join(EXTRACT_KIT_DIR, "models"),
39
+ "layoutreader-model-dir": layoutreader_dir,
40
+ "device-mode": "cpu",
41
+ "layout-config": {
42
+ "model": "doclayout_yolo"
43
+ },
44
+ "formula-config": {
45
+ "mfd_model": "yolo_v8_mfd",
46
+ "mfr_model": "unimernet_small",
47
+ "enable": False
48
+ },
49
+ "table-config": {
50
+ "model": "rapid_table",
51
+ "enable": True,
52
+ "max_time": 400
53
+ },
54
+ "backend": "pipeline"
55
+ }
56
+ config_path = os.path.expanduser("~/magic-pdf.json")
57
+ with open(config_path, "w") as f:
58
+ json.dump(config, f, indent=2)
59
+ print(f"Config written β†’ {config_path}")
60
+ return config_path
61
+
62
+
63
+ def download() -> None:
64
+ try:
65
+ from huggingface_hub import snapshot_download
66
+ except ImportError:
67
+ print("ERROR: huggingface_hub not installed", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ # ── Skip-if-exists ────────────────────────────────────────────────────────
71
+ if _models_present():
72
+ print("Models already present β€” skipping download (Docker layer cache).")
73
+ # Config may still need writing if this is a fresh container from cached layer
74
+ lr_dir = LAYOUTREADER_DIR if os.path.isdir(LAYOUTREADER_DIR) else ""
75
+ _write_config(lr_dir)
76
+ return
77
+
78
+ os.makedirs(MODELS_DIR, exist_ok=True)
79
+
80
+ # ── PDF-Extract-Kit-1.0 ───────────────────────────────────────────────────
81
+ # Excluded via ignore_patterns:
82
+ # models/MFR β€” formula recognition (unimernet). Disabled in config.
83
+ # Saves ~1-2 GB of download and disk.
84
+ print("=" * 60)
85
+ print("Downloading PDF-Extract-Kit-1.0 ...")
86
+ print(" (MFR/formula-recognition excluded β€” disabled in config)")
87
+ print("=" * 60)
88
+ snapshot_download(
89
+ repo_id="opendatalab/PDF-Extract-Kit-1.0",
90
+ local_dir=EXTRACT_KIT_DIR,
91
+ ignore_patterns=[
92
+ "*.git*",
93
+ ".gitattributes",
94
+ "models/MFR*",
95
+ "models/MFR/*",
96
+ ],
97
+ )
98
+ print(f" β†’ {EXTRACT_KIT_DIR}")
99
+
100
+ # ── layoutreader (optional) ───────────────────────────────────────────────
101
+ # Improves reading-order accuracy. If unavailable, MinerU uses fallback.
102
+ layoutreader_dir = ""
103
+ print("=" * 60)
104
+ print("Downloading layoutreader (optional, improves reading order) ...")
105
+ print("=" * 60)
106
+ try:
107
+ snapshot_download(
108
+ repo_id="hantian/layoutreader",
109
+ local_dir=LAYOUTREADER_DIR,
110
+ ignore_patterns=["*.git*", ".gitattributes"],
111
+ )
112
+ layoutreader_dir = LAYOUTREADER_DIR
113
+ print(f" β†’ {LAYOUTREADER_DIR}")
114
+ except Exception as exc:
115
+ print(f" WARNING: layoutreader download failed ({exc})")
116
+ print(" Continuing without layoutreader β€” MinerU will use fallback ordering.")
117
+
118
+ # ── Write config ──────────────────────────────────────────────────────────
119
+ _write_config(layoutreader_dir)
120
+
121
+ print("\nβœ“ Model setup complete.")
122
+ print(f" models-dir : {os.path.join(EXTRACT_KIT_DIR, 'models')}")
123
+ print(f" layoutreader-dir : {layoutreader_dir or '(not available)'}")
124
+ print(f" device-mode : cpu")
125
+ print(f" formula recognition : disabled (MFR models excluded)")
126
+ print(f" table recognition : enabled")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ 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,885 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MinerU OCR & Document Extraction Service
3
+ FastAPI application for Hugging Face Docker Space (CPU / pipeline backend)
4
+
5
+ Correct imports for magic-pdf >= 1.0.x (magic_pdf module):
6
+
7
+ from magic_pdf.data.data_reader_writer import FileBasedDataReader, FileBasedDataWriter
8
+ from magic_pdf.data.dataset import PymuDocDataset, ImageDataset
9
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
10
+ from magic_pdf.config.enums import SupportedPdfParseMethod
11
+
12
+ OBSOLETE imports (removed in magic-pdf >= 1.0, do not use):
13
+ from magic_pdf.pipe.UNIPipe import UNIPipe ← removed
14
+ from magic_pdf.rw.DiskReaderWriter import ... ← removed
15
+ from magic_pdf.data.read_api import read_local_images ← NOT used here;
16
+ function expects a single string path in 1.x but crashes with
17
+ "stat: ... not list" if given a list. Use FileBasedDataReader instead.
18
+
19
+ Removed features vs original:
20
+ - DOCX / PPTX / XLSX (required LibreOffice; caused build OOM/timeout)
21
+ - subprocess (was only used for LibreOffice conversion)
22
+ - python-magic / libmagic (listed in requirements but never imported)
23
+
24
+ Endpoints:
25
+ GET /health β€” liveness (always fast, no dependency check)
26
+ GET /status β€” full node status including memory (via cgroups), uptime,
27
+ cache, active requests, lastModelLoadMs
28
+ POST /extract β€” single file (PDF or image) with SHA256 cache + memory guard
29
+ POST /batch β€” up to BATCH_MAX_FILES files; extras silently ignored
30
+
31
+ Structured error format (all non-2xx responses from /extract and /batch):
32
+ {
33
+ "success": false,
34
+ "stage": "upload" | "validation" | "decode" | "ocr" | "markdown" | "unknown",
35
+ "errorCode": "UNSUPPORTED_TYPE" | "FILE_TOO_LARGE" | "EMPTY_FILE" |
36
+ "LOW_MEMORY" | "IMAGE_DECODE_FAILED" | "OCR_PIPELINE_FAILED" |
37
+ "MARKDOWN_FAILED" | "INTERNAL_ERROR",
38
+ "message": "<human-readable detail>"
39
+ }
40
+ """
41
+
42
+ import hashlib
43
+ import io
44
+ import os
45
+ import re
46
+ import shutil
47
+ import sys
48
+ import tempfile
49
+ import threading
50
+ import time
51
+ import traceback
52
+ import logging
53
+ from importlib.metadata import version as pkg_version
54
+ from typing import Any
55
+
56
+ import fitz # PyMuPDF β€” bundled with magic-pdf[full-cpu]
57
+ from PIL import Image
58
+
59
+ from fastapi import FastAPI, File, UploadFile, HTTPException
60
+ from fastapi.middleware.cors import CORSMiddleware
61
+ from fastapi.responses import JSONResponse
62
+
63
+ # ── Logging ───────────────────────────────────────────────────────────────────
64
+ logging.basicConfig(
65
+ level=logging.INFO,
66
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
67
+ )
68
+ logger = logging.getLogger("mineru-service")
69
+
70
+ # ── Start time ────────────────────────────────────────────────────────────────
71
+ _START_TIME: float = time.time()
72
+
73
+ # ── Upload / batch constants ──────────────────────────────────────────────────
74
+ MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
75
+ BATCH_MAX_FILES = 8
76
+
77
+ # ── Supported file types ──────────────────────────────────────────────────────
78
+ PDF_EXTENSIONS = {"pdf"}
79
+
80
+ # Natively supported by ImageDataset via FileBasedDataReader
81
+ NATIVE_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png"}
82
+
83
+ # Need Pillow conversion to PNG before feeding to MinerU
84
+ PILLOW_IMAGE_EXTENSIONS = {"webp", "bmp", "tiff", "tif", "gif", "heic", "heif", "avif"}
85
+
86
+ IMAGE_EXTENSIONS = NATIVE_IMAGE_EXTENSIONS | PILLOW_IMAGE_EXTENSIONS
87
+ ALLOWED_EXTENSIONS = PDF_EXTENSIONS | IMAGE_EXTENSIONS
88
+
89
+ # ── Memory safety thresholds ──────────────────────────────────────────────────
90
+ BYTES_PER_OCR_PAGE = 100 * 1024 * 1024 # ~100 MB / page (conservative)
91
+ IMAGE_MEMORY_FACTOR = 4 # decoded pixels Γ— 4 for pipeline buffers
92
+ MEM_SAFETY_FLOOR_MB = 1024 # always keep 1 GB free
93
+
94
+ # ── SHA256 extraction cache (in-process, bounded by available RAM) ────────────
95
+ _cache: dict[str, dict[str, Any]] = {}
96
+ _cache_lock = threading.Lock()
97
+
98
+ # ── Active-request counter ────────────────────────────────────────────────────
99
+ _active_requests: int = 0
100
+ _active_lock = threading.Lock()
101
+
102
+ # ── Model load timing ─────────────────────────────────────────────────────────
103
+ _model_load_ms: int = 0
104
+
105
+ # ── Startup self-test results (populated by startup handler) ──────────────────
106
+ _startup_issues: list[str] = []
107
+ _startup_done: bool = False
108
+
109
+
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+ # Structured error exception
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ class ExtractionError(Exception):
114
+ """
115
+ Raised anywhere in the extraction pipeline to produce a structured
116
+ JSON error response with stage + errorCode instead of a generic 500.
117
+ """
118
+ def __init__(
119
+ self,
120
+ stage: str,
121
+ code: str,
122
+ message: str,
123
+ http_status: int = 422,
124
+ ) -> None:
125
+ self.stage = stage
126
+ self.code = code
127
+ self.message = message
128
+ self.http_status = http_status
129
+ super().__init__(message)
130
+
131
+ def to_dict(self) -> dict[str, Any]:
132
+ return {
133
+ "success": False,
134
+ "stage": self.stage,
135
+ "errorCode": self.code,
136
+ "message": self.message,
137
+ }
138
+
139
+
140
+ def _err(stage: str, code: str, msg: str, status: int = 422) -> ExtractionError:
141
+ """Shorthand constructor."""
142
+ return ExtractionError(stage, code, msg, status)
143
+
144
+
145
+ # ─────────────────────────────────────────────────────────────────────────────
146
+ # Active request helpers
147
+ # ─────────────────────────────────────────────────────────────────────────────
148
+ def _inc_active() -> None:
149
+ global _active_requests
150
+ with _active_lock:
151
+ _active_requests += 1
152
+
153
+
154
+ def _dec_active() -> None:
155
+ global _active_requests
156
+ with _active_lock:
157
+ _active_requests = max(0, _active_requests - 1)
158
+
159
+
160
+ # ─────────────────────────────────────────────────────────────────────────────
161
+ # Pipeline readiness (lazy, first-request check)
162
+ # ─────────────────────────────────────────────────────────────────────────────
163
+ _pipeline_ready: bool = False
164
+ _pipeline_lock = threading.Lock()
165
+
166
+
167
+ def _ensure_pipeline() -> None:
168
+ """
169
+ Verify MinerU is importable and its config is present.
170
+ Sets _pipeline_ready on first success; raises ExtractionError on failure.
171
+ Checks are done under a lock so concurrent first-requests don't race.
172
+ """
173
+ global _pipeline_ready, _model_load_ms
174
+ if _pipeline_ready:
175
+ return
176
+
177
+ with _pipeline_lock:
178
+ if _pipeline_ready: # double-checked locking
179
+ return
180
+
181
+ config_path = os.path.expanduser("~/magic-pdf.json")
182
+ if not os.path.exists(config_path):
183
+ raise _err(
184
+ "model_load", "CONFIG_MISSING",
185
+ f"magic-pdf.json not found at {config_path}. "
186
+ "Check Docker build log for download_models.py output.",
187
+ 503,
188
+ )
189
+
190
+ # Trigger a lightweight import to confirm the package is usable.
191
+ t0 = time.perf_counter()
192
+ try:
193
+ from magic_pdf.data.dataset import PymuDocDataset, ImageDataset # noqa: F401
194
+ from magic_pdf.data.data_reader_writer import ( # noqa: F401
195
+ FileBasedDataReader, FileBasedDataWriter,
196
+ )
197
+ except ImportError as exc:
198
+ raise _err(
199
+ "model_load", "IMPORT_FAILED",
200
+ f"magic_pdf not importable: {exc}. Check Dockerfile pip layers.",
201
+ 503,
202
+ ) from exc
203
+
204
+ _model_load_ms = int((time.perf_counter() - t0) * 1000)
205
+ _pipeline_ready = True
206
+ logger.info("Pipeline ready (import check: %d ms).", _model_load_ms)
207
+
208
+
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+ # FastAPI app
211
+ # ─────────────────────────────────────────────────────────────────────────────
212
+ app = FastAPI(
213
+ title="MinerU OCR Service",
214
+ description=(
215
+ "OCR and document extraction via MinerU pipeline backend (CPU). "
216
+ "Supports PDF and image files up to 30 MB."
217
+ ),
218
+ version="1.1.0",
219
+ )
220
+
221
+ app.add_middleware(
222
+ CORSMiddleware,
223
+ allow_origins=["*"],
224
+ allow_methods=["GET", "POST"],
225
+ allow_headers=["*"],
226
+ )
227
+
228
+
229
+ # ─────────────────────────────────────────────────────────────────────────────
230
+ # Startup self-test
231
+ # ─────────────────────────────────────────────────────────────────────────────
232
+ @app.on_event("startup")
233
+ async def startup_self_test() -> None:
234
+ """
235
+ Run at container startup. Verifies all critical dependencies are present.
236
+ Never crashes the server β€” issues are stored in _startup_issues and
237
+ surfaced via GET /status so operators can diagnose without SSH access.
238
+ """
239
+ global _startup_done
240
+ issues: list[str] = []
241
+
242
+ # 1. cv2 β€” most common missing dependency
243
+ try:
244
+ import cv2 # noqa: F401
245
+ logger.info("startup βœ“ cv2 available (version %s)", cv2.__version__)
246
+ except ImportError as exc:
247
+ msg = (
248
+ f"cv2 not importable: {exc}. "
249
+ "Add 'opencv-python-headless>=4.8.0' to pip layer 1 in Dockerfile."
250
+ )
251
+ issues.append(msg)
252
+ logger.critical("startup FAIL %s", msg)
253
+
254
+ # 2. magic_pdf core imports
255
+ try:
256
+ from magic_pdf.data.dataset import PymuDocDataset, ImageDataset # noqa: F401
257
+ from magic_pdf.data.data_reader_writer import ( # noqa: F401
258
+ FileBasedDataReader, FileBasedDataWriter,
259
+ )
260
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze # noqa: F401
261
+ from magic_pdf.config.enums import SupportedPdfParseMethod # noqa: F401
262
+ logger.info("startup βœ“ magic_pdf imports OK")
263
+ except ImportError as exc:
264
+ msg = f"magic_pdf not importable: {exc}"
265
+ issues.append(msg)
266
+ logger.critical("startup FAIL %s", msg)
267
+
268
+ # 3. MinerU config
269
+ config_path = os.path.expanduser("~/magic-pdf.json")
270
+ if os.path.exists(config_path):
271
+ logger.info("startup βœ“ magic-pdf.json found at %s", config_path)
272
+ else:
273
+ msg = f"magic-pdf.json missing at {config_path} β€” run download_models.py"
274
+ issues.append(msg)
275
+ logger.critical("startup FAIL %s", msg)
276
+
277
+ # 4. Model files
278
+ models_dir = "/app/models/PDF-Extract-Kit-1.0/models"
279
+ if os.path.isdir(models_dir):
280
+ logger.info("startup βœ“ models directory found at %s", models_dir)
281
+ else:
282
+ msg = f"Models directory missing at {models_dir} β€” run download_models.py"
283
+ issues.append(msg)
284
+ logger.critical("startup FAIL %s", msg)
285
+
286
+ # 5. Temp storage writable
287
+ try:
288
+ td = tempfile.mkdtemp(prefix="mineru_selftest_")
289
+ shutil.rmtree(td)
290
+ logger.info("startup βœ“ temp storage writable")
291
+ except Exception as exc:
292
+ msg = f"Temp storage not writable: {exc}"
293
+ issues.append(msg)
294
+ logger.critical("startup FAIL %s", msg)
295
+
296
+ _startup_issues.extend(issues)
297
+ _startup_done = True
298
+
299
+ if not issues:
300
+ logger.info("=" * 60)
301
+ logger.info("Startup self-test PASSED β€” service ready.")
302
+ logger.info("=" * 60)
303
+ else:
304
+ logger.error("=" * 60)
305
+ logger.error("Startup self-test FAILED β€” %d issue(s). See above.", len(issues))
306
+ logger.error("Service will start but /extract will fail until fixed.")
307
+ logger.error("=" * 60)
308
+
309
+
310
+ # ─────────────────────────────────────────────────────────────────────────────
311
+ # GET /health
312
+ # ─────────────────────────────────────────────────────────────────────────────
313
+ @app.get("/health")
314
+ def health() -> dict[str, Any]:
315
+ """
316
+ Liveness probe. Always returns 200 so HF Space marks the container as
317
+ running. Use GET /status to check whether the OCR pipeline is ready.
318
+ """
319
+ return {"status": "healthy"}
320
+
321
+
322
+ # ─────────────────────────────────────────────────────────────────────────────
323
+ # GET /status
324
+ # ─────────────────────────────────────────────────────────────────────────────
325
+ @app.get("/status")
326
+ def status() -> dict[str, Any]:
327
+ """
328
+ Full readiness report. Memory is read from cgroups (not /proc/meminfo)
329
+ so the container's actual allocation is reported β€” not the host's RAM.
330
+ /proc/meminfo inside a Docker container on HF shows the host machine's
331
+ RAM (e.g. 123 GB) which is misleading. Cgroups v2 β†’ v1 β†’ /proc fallback.
332
+ """
333
+ used_mb, total_mb = _mem_mb()
334
+ return {
335
+ "status": "healthy" if not _startup_issues else "degraded",
336
+ "provider": "mineru",
337
+ "version": _mineru_version(),
338
+ "modelsLoaded": _pipeline_ready,
339
+ "startupIssues": _startup_issues,
340
+ "uptimeSeconds": int(time.time() - _START_TIME),
341
+ "memoryUsedMB": used_mb,
342
+ "memoryTotalMB": total_mb,
343
+ "activeRequests": _active_requests,
344
+ "cacheEntries": len(_cache),
345
+ "lastModelLoadMs": _model_load_ms,
346
+ }
347
+
348
+
349
+ # ─────────────────────────────────────────────────────────────────────────────
350
+ # POST /extract β€” single file
351
+ # ─────────────────────────────────────────────────────────────────────────────
352
+ @app.post("/extract")
353
+ async def extract(file: UploadFile = File(...)) -> JSONResponse:
354
+ try:
355
+ _ensure_pipeline()
356
+ raw, filename, ext = await _read_upload(file)
357
+ result = _run_extraction(raw, filename, ext)
358
+ return JSONResponse(content=result)
359
+ except ExtractionError as exc:
360
+ logger.warning(
361
+ "/extract structured error [%s/%s]: %s",
362
+ exc.stage, exc.code, exc.message,
363
+ )
364
+ return JSONResponse(status_code=exc.http_status, content=exc.to_dict())
365
+ except HTTPException:
366
+ raise
367
+ except Exception as exc:
368
+ logger.exception("/extract unhandled error")
369
+ return JSONResponse(
370
+ status_code=500,
371
+ content={
372
+ "success": False,
373
+ "stage": "unknown",
374
+ "errorCode": "INTERNAL_ERROR",
375
+ "message": str(exc),
376
+ "traceback": traceback.format_exc()[-2000:],
377
+ },
378
+ )
379
+
380
+
381
+ # ─────────────────────────────────────────────────────────────────────────────
382
+ # POST /batch β€” up to 8 files; extras silently ignored
383
+ # ─────────────────────────────────────────────────────────────────────────────
384
+ @app.post("/batch")
385
+ async def batch(files: list[UploadFile] = File(...)) -> JSONResponse:
386
+ """
387
+ Policy:
388
+ - 1–8 files β†’ process all.
389
+ - > 8 files β†’ silently process only files[0:8].
390
+ Sequential processing to stay within CPU Basic memory limits.
391
+ Per-file failures use the structured error format; one failure never
392
+ aborts the rest of the batch.
393
+ """
394
+ try:
395
+ _ensure_pipeline()
396
+ except ExtractionError as exc:
397
+ return JSONResponse(status_code=exc.http_status, content=exc.to_dict())
398
+
399
+ candidates = files[:BATCH_MAX_FILES]
400
+ results: list[dict[str, Any]] = []
401
+
402
+ for upload in candidates:
403
+ try:
404
+ raw, filename, ext = await _read_upload(upload)
405
+ result = _run_extraction(raw, filename, ext)
406
+ except ExtractionError as exc:
407
+ result = exc.to_dict()
408
+ result["filename"] = _sanitize_filename(upload.filename or "upload")
409
+ except Exception as exc:
410
+ fname = _sanitize_filename(upload.filename or "upload")
411
+ logger.exception("Batch item failed: %s", fname)
412
+ result = {
413
+ "success": False,
414
+ "filename": fname,
415
+ "stage": "unknown",
416
+ "errorCode": "INTERNAL_ERROR",
417
+ "message": str(exc),
418
+ }
419
+ results.append(result)
420
+
421
+ return JSONResponse(content={
422
+ "success": True,
423
+ "processed": len(results),
424
+ "results": results,
425
+ })
426
+
427
+
428
+ # ─────────────────────────────────────────────────────────────────────────────
429
+ # Upload reader (shared by /extract and /batch)
430
+ # ─────────────────────────────────────────────────────────────────────────────
431
+ async def _read_upload(upload: UploadFile) -> tuple[bytes, str, str]:
432
+ """Validate and read one upload. Returns (raw_bytes, filename, ext)."""
433
+ filename = _sanitize_filename(upload.filename or "upload")
434
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
435
+
436
+ if ext not in ALLOWED_EXTENSIONS:
437
+ raise _err(
438
+ "validation", "UNSUPPORTED_TYPE",
439
+ f"Unsupported file type '.{ext}'. "
440
+ f"Supported: {sorted(ALLOWED_EXTENSIONS)}",
441
+ 415,
442
+ )
443
+
444
+ raw = await upload.read(MAX_UPLOAD_BYTES + 1)
445
+
446
+ if len(raw) > MAX_UPLOAD_BYTES:
447
+ raise _err(
448
+ "upload", "FILE_TOO_LARGE",
449
+ f"'{filename}' exceeds the {MAX_UPLOAD_BYTES // 1024 // 1024} MB limit.",
450
+ 413,
451
+ )
452
+ if len(raw) == 0:
453
+ raise _err("upload", "EMPTY_FILE", f"'{filename}' is empty.", 400)
454
+
455
+ return raw, filename, ext
456
+
457
+
458
+ # ─────────────────────────────────────────────────────────────────────────────
459
+ # Extraction dispatcher (shared by /extract and /batch)
460
+ # ─────────────────────────────────────────────────────────────────────────────
461
+ def _run_extraction(raw: bytes, filename: str, ext: str) -> dict[str, Any]:
462
+ """
463
+ 1. SHA256 cache lookup β†’ return immediately on hit (cached: true)
464
+ 2. Memory safety guard β†’ raise ExtractionError(LOW_MEMORY) if OOM likely
465
+ 3. Dispatch to PDF or image processor
466
+ 4. Cache successful result
467
+ 5. Return with timing metadata
468
+ """
469
+ # ── SHA256 cache ──────────────────────────────────────────────────────────
470
+ file_hash = hashlib.sha256(raw).hexdigest()
471
+ with _cache_lock:
472
+ cached = _cache.get(file_hash)
473
+ if cached is not None:
474
+ logger.info("Cache hit %s sha256=%.12s…", filename, file_hash)
475
+ result = {**cached}
476
+ result["metadata"] = {**cached["metadata"], "cached": True, "processingTimeMs": 0}
477
+ return result
478
+
479
+ # ── Memory safety guard ───────────────────────────────────────────────────
480
+ _assert_memory_safe(raw, ext)
481
+
482
+ # ── Process ───────────────────────────────────────────────────────────────
483
+ _inc_active()
484
+ work_dir = tempfile.mkdtemp(prefix="mineru_")
485
+ try:
486
+ t0 = time.perf_counter()
487
+
488
+ if ext in PDF_EXTENSIONS:
489
+ result = _process_pdf(raw, filename, work_dir)
490
+ else:
491
+ result = _process_image(raw, filename, ext, work_dir)
492
+
493
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
494
+ result["metadata"] = {
495
+ **result["metadata"],
496
+ "processingTimeMs": elapsed_ms,
497
+ "cached": False,
498
+ }
499
+
500
+ # Store without timing so cache entries stay lean
501
+ entry = {**result, "metadata": {k: v for k, v in result["metadata"].items()
502
+ if k not in ("processingTimeMs", "cached")}}
503
+ with _cache_lock:
504
+ _cache[file_hash] = entry
505
+
506
+ return result
507
+
508
+ except ExtractionError:
509
+ raise
510
+ except Exception as exc:
511
+ logger.exception("Extraction failed for %s", filename)
512
+ raise _err(
513
+ "unknown", "INTERNAL_ERROR",
514
+ f"Unexpected error during extraction: {exc}",
515
+ 500,
516
+ ) from exc
517
+ finally:
518
+ _dec_active()
519
+ shutil.rmtree(work_dir, ignore_errors=True)
520
+
521
+
522
+ # ─────────────────────────────────────────────────────────────────────────────
523
+ # Memory safety guard
524
+ # ─────────────────────────────────────────────────────────────────────────────
525
+ def _assert_memory_safe(raw: bytes, ext: str) -> None:
526
+ """
527
+ Estimate peak memory the pipeline needs and reject with LOW_MEMORY if
528
+ available would drop below MEM_SAFETY_FLOOR_MB.
529
+ """
530
+ used_mb, total_mb = _mem_mb()
531
+ if total_mb == 0:
532
+ return # cgroups and /proc both unavailable β€” skip guard
533
+
534
+ available_mb = total_mb - used_mb
535
+
536
+ if ext in PDF_EXTENSIONS:
537
+ page_count = max(1, _pdf_page_count(raw))
538
+ estimated_mb = (page_count * BYTES_PER_OCR_PAGE) // (1024 * 1024)
539
+ else:
540
+ estimated_mb = _image_memory_estimate(raw, ext) // (1024 * 1024)
541
+
542
+ free_after = available_mb - estimated_mb
543
+ logger.info(
544
+ "Memory check: avail=%d MB est=%d MB free_after=%d MB",
545
+ available_mb, estimated_mb, free_after,
546
+ )
547
+
548
+ if free_after < MEM_SAFETY_FLOOR_MB:
549
+ raise _err(
550
+ "validation", "LOW_MEMORY",
551
+ f"Insufficient memory. "
552
+ f"Available: {available_mb} MB, "
553
+ f"Estimated needed: {estimated_mb} MB, "
554
+ f"Safety floor: {MEM_SAFETY_FLOOR_MB} MB. "
555
+ "Try a smaller file or wait for active requests to complete.",
556
+ 507,
557
+ )
558
+
559
+
560
+ def _image_memory_estimate(raw: bytes, ext: str) -> int:
561
+ try:
562
+ if ext in {"heic", "heif"}:
563
+ try:
564
+ from pillow_heif import register_heif_opener
565
+ register_heif_opener()
566
+ except ImportError:
567
+ pass
568
+ img = Image.open(io.BytesIO(raw))
569
+ w, h = img.size
570
+ channels = len(img.getbands()) or 3
571
+ img.close()
572
+ return w * h * channels * IMAGE_MEMORY_FACTOR
573
+ except Exception:
574
+ return len(raw) * 20 # conservative fallback
575
+
576
+
577
+ # ─────────────────────────────────────────────────────────────────────────────
578
+ # PDF processor
579
+ # ─────────────────────────────────────────────────────────────────────────────
580
+ def _process_pdf(raw: bytes, filename: str, work_dir: str) -> dict[str, Any]:
581
+ from magic_pdf.data.data_reader_writer import FileBasedDataWriter
582
+ from magic_pdf.data.dataset import PymuDocDataset
583
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
584
+ from magic_pdf.config.enums import SupportedPdfParseMethod
585
+
586
+ images_dir = os.path.join(work_dir, "images")
587
+ os.makedirs(images_dir, exist_ok=True)
588
+
589
+ try:
590
+ image_writer = FileBasedDataWriter(images_dir)
591
+ except Exception as exc:
592
+ raise _err("decode", "PDF_WRITER_FAILED", f"Could not create image writer: {exc}") from exc
593
+
594
+ page_count = _pdf_page_count(raw)
595
+
596
+ try:
597
+ ds = PymuDocDataset(raw)
598
+ method = ds.classify()
599
+ except Exception as exc:
600
+ raise _err("decode", "PDF_PARSE_FAILED", f"Could not parse PDF: {exc}") from exc
601
+
602
+ try:
603
+ if method == SupportedPdfParseMethod.TXT:
604
+ infer_result = ds.apply(doc_analyze, ocr=False)
605
+ pipe_result = infer_result.pipe_txt_mode(image_writer)
606
+ parse_method = "txt"
607
+ confidence = 0.95
608
+ else:
609
+ infer_result = ds.apply(doc_analyze, ocr=True)
610
+ pipe_result = infer_result.pipe_ocr_mode(image_writer)
611
+ parse_method = "ocr"
612
+ confidence = 0.85
613
+ except Exception as exc:
614
+ raise _err("ocr", "OCR_PIPELINE_FAILED", f"doc_analyze/pipe failed: {exc}") from exc
615
+
616
+ try:
617
+ markdown = pipe_result.get_markdown(images_dir)
618
+ except Exception as exc:
619
+ raise _err("markdown", "MARKDOWN_FAILED", f"get_markdown failed: {exc}") from exc
620
+
621
+ content_list = _safe_content_list(pipe_result, images_dir)
622
+ doc_type = _classify_document(markdown, filename)
623
+
624
+ return {
625
+ "success": True,
626
+ "filename": filename,
627
+ "docType": doc_type,
628
+ "pageCount": page_count,
629
+ "confidence": confidence,
630
+ "markdown": markdown,
631
+ "metadata": {
632
+ "parseMethod": parse_method,
633
+ "backend": "pipeline",
634
+ "docTypeClassification": doc_type,
635
+ "imageCount": _count_images(content_list),
636
+ "tableCount": _count_tables(content_list),
637
+ "formulaCount": _count_formulas(content_list),
638
+ },
639
+ }
640
+
641
+
642
+ # ─────────────────────────────────────────────────────────────────────────────
643
+ # Image processor
644
+ # ─────────────────────────────────────────────────────────────────────────────
645
+ def _process_image(raw: bytes, filename: str, ext: str, work_dir: str) -> dict[str, Any]:
646
+ """
647
+ NOTE: read_local_images() is intentionally NOT used here.
648
+ In magic-pdf 1.x it expects a single path string; passing a list causes:
649
+ "stat: path should be string, bytes, os.PathLike or integer, not list"
650
+ We use FileBasedDataReader + ImageDataset directly β€” explicit and safe.
651
+ """
652
+ from magic_pdf.data.data_reader_writer import FileBasedDataReader, FileBasedDataWriter
653
+ from magic_pdf.data.dataset import ImageDataset
654
+ from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
655
+
656
+ images_dir = os.path.join(work_dir, "images")
657
+ os.makedirs(images_dir, exist_ok=True)
658
+
659
+ try:
660
+ image_writer = FileBasedDataWriter(images_dir)
661
+ except Exception as exc:
662
+ raise _err("decode", "IMAGE_WRITER_FAILED", f"Could not create image writer: {exc}") from exc
663
+
664
+ # Convert non-native formats to PNG before feeding to MinerU
665
+ try:
666
+ if ext in PILLOW_IMAGE_EXTENSIONS:
667
+ raw = _convert_image_to_png(raw, ext)
668
+ save_ext = "png"
669
+ else:
670
+ save_ext = ext
671
+ except ExtractionError:
672
+ raise
673
+ except Exception as exc:
674
+ raise _err("decode", "IMAGE_DECODE_FAILED", f"Could not decode image: {exc}") from exc
675
+
676
+ img_filename = f"input.{save_ext}"
677
+ img_path = os.path.join(work_dir, img_filename)
678
+ try:
679
+ with open(img_path, "wb") as fh:
680
+ fh.write(raw)
681
+ except OSError as exc:
682
+ raise _err("decode", "WRITE_FAILED", f"Could not write temp image: {exc}") from exc
683
+
684
+ try:
685
+ # FileBasedDataReader(base_dir).read(relative_name) β†’ bytes
686
+ reader = FileBasedDataReader(work_dir)
687
+ image_bytes = reader.read(img_filename)
688
+ ds = ImageDataset(image_bytes)
689
+ except Exception as exc:
690
+ raise _err("decode", "IMAGE_DATASET_FAILED",
691
+ f"Could not build ImageDataset: {exc}") from exc
692
+
693
+ try:
694
+ infer_result = ds.apply(doc_analyze, ocr=True)
695
+ pipe_result = infer_result.pipe_ocr_mode(image_writer)
696
+ except Exception as exc:
697
+ raise _err("ocr", "OCR_PIPELINE_FAILED", f"doc_analyze/pipe failed: {exc}") from exc
698
+
699
+ try:
700
+ markdown = pipe_result.get_markdown(images_dir)
701
+ except Exception as exc:
702
+ raise _err("markdown", "MARKDOWN_FAILED", f"get_markdown failed: {exc}") from exc
703
+
704
+ content_list = _safe_content_list(pipe_result, images_dir)
705
+ doc_type = _classify_document(markdown, filename)
706
+
707
+ return {
708
+ "success": True,
709
+ "filename": filename,
710
+ "docType": doc_type,
711
+ "pageCount": 1,
712
+ "confidence": 0.85,
713
+ "markdown": markdown,
714
+ "metadata": {
715
+ "parseMethod": "ocr",
716
+ "backend": "pipeline",
717
+ "docTypeClassification": doc_type,
718
+ "imageCount": _count_images(content_list),
719
+ "tableCount": _count_tables(content_list),
720
+ "formulaCount": _count_formulas(content_list),
721
+ },
722
+ }
723
+
724
+
725
+ # ─────────────────────────────────────────────────────────────────────────────
726
+ # Utility helpers
727
+ # ─────────────────────────────────────────────────────────────────────────────
728
+ def _sanitize_filename(name: str) -> str:
729
+ name = os.path.basename(name)
730
+ name = re.sub(r"[^\w.\-]", "_", name)
731
+ return name[:200] or "upload"
732
+
733
+
734
+ def _pdf_page_count(raw: bytes) -> int:
735
+ try:
736
+ doc = fitz.open(stream=raw, filetype="pdf")
737
+ count = doc.page_count
738
+ doc.close()
739
+ return count
740
+ except Exception:
741
+ return 0
742
+
743
+
744
+ def _convert_image_to_png(raw: bytes, ext: str) -> bytes:
745
+ if ext in {"heic", "heif"}:
746
+ try:
747
+ from pillow_heif import register_heif_opener
748
+ register_heif_opener()
749
+ except ImportError:
750
+ raise _err(
751
+ "decode", "HEIF_NOT_SUPPORTED",
752
+ "HEIC/HEIF support requires pillow-heif (not installed).",
753
+ 415,
754
+ )
755
+ try:
756
+ img = Image.open(io.BytesIO(raw)).convert("RGB")
757
+ buf = io.BytesIO()
758
+ img.save(buf, format="PNG")
759
+ return buf.getvalue()
760
+ except Exception as exc:
761
+ raise _err("decode", "IMAGE_DECODE_FAILED", f"Pillow could not open image: {exc}") from exc
762
+
763
+
764
+ def _safe_content_list(pipe_result: Any, images_dir: str) -> list[dict]:
765
+ try:
766
+ return pipe_result.get_content_list(images_dir) or []
767
+ except Exception:
768
+ return []
769
+
770
+
771
+ def _count_images(content_list: list[dict]) -> int:
772
+ return sum(1 for item in content_list if item.get("type") == "image")
773
+
774
+
775
+ def _count_tables(content_list: list[dict]) -> int:
776
+ return sum(1 for item in content_list if item.get("type") == "table")
777
+
778
+
779
+ def _count_formulas(content_list: list[dict]) -> int:
780
+ return sum(
781
+ 1 for item in content_list
782
+ if item.get("type") in {"equation", "formula", "interline_equation"}
783
+ )
784
+
785
+
786
+ def _classify_document(markdown: str, filename: str) -> str:
787
+ """Keyword-based document type heuristic over extracted Markdown + filename."""
788
+ text = (markdown + " " + filename).lower()
789
+
790
+ rules: list[tuple[str, list[str]]] = [
791
+ ("invoice", ["invoice", "bill to", "invoice number", "invoice #",
792
+ "due date", "amount due", "subtotal", "tax invoice"]),
793
+ ("receipt", ["receipt", "thank you for your purchase", "order total",
794
+ "payment received", "transaction id", "cash receipt"]),
795
+ ("marksheet", ["marksheet", "mark sheet", "grade sheet", "scorecard",
796
+ "score card", "cgpa", "sgpa", "semester result",
797
+ "result sheet", "marks obtained"]),
798
+ ("resume", ["curriculum vitae", "cv", "resume", "work experience",
799
+ "education", "skills", "references", "objective",
800
+ "professional summary"]),
801
+ ("research paper", ["abstract", "introduction", "methodology", "conclusion",
802
+ "references", "keywords", "doi:", "arxiv", "journal",
803
+ "proceedings"]),
804
+ ("form", ["please fill", "signature", "date of birth", "applicant",
805
+ "application form", "form no", "checkbox", "tick", "field"]),
806
+ ("contract", ["agreement", "hereby", "whereas", "terms and conditions",
807
+ "party of the first", "signed by", "witnesseth",
808
+ "indemnify", "governing law"]),
809
+ ("screenshot", ["screenshot", "screen capture", "url:", "http://",
810
+ "https://", "browser", "toolbar", "desktop"]),
811
+ ]
812
+
813
+ scores: dict[str, int] = {}
814
+ for doc_type, keywords in rules:
815
+ score = sum(1 for kw in keywords if kw in text)
816
+ if score:
817
+ scores[doc_type] = score
818
+
819
+ return max(scores, key=lambda k: scores[k]) if scores else "generic document"
820
+
821
+
822
+ # ─────────────────────────────────────────────────────────────────────────────
823
+ # Memory β€” cgroup-aware (fixes "105 GB / 123 GB" /proc/meminfo host bleed)
824
+ # ─────────────────────────────────────────────────────────────────────────────
825
+ def _mem_mb() -> tuple[int, int]:
826
+ """
827
+ Return (used_mb, total_mb) for the CONTAINER, not the host.
828
+
829
+ Priority:
830
+ 1. cgroups v2 /sys/fs/cgroup/memory.max + memory.current
831
+ 2. cgroups v1 /sys/fs/cgroup/memory/memory.limit_in_bytes + usage_in_bytes
832
+ 3. /proc/meminfo fallback (may show host memory in Docker β€” known inaccuracy)
833
+
834
+ /proc/meminfo is last resort because HF Docker containers typically do NOT
835
+ have cgroup memory limits mapped into /proc, so it shows the physical host
836
+ RAM (e.g. 123 GB on a 128 GB bare-metal host), misleading the memory guard.
837
+ """
838
+ # ── cgroups v2 (preferred β€” modern Docker / HF Spaces) ───────────────────
839
+ try:
840
+ with open("/sys/fs/cgroup/memory.max") as f:
841
+ raw_max = f.read().strip()
842
+ if raw_max != "max":
843
+ limit_bytes = int(raw_max)
844
+ with open("/sys/fs/cgroup/memory.current") as f:
845
+ used_bytes = int(f.read().strip())
846
+ if limit_bytes > 0:
847
+ return used_bytes // (1024 * 1024), limit_bytes // (1024 * 1024)
848
+ except (FileNotFoundError, ValueError, OSError):
849
+ pass
850
+
851
+ # ── cgroups v1 ────────────────────────────────────────────────────────────
852
+ try:
853
+ with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
854
+ limit_bytes = int(f.read().strip())
855
+ with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f:
856
+ used_bytes = int(f.read().strip())
857
+ # Unconstrained cgroup reports a sentinel > 1 PB; skip it
858
+ if limit_bytes < 128 * 1024 * 1024 * 1024:
859
+ return used_bytes // (1024 * 1024), limit_bytes // (1024 * 1024)
860
+ except (FileNotFoundError, ValueError, OSError):
861
+ pass
862
+
863
+ # ── /proc/meminfo fallback ────────────────────────────────────────────────
864
+ try:
865
+ info: dict[str, int] = {}
866
+ with open("/proc/meminfo") as f:
867
+ for line in f:
868
+ parts = line.split()
869
+ if len(parts) >= 2:
870
+ info[parts[0].rstrip(":")] = int(parts[1]) # values are in kB
871
+ total_kb = info.get("MemTotal", 0)
872
+ avail_kb = info.get("MemAvailable", 0)
873
+ used_kb = total_kb - avail_kb
874
+ return used_kb // 1024, total_kb // 1024
875
+ except Exception:
876
+ return 0, 0
877
+
878
+
879
+ def _mineru_version() -> str:
880
+ for pkg in ("magic-pdf", "mineru"):
881
+ try:
882
+ return pkg_version(pkg)
883
+ except Exception:
884
+ continue
885
+ return "unknown"
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reference only β€” dependencies are installed directly in the Dockerfile
2
+ # to allow two-layer pip caching (small packages / magic-pdf separately).
3
+ #
4
+ # ── Layer 1 β€” small + opencv-headless ────────────────────────────────────────
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 # MUST be layer-1 so magic-pdf sees cv2 as satisfied
12
+ # and does not pull in the full X11 opencv-python build
13
+ #
14
+ # ── Layer 2 β€” magic-pdf (requires --extra-index-url https://myhloli.github.io/wheels/) ──
15
+ magic-pdf[full-cpu]==1.3.12
16
+ #
17
+ # ── Removed ───────────────────────────────────────────────────────────────────
18
+ # python-magic β€” was listed in requirements but never imported in main.py
19
+ # libmagic1 β€” C dep of python-magic, also removed from system packages
validate.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
16
+ import importlib
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ import traceback
24
+
25
+ SOFT_MODE = "--soft" in sys.argv # never exit 1, just print
26
+
27
+ MODELS_DIR = "/app/models"
28
+ EXTRACT_KIT_MODELS = os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0", "models")
29
+ LAYOUT_MARKER = os.path.join(EXTRACT_KIT_MODELS, "Layout") # canary directory
30
+ CONFIG_PATH = os.path.expanduser("~/magic-pdf.json")
31
+
32
+
33
+ # ── helpers ────────────────────────────────────────────────────────────────────
34
+ def ok(label: str, detail: str = "") -> None:
35
+ suffix = f" ({detail})" if detail else ""
36
+ print(f" βœ“ {label}{suffix}", flush=True)
37
+
38
+
39
+ def fail(label: str, detail: str, critical: bool = True) -> None:
40
+ tag = "CRITICAL" if critical else "WARNING"
41
+ print(f" βœ— [{tag}] {label}: {detail}", flush=True)
42
+
43
+
44
+ def section(title: str) -> None:
45
+ print(f"\n{'─' * 60}", flush=True)
46
+ print(f" {title}", flush=True)
47
+ print(f"{'─' * 60}", flush=True)
48
+
49
+
50
+ # ── check registry ─────────────────────────────────────────────────────────────
51
+ failures: list[tuple[str, str]] = [] # (label, detail)
52
+ warnings: list[tuple[str, str]] = []
53
+
54
+
55
+ def record_fail(label: str, detail: str, critical: bool = True) -> None:
56
+ fail(label, detail, critical)
57
+ if critical:
58
+ failures.append((label, detail))
59
+ else:
60
+ warnings.append((label, detail))
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════════════════
64
+ print("\n" + "═" * 60, flush=True)
65
+ print(" MinerU OCR Service β€” Pre-flight Validation", flush=True)
66
+ print("═" * 60, flush=True)
67
+
68
+ # ── 1. Python version ──────────────────────────────────────────────────────────
69
+ section("1. Python runtime")
70
+ pv = sys.version_info
71
+ if pv >= (3, 10):
72
+ ok("Python version", f"{pv.major}.{pv.minor}.{pv.micro}")
73
+ else:
74
+ record_fail("Python version",
75
+ f"{pv.major}.{pv.minor} detected β€” magic-pdf requires >= 3.10")
76
+
77
+ # ── 2. cv2 ─────────────────────────────────────────────────────────────────────
78
+ section("2. OpenCV (cv2)")
79
+ try:
80
+ import cv2
81
+ ok("cv2 import", f"version {cv2.__version__}")
82
+
83
+ # Confirm headless (no X11 dep) by checking build info
84
+ build = cv2.getBuildInformation()
85
+ if "GTK" in build or "Qt" in build:
86
+ record_fail("cv2 build", "GUI backend detected β€” use opencv-python-headless",
87
+ critical=False)
88
+ else:
89
+ ok("cv2 headless", "no GUI backend detected")
90
+ except ImportError as exc:
91
+ record_fail(
92
+ "cv2 import",
93
+ f"{exc}. "
94
+ "Add 'opencv-python-headless>=4.8.0' to Dockerfile pip layer 1 "
95
+ "BEFORE magic-pdf install.",
96
+ )
97
+ except Exception as exc:
98
+ record_fail("cv2 import", f"unexpected error: {exc}")
99
+
100
+ # ── 3. magic_pdf core ──────────────────────────────────────────────────────────
101
+ section("3. magic_pdf core imports")
102
+
103
+ REQUIRED_IMPORTS = [
104
+ ("magic_pdf.data.dataset", ["PymuDocDataset", "ImageDataset"]),
105
+ ("magic_pdf.data.data_reader_writer", ["FileBasedDataReader", "FileBasedDataWriter"]),
106
+ ("magic_pdf.model.doc_analyze_by_custom_model", ["doc_analyze"]),
107
+ ("magic_pdf.config.enums", ["SupportedPdfParseMethod"]),
108
+ ]
109
+
110
+ for module_path, symbols in REQUIRED_IMPORTS:
111
+ try:
112
+ mod = importlib.import_module(module_path)
113
+ missing = [s for s in symbols if not hasattr(mod, s)]
114
+ if missing:
115
+ record_fail(f"{module_path}", f"missing symbols: {missing}")
116
+ else:
117
+ ok(module_path, ", ".join(symbols))
118
+ except ImportError as exc:
119
+ record_fail(module_path, str(exc))
120
+ except Exception as exc:
121
+ record_fail(module_path, f"unexpected: {exc}")
122
+
123
+ # Confirm removed/deprecated imports are truly gone
124
+ section("3b. Deprecated API check (should NOT exist)")
125
+ OBSOLETE = [
126
+ "magic_pdf.pipe.UNIPipe",
127
+ "magic_pdf.rw.DiskReaderWriter",
128
+ ]
129
+ for mod_path in OBSOLETE:
130
+ try:
131
+ importlib.import_module(mod_path)
132
+ record_fail(mod_path, "still importable β€” code may use old API", critical=False)
133
+ except ImportError:
134
+ ok(f"{mod_path} (correctly absent)")
135
+
136
+ # ── 4. Config file ─────────────────────────────────────────────────────────────
137
+ section("4. MinerU config (magic-pdf.json)")
138
+ if os.path.exists(CONFIG_PATH):
139
+ try:
140
+ with open(CONFIG_PATH) as f:
141
+ cfg = json.load(f)
142
+ required_keys = ["models-dir", "device-mode"]
143
+ missing_keys = [k for k in required_keys if k not in cfg]
144
+ if missing_keys:
145
+ record_fail("Config keys", f"missing: {missing_keys}")
146
+ else:
147
+ ok("Config file", CONFIG_PATH)
148
+ ok("device-mode", cfg.get("device-mode", "?"))
149
+ ok("models-dir", cfg.get("models-dir", "?"))
150
+ ok("formula-enable", str(cfg.get("formula-config", {}).get("enable", "?")))
151
+ ok("table-enable", str(cfg.get("table-config", {}).get("enable", "?")))
152
+ except json.JSONDecodeError as exc:
153
+ record_fail("Config file", f"invalid JSON: {exc}")
154
+ except Exception as exc:
155
+ record_fail("Config file", str(exc))
156
+ else:
157
+ record_fail(
158
+ "Config file",
159
+ f"not found at {CONFIG_PATH}. "
160
+ "Run download_models.py or check Docker build log.",
161
+ )
162
+
163
+ # ── 5. Model files ─────────────────────────────────────────────────────────────
164
+ section("5. Model files")
165
+
166
+ model_checks = [
167
+ ("PDF-Extract-Kit-1.0 root", os.path.join(MODELS_DIR, "PDF-Extract-Kit-1.0")),
168
+ ("Layout models (canary)", LAYOUT_MARKER),
169
+ ("MFD models", os.path.join(EXTRACT_KIT_MODELS, "MFD")),
170
+ ("Table models", os.path.join(EXTRACT_KIT_MODELS, "TabRec")),
171
+ ]
172
+
173
+ for label, path in model_checks:
174
+ if os.path.isdir(path):
175
+ # Count files for a sanity check
176
+ try:
177
+ n = sum(1 for _ in os.scandir(path))
178
+ ok(label, f"{path} ({n} entries)")
179
+ except OSError:
180
+ ok(label, path)
181
+ else:
182
+ record_fail(label, f"directory not found: {path}")
183
+
184
+ # layoutreader β€” optional
185
+ lr_dir = os.path.join(MODELS_DIR, "layoutreader")
186
+ if os.path.isdir(lr_dir):
187
+ ok("layoutreader (optional)", lr_dir)
188
+ else:
189
+ record_fail("layoutreader (optional)",
190
+ "not found β€” MinerU will use fallback ordering (non-critical)",
191
+ critical=False)
192
+
193
+ # Validate config models-dir points to existing path
194
+ try:
195
+ with open(CONFIG_PATH) as f:
196
+ cfg = json.load(f)
197
+ cfg_models = cfg.get("models-dir", "")
198
+ if cfg_models and os.path.isdir(cfg_models):
199
+ ok("Config models-dir exists", cfg_models)
200
+ elif cfg_models:
201
+ record_fail("Config models-dir", f"points to missing path: {cfg_models}")
202
+ except Exception:
203
+ pass # already reported above
204
+
205
+ # ── 6. Temp storage ────────────────────────────────────────────────────────────
206
+ section("6. Temp storage")
207
+ try:
208
+ td = tempfile.mkdtemp(prefix="mineru_validate_")
209
+ test_file = os.path.join(td, "write_test.bin")
210
+ with open(test_file, "wb") as f:
211
+ f.write(b"x" * 4096)
212
+ assert os.path.getsize(test_file) == 4096
213
+ shutil.rmtree(td)
214
+ ok("Temp write + delete", tempfile.gettempdir())
215
+ except Exception as exc:
216
+ record_fail("Temp storage", str(exc))
217
+
218
+ # ── 7. System memory ───────────────────────────────────────────────────────────
219
+ section("7. System memory (cgroups)")
220
+ mem_source = "unknown"
221
+ total_mb = used_mb = 0
222
+
223
+ try:
224
+ with open("/sys/fs/cgroup/memory.max") as f:
225
+ raw = f.read().strip()
226
+ if raw != "max":
227
+ total_mb = int(raw) // (1024 * 1024)
228
+ with open("/sys/fs/cgroup/memory.current") as f:
229
+ used_mb = int(f.read().strip()) // (1024 * 1024)
230
+ mem_source = "cgroups v2"
231
+ except (FileNotFoundError, ValueError):
232
+ pass
233
+
234
+ if total_mb == 0:
235
+ try:
236
+ with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
237
+ limit = int(f.read().strip())
238
+ with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f:
239
+ used_bytes = int(f.read().strip())
240
+ if limit < 128 * 1024 * 1024 * 1024:
241
+ total_mb = limit // (1024 * 1024)
242
+ used_mb = used_bytes // (1024 * 1024)
243
+ mem_source = "cgroups v1"
244
+ except (FileNotFoundError, ValueError):
245
+ pass
246
+
247
+ if total_mb == 0:
248
+ try:
249
+ info: dict[str, int] = {}
250
+ with open("/proc/meminfo") as f:
251
+ for line in f:
252
+ parts = line.split()
253
+ if len(parts) >= 2:
254
+ info[parts[0].rstrip(":")] = int(parts[1])
255
+ total_mb = info.get("MemTotal", 0) // 1024
256
+ used_mb = (info.get("MemTotal", 0) - info.get("MemAvailable", 0)) // 1024
257
+ mem_source = "/proc/meminfo (may show host RAM)"
258
+ except Exception:
259
+ pass
260
+
261
+ ok("Memory source", mem_source)
262
+ ok("Total memory", f"{total_mb} MB")
263
+ ok("Used memory", f"{used_mb} MB")
264
+ ok("Free memory", f"{total_mb - used_mb} MB")
265
+
266
+ if total_mb > 32 * 1024:
267
+ record_fail(
268
+ "Memory total",
269
+ f"{total_mb} MB seems too large for a container β€” "
270
+ "cgroups may not be available; /proc/meminfo is showing host RAM. "
271
+ "Memory guard in main.py will be conservative.",
272
+ critical=False,
273
+ )
274
+
275
+ # ── 8. /proc/meminfo sanity ────────────────────────────────────────────────────
276
+ section("8. /proc/meminfo (for reference)")
277
+ try:
278
+ with open("/proc/meminfo") as f:
279
+ lines = f.readlines()[:5]
280
+ for line in lines:
281
+ parts = line.split()
282
+ if len(parts) >= 2:
283
+ kb = int(parts[1])
284
+ ok(parts[0].rstrip(":"), f"{kb // 1024} MB")
285
+ except Exception as exc:
286
+ record_fail("/proc/meminfo", str(exc), critical=False)
287
+
288
+ # ═══════════════════════════════════════════════════════════════════════════════
289
+ # Summary
290
+ # ═══════════════════════════════════════════════════════════════════════════════
291
+ print("\n" + "═" * 60, flush=True)
292
+ print(" Validation Summary", flush=True)
293
+ print("═" * 60, flush=True)
294
+
295
+ if warnings:
296
+ print(f"\n ⚠ {len(warnings)} warning(s):", flush=True)
297
+ for label, detail in warnings:
298
+ print(f" β€’ {label}: {detail}", flush=True)
299
+
300
+ if failures:
301
+ print(f"\n βœ— {len(failures)} CRITICAL failure(s):", flush=True)
302
+ for label, detail in failures:
303
+ print(f" β€’ {label}: {detail}", flush=True)
304
+ print("\n Service will NOT start until these are resolved.", flush=True)
305
+ print(" Check Dockerfile pip layers and Docker build log.", flush=True)
306
+ print("═" * 60 + "\n", flush=True)
307
+ if not SOFT_MODE:
308
+ sys.exit(1)
309
+ else:
310
+ print(f"\n βœ“ All critical checks passed", flush=True)
311
+ if warnings:
312
+ print(f" ⚠ {len(warnings)} non-critical warning(s) β€” see above", flush=True)
313
+ print("\n Service is ready to start.", flush=True)
314
+ print("═" * 60 + "\n", flush=True)
315
+ sys.exit(0)