fix(ocr): fix PaddleOCR crashes, add multi-image and auto-detect language

- Pin PaddlePaddle to 3.0.0 on ARM64 to fix segfault in PIR inference
  engine (3.1+ crashes on aarch64 Debian Bookworm)
- Fix text extraction for PaddleOCR 3.4.x result format (rec_texts)
- Add Node.js-level fallback chain (best -> balanced -> fast) when
  Python subprocess crashes
- Add multi-image OCR: processes all uploaded files sequentially with
  per-file progress and filename headers in combined output
- Convert input images to PNG via Sharp before OCR so HEIC, AVIF, WebP,
  TIFF all work transparently
- Implement real auto-detect language using Tesseract multi-lang script
  detection (analyzes Unicode ranges for Hangul, CJK, Kana, Latin)
- Default enhance to off (hurts clean digital images)
This commit is contained in:
Siddharth Kumar Sah
2026-04-12 23:46:39 +08:00
parent f2e17d2d44
commit 29fafd0722
6 changed files with 259 additions and 120 deletions
+57 -21
View File
@@ -103,30 +103,66 @@ export function registerOcr(app: FastifyInstance) {
} }
: undefined; : undefined;
const result = await extractText( // Fallback chain: best -> balanced -> fast
fileBuffer, // PaddleOCR can crash with segfault on some platforms, so we retry
workspacePath, // with a lower quality tier at the Node.js level.
{ const fallbackChain: Array<"fast" | "balanced" | "best"> =
quality, quality === "best"
language: settings.language, ? ["best", "balanced", "fast"]
enhance: settings.enhance, : quality === "balanced"
}, ? ["balanced", "fast"]
onProgress, : ["fast"];
);
if (clientJobId) { let lastError: unknown;
updateSingleFileProgress({ for (const tier of fallbackChain) {
jobId: clientJobId, try {
phase: "complete", const result = await extractText(
percent: 100, fileBuffer,
}); workspacePath,
{
quality: tier,
language: settings.language,
enhance: settings.enhance,
},
onProgress,
);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
return reply.send({
jobId,
filename,
text: result.text,
});
} catch (err) {
lastError = err;
const msg = err instanceof Error ? err.message : String(err);
// If the Python process crashed (segfault, dispatcher exit), try next tier
if (
msg.includes("exited unexpectedly") ||
msg.includes("exited with code") ||
msg.includes("Segmentation fault")
) {
request.log.warn(
{ toolId: "ocr", quality: tier, err },
`OCR ${tier} crashed, falling back`,
);
if (onProgress) onProgress(15, "Retrying...");
continue;
}
// Non-crash errors (validation, timeout) should not retry
throw err;
}
} }
return reply.send({ // All tiers failed
jobId, throw lastError;
filename,
text: result.text,
});
} catch (err) { } catch (err) {
request.log.error({ err, toolId: "ocr" }, "OCR failed"); request.log.error({ err, toolId: "ocr" }, "OCR failed");
return reply.status(422).send({ return reply.status(422).send({
+110 -71
View File
@@ -25,8 +25,8 @@ const LANGUAGES = [
]; ];
const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = { const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
fast: true, fast: false,
balanced: true, balanced: false,
best: false, best: false,
}; };
@@ -38,12 +38,73 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
); );
} }
/** Send one file to the OCR API and return the extracted text. */
function ocrOneFile(
file: File,
settings: { quality: string; language: string; enhance: boolean },
callbacks: {
onUploadProgress: (pct: number) => void;
onProcessingProgress: (pct: number, stage: string) => void;
},
): Promise<string> {
return new Promise((resolve, reject) => {
const clientJobId = generateId();
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
callbacks.onProcessingProgress(data.percent, data.stage);
}
} catch {}
};
es.onerror = () => es.close();
const formData = new FormData();
formData.append("file", file);
formData.append("settings", JSON.stringify(settings));
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) callbacks.onUploadProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => {
es.close();
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText).text ?? "");
} catch {
reject(new Error("Invalid response"));
}
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.error || body.details || `Failed: ${xhr.status}`));
} catch {
reject(new Error(`Processing failed: ${xhr.status}`));
}
}
};
xhr.onerror = () => {
es.close();
reject(new Error("Network error"));
};
xhr.open("POST", "/api/v1/tools/ocr");
for (const [key, value] of formatHeaders()) {
xhr.setRequestHeader(key, value);
}
xhr.send(formData);
});
}
export function OcrSettings() { export function OcrSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore(); const { files, processing, error, setProcessing, setError } = useFileStore();
const [quality, setQuality] = useState<OcrQuality>("balanced"); const [quality, setQuality] = useState<OcrQuality>("balanced");
const [language, setLanguage] = useState("auto"); const [language, setLanguage] = useState("auto");
const [enhance, setEnhance] = useState(true); const [enhance, setEnhance] = useState(false);
const [enhanceManuallySet, setEnhanceManuallySet] = useState(false); const [enhanceManuallySet, setEnhanceManuallySet] = useState(false);
const [langOpen, setLangOpen] = useState(false); const [langOpen, setLangOpen] = useState(false);
@@ -57,10 +118,7 @@ export function OcrSettings() {
const handleQualityChange = (q: OcrQuality) => { const handleQualityChange = (q: OcrQuality) => {
setQuality(q); setQuality(q);
// Update enhance default unless user has manually toggled it if (!enhanceManuallySet) setEnhance(ENHANCE_DEFAULTS[q]);
if (!enhanceManuallySet) {
setEnhance(ENHANCE_DEFAULTS[q]);
}
}; };
const handleEnhanceToggle = (checked: boolean) => { const handleEnhanceToggle = (checked: boolean) => {
@@ -84,70 +142,50 @@ export function OcrSettings() {
setElapsed(Math.floor((Date.now() - startTime) / 1000)); setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000); }, 1000);
const clientJobId = generateId(); const settings = { quality, language, enhance };
const total = files.length;
const results: string[] = [];
const errors: string[] = [];
for (let i = 0; i < total; i++) {
const file = files[i];
const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
// Each file gets an equal share of the 0-100 progress bar
const fileBase = (i / total) * 100;
const fileShare = 100 / total;
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
es.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const text = await ocrOneFile(file, settings, {
if (data.type === "single" && typeof data.percent === "number") { onUploadProgress: (pct) => {
setProgressPhase("processing"); setProgressPhase("uploading");
setProgressPercent(15 + (data.percent / 100) * 85); setProgressPercent(fileBase + (pct / 100) * fileShare * 0.15);
setProgressStage(data.stage); setProgressStage(`${prefix}Uploading...`);
} },
} catch {} onProcessingProgress: (pct, stage) => {
}; setProgressPhase("processing");
es.onerror = () => es.close(); setProgressPercent(fileBase + fileShare * 0.15 + (pct / 100) * fileShare * 0.85);
setProgressStage(`${prefix}${stage}`);
const formData = new FormData(); },
formData.append("file", files[0]); });
formData.append("settings", JSON.stringify({ quality, language, enhance })); results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
formData.append("clientJobId", clientJobId); } catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const xhr = new XMLHttpRequest(); errors.push(`${file.name}: ${msg}`);
xhr.upload.onprogress = (e) => { results.push(total > 1 ? `--- ${file.name} ---\n(error: ${msg})` : "");
if (e.lengthComputable) {
setProgressPercent((e.loaded / e.total) * 15);
} }
}; }
xhr.upload.onload = () => {
setProgressPhase("processing"); if (elapsedRef.current) clearInterval(elapsedRef.current);
setProgressPercent(15);
setProgressStage("Starting..."); if (errors.length === total) {
}; setError(errors.join("; "));
xhr.onload = () => { } else if (errors.length > 0) {
if (elapsedRef.current) clearInterval(elapsedRef.current); setError(`${errors.length} of ${total} files failed`);
es.close(); }
if (xhr.status >= 200 && xhr.status < 300) {
try { setText(results.join("\n\n"));
const data = JSON.parse(xhr.responseText); setProcessing(false);
setText(data.text ?? ""); setProgressPhase("idle");
} catch {
setError("Invalid response");
}
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Failed: ${xhr.status}`);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
}
setProcessing(false);
setProgressPhase("idle");
};
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
setError("Network error");
setProcessing(false);
setProgressPhase("idle");
};
xhr.open("POST", "/api/v1/tools/ocr");
formatHeaders().forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
xhr.send(formData);
}; };
const handleCopy = async () => { const handleCopy = async () => {
@@ -165,7 +203,8 @@ export function OcrSettings() {
const blob = new Blob([text], { type: "text/plain" }); const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
const baseName = files[0]?.name?.replace(/\.[^.]+$/, "") ?? "extracted"; const baseName =
files.length === 1 ? (files[0]?.name?.replace(/\.[^.]+$/, "") ?? "extracted") : "ocr_results";
a.href = url; a.href = url;
a.download = `${baseName}_ocr.txt`; a.download = `${baseName}_ocr.txt`;
document.body.appendChild(a); document.body.appendChild(a);
@@ -264,7 +303,7 @@ export function OcrSettings() {
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
Extract Text {files.length > 1 ? `Extract Text (${files.length} files)` : "Extract Text"}
</button> </button>
)} )}
@@ -300,7 +339,7 @@ export function OcrSettings() {
data-testid="ocr-result-text" data-testid="ocr-result-text"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
rows={8} rows={Math.min(16, Math.max(8, text.split("\n").length + 2))}
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y" className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
/> />
<p className="text-[10px] text-muted-foreground">{text.length} characters</p> <p className="text-[10px] text-muted-foreground">{text.length} characters</p>
+1 -1
View File
@@ -127,7 +127,7 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
; else \ ; else \
/opt/venv/bin/pip install "rembg[cpu]==2.0.62" && \ /opt/venv/bin/pip install "rembg[cpu]==2.0.62" && \
/opt/venv/bin/pip install realesrgan==0.3.0 && \ /opt/venv/bin/pip install realesrgan==0.3.0 && \
/opt/venv/bin/pip install paddlepaddle>=3.2.1 "paddleocr[doc-parser]>=3.4.0,<3.5.0" \ /opt/venv/bin/pip install paddlepaddle==3.0.0 "paddleocr[doc-parser]>=3.4.0,<3.5.0" \
; fi ; fi
# mediapipe 0.10.21 only has amd64 wheels; arm64 maxes out at 0.10.18 # mediapipe 0.10.21 only has amd64 wheels; arm64 maxes out at 0.10.18
+84 -25
View File
@@ -24,21 +24,53 @@ PADDLE_LANG_MAP = {
def auto_detect_language(input_path): def auto_detect_language(input_path):
"""Return the default language for OCR. """Detect the predominant script in the image using Tesseract multi-lang.
Currently defaults to "en" which works well across engines. Runs a quick Tesseract pass with all installed language packs,
PaddleOCR PP-OCRv5 and VL handle multi-script input natively then analyzes the Unicode character ranges in the output to
regardless of the language parameter, so the default is sufficient determine which PaddleOCR language model to use.
for most use cases. Users can override via the language dropdown.
""" """
return "en" import subprocess
try:
result = subprocess.run(
["tesseract", input_path, "stdout", "-l", "eng+kor+chi_sim+jpn"],
capture_output=True, text=True, timeout=30,
)
text = result.stdout.strip()
if not text:
return "en"
hangul = sum(1 for c in text if "\uAC00" <= c <= "\uD7AF" or "\u1100" <= c <= "\u11FF")
cjk = sum(1 for c in text if "\u4E00" <= c <= "\u9FFF")
hiragana = sum(1 for c in text if "\u3040" <= c <= "\u309F")
katakana = sum(1 for c in text if "\u30A0" <= c <= "\u30FF")
latin = sum(1 for c in text if c.isascii() and c.isalpha())
total = hangul + cjk + hiragana + katakana + latin
if total == 0:
return "en"
if hangul / total > 0.3:
return "ko"
if (hiragana + katakana) / total > 0.2:
return "ja"
if cjk / total > 0.3:
return "zh"
return "en"
except Exception:
return "en"
def run_tesseract(input_path, language): def run_tesseract(input_path, language, is_auto=False):
"""Run Tesseract OCR (Fast tier).""" """Run Tesseract OCR (Fast tier)."""
import subprocess import subprocess
tess_lang = TESSERACT_LANG_MAP.get(language, "eng") # When auto-detected, use all installed language packs for best coverage
if is_auto:
tess_lang = "eng+kor+chi_sim+jpn+deu+fra+spa"
else:
tess_lang = TESSERACT_LANG_MAP.get(language, "eng")
emit_progress(30, "Scanning") emit_progress(30, "Scanning")
result = subprocess.run( result = subprocess.run(
@@ -54,6 +86,28 @@ def run_tesseract(input_path, language):
return text return text
def _extract_ocr_texts(results):
"""Extract text from PaddleOCR 3.x result objects.
Handles multiple result formats across PaddleOCR versions:
- 3.4.x: OCRResult with .json["res"]["rec_texts"]
- Earlier: result objects with .res dict containing "text" list
"""
text_parts = []
for res in results:
# PaddleOCR 3.4.x format: OCRResult with .json dict
if hasattr(res, "json") and isinstance(res.json, dict):
inner = res.json.get("res", {})
rec_texts = inner.get("rec_texts", [])
if rec_texts:
text_parts.extend(rec_texts)
continue
# Older format: .res dict with "text" list
if hasattr(res, "res") and isinstance(res.res, dict):
text_parts.extend(res.res.get("text", []))
return "\n".join(text_parts)
def run_paddleocr_v5(input_path, language): def run_paddleocr_v5(input_path, language):
"""Run PaddleOCR PP-OCRv5 server models (Balanced tier).""" """Run PaddleOCR PP-OCRv5 server models (Balanced tier)."""
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
@@ -62,30 +116,28 @@ def run_paddleocr_v5(input_path, language):
os.dup2(2, 1) os.dup2(2, 1)
try: try:
import logging
from paddleocr import PaddleOCR from paddleocr import PaddleOCR
from gpu import gpu_available from gpu import gpu_available
# Suppress PaddleOCR internal logging (replaces removed show_log param)
for name in ("ppocr", "paddleocr", "paddle"):
logging.getLogger(name).setLevel(logging.ERROR)
paddle_lang = PADDLE_LANG_MAP.get(language, "en") paddle_lang = PADDLE_LANG_MAP.get(language, "en")
device = "gpu:0" if gpu_available() else "cpu"
emit_progress(20, "Loading") emit_progress(20, "Loading")
ocr = PaddleOCR( ocr = PaddleOCR(
lang=paddle_lang, lang=paddle_lang,
use_gpu=gpu_available(), device=device,
show_log=False,
ocr_version="PP-OCRv5", ocr_version="PP-OCRv5",
) )
emit_progress(30, "Scanning") emit_progress(30, "Scanning")
result = ocr.ocr(input_path) results = ocr.predict(input=input_path)
emit_progress(70, "Extracting text") emit_progress(70, "Extracting text")
text = "\n".join(
[ text = _extract_ocr_texts(results)
line[1][0]
for res in result
if res
for line in res
if line and line[1]
]
)
finally: finally:
os.dup2(stdout_fd, 1) os.dup2(stdout_fd, 1)
os.close(stdout_fd) os.close(stdout_fd)
@@ -98,6 +150,7 @@ def run_paddleocr_vl(input_path):
The VLM is lazy-loaded on first call and stays resident in the The VLM is lazy-loaded on first call and stays resident in the
dispatcher process for subsequent requests. dispatcher process for subsequent requests.
Requires PaddlePaddle >= 3.2 for fused_rms_norm_ext.
""" """
global _paddleocr_vl_instance global _paddleocr_vl_instance
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
@@ -127,6 +180,11 @@ def run_paddleocr_vl(input_path):
text_parts.append(content) text_parts.append(content)
elif hasattr(res, "rec_text"): elif hasattr(res, "rec_text"):
text_parts.append(res.rec_text) text_parts.append(res.rec_text)
# Also try the json-based extraction as fallback
elif hasattr(res, "json") and isinstance(res.json, dict):
inner = res.json.get("res", {})
rec_texts = inner.get("rec_texts", [])
text_parts.extend(rec_texts)
text = "\n".join(text_parts) text = "\n".join(text_parts)
finally: finally:
@@ -166,14 +224,15 @@ def main():
preprocessed_path = None preprocessed_path = None
# Language auto-detection # Language auto-detection
if language == "auto": was_auto = language == "auto"
if was_auto:
emit_progress(10, "Detecting language") emit_progress(10, "Detecting language")
language = auto_detect_language(input_path) language = auto_detect_language(input_path)
# Route to engine based on quality tier # Route to engine based on quality tier
if quality == "fast": if quality == "fast":
try: try:
text = run_tesseract(input_path, language) text = run_tesseract(input_path, language, is_auto=was_auto)
except FileNotFoundError: except FileNotFoundError:
print(json.dumps({"success": False, "error": "Tesseract is not installed"})) print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
sys.exit(1) sys.exit(1)
@@ -187,7 +246,7 @@ def main():
except Exception: except Exception:
emit_progress(25, "Falling back") emit_progress(25, "Falling back")
try: try:
text = run_tesseract(input_path, language) text = run_tesseract(input_path, language, is_auto=was_auto)
except FileNotFoundError: except FileNotFoundError:
print(json.dumps({"success": False, "error": "OCR engines unavailable"})) print(json.dumps({"success": False, "error": "OCR engines unavailable"}))
sys.exit(1) sys.exit(1)
@@ -200,13 +259,13 @@ def main():
try: try:
text = run_paddleocr_v5(input_path, language) text = run_paddleocr_v5(input_path, language)
except Exception: except Exception:
text = run_tesseract(input_path, language) text = run_tesseract(input_path, language, is_auto=was_auto)
except Exception: except Exception:
emit_progress(20, "Falling back") emit_progress(20, "Falling back")
try: try:
text = run_paddleocr_v5(input_path, language) text = run_paddleocr_v5(input_path, language)
except Exception: except Exception:
text = run_tesseract(input_path, language) text = run_tesseract(input_path, language, is_auto=was_auto)
else: else:
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"})) print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
+1 -1
View File
@@ -1,7 +1,7 @@
rembg[cpu]==2.0.62 rembg[cpu]==2.0.62
realesrgan==0.3.0 realesrgan==0.3.0
paddleocr[doc-parser]>=3.4.0,<3.5.0 paddleocr[doc-parser]>=3.4.0,<3.5.0
paddlepaddle>=3.2.1 paddlepaddle>=3.0.0,<3.1.0
mediapipe==0.10.21 mediapipe==0.10.21
onnxruntime==1.20.1 onnxruntime==1.20.1
numpy==1.26.4 numpy==1.26.4
+6 -1
View File
@@ -1,5 +1,6 @@
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, runPythonWithProgress } from "./bridge.js"; import { type ProgressCallback, runPythonWithProgress } from "./bridge.js";
export type OcrQuality = "fast" | "balanced" | "best"; export type OcrQuality = "fast" | "balanced" | "best";
@@ -24,7 +25,11 @@ export async function extractText(
): Promise<OcrResult> { ): Promise<OcrResult> {
const inputPath = join(outputDir, "input_ocr.png"); const inputPath = join(outputDir, "input_ocr.png");
await writeFile(inputPath, inputBuffer); // Convert any input format (HEIC, AVIF, WebP, TIFF, etc.) to PNG
// so Tesseract and PaddleOCR can read it reliably.
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
const { stdout } = await runPythonWithProgress("ocr.py", [inputPath, JSON.stringify(options)], { const { stdout } = await runPythonWithProgress("ocr.py", [inputPath, JSON.stringify(options)], {
onProgress, onProgress,
timeout: 600_000, // 10 min timeout for VLM on CPU timeout: 600_000, // 10 min timeout for VLM on CPU