mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Add enable_mkldnn=False to PaddleOCR constructor to bypass PaddlePaddle 3.3+ OneDNN/PIR crash on CPU-only systems - Add 25MP and 75% max-reduction guard to seam carving with clear error messages instead of silent timeout/crash - Replace barcode/QR AVIF test fixtures with actual scannable codes (old fixtures did not contain real barcodes)
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""Text extraction from images using Tesseract, PaddleOCR PP-OCRv5, or PaddleOCR-VL 1.5."""
|
|
import sys
|
|
import json
|
|
import os
|
|
|
|
# Prevent PaddlePaddle C++ runtime from probing for CUDA on CPU-only systems.
|
|
# Without these, paddlepaddle-gpu can segfault during import on machines without
|
|
# a GPU, because the C++ layer attempts GPU initialization before Python-level
|
|
# device routing takes effect. Must run before any PaddleOCR import.
|
|
from gpu import gpu_available as _gpu_available
|
|
if not _gpu_available():
|
|
if not os.environ.get("FLAGS_use_cuda"):
|
|
os.environ["FLAGS_use_cuda"] = "0"
|
|
if not os.environ.get("FLAGS_use_cudnn"):
|
|
os.environ["FLAGS_use_cudnn"] = "0"
|
|
|
|
# Lazy-loaded VLM instance (stays resident in dispatcher process)
|
|
_paddleocr_vl_instance = None
|
|
|
|
|
|
def emit_progress(percent, stage):
|
|
"""Emit structured progress to stderr for bridge.ts to capture."""
|
|
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
|
|
|
|
|
|
TESSERACT_LANG_MAP = {
|
|
"en": "eng", "de": "deu", "fr": "fra", "es": "spa",
|
|
"zh": "chi_sim", "ja": "jpn", "ko": "kor",
|
|
}
|
|
|
|
PADDLE_LANG_MAP = {
|
|
"en": "en", "de": "latin", "fr": "latin", "es": "latin",
|
|
"zh": "ch", "ja": "japan", "ko": "korean",
|
|
}
|
|
|
|
|
|
def auto_detect_language(input_path):
|
|
"""Detect the predominant script in the image using Tesseract multi-lang.
|
|
|
|
Runs a quick Tesseract pass with all installed language packs,
|
|
then analyzes the Unicode character ranges in the output to
|
|
determine which PaddleOCR language model to use.
|
|
"""
|
|
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, is_auto=False):
|
|
"""Run Tesseract OCR (Fast tier)."""
|
|
import subprocess
|
|
|
|
# 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")
|
|
result = subprocess.run(
|
|
["tesseract", input_path, "stdout", "-l", tess_lang],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
emit_progress(70, "Extracting text")
|
|
text = result.stdout.strip()
|
|
if result.returncode != 0 and not text:
|
|
raise RuntimeError(result.stderr.strip() or "Tesseract failed")
|
|
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):
|
|
"""Run PaddleOCR PP-OCRv5 server models (Balanced tier)."""
|
|
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
|
|
|
stdout_fd = os.dup(1)
|
|
os.dup2(2, 1)
|
|
|
|
try:
|
|
import logging
|
|
from paddleocr import PaddleOCR
|
|
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")
|
|
device = "gpu:0" if gpu_available() else "cpu"
|
|
|
|
emit_progress(20, "Loading")
|
|
ocr = PaddleOCR(
|
|
lang=paddle_lang,
|
|
device=device,
|
|
ocr_version="PP-OCRv5",
|
|
enable_mkldnn=False,
|
|
)
|
|
emit_progress(30, "Scanning")
|
|
results = ocr.predict(input=input_path)
|
|
emit_progress(70, "Extracting text")
|
|
|
|
text = _extract_ocr_texts(results)
|
|
finally:
|
|
os.dup2(stdout_fd, 1)
|
|
os.close(stdout_fd)
|
|
|
|
return text
|
|
|
|
|
|
def run_paddleocr_vl(input_path):
|
|
"""Run PaddleOCR-VL 1.5 vision-language model (Best tier).
|
|
|
|
The VLM is lazy-loaded on first call and stays resident in the
|
|
dispatcher process for subsequent requests.
|
|
Requires PaddlePaddle >= 3.2 for fused_rms_norm_ext.
|
|
"""
|
|
global _paddleocr_vl_instance
|
|
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
|
|
|
stdout_fd = os.dup(1)
|
|
os.dup2(2, 1)
|
|
|
|
try:
|
|
if _paddleocr_vl_instance is None:
|
|
emit_progress(15, "Loading model")
|
|
from paddleocr import PaddleOCRVL
|
|
from gpu import gpu_available
|
|
|
|
device = "gpu" if gpu_available() else "cpu"
|
|
_paddleocr_vl_instance = PaddleOCRVL(device=device)
|
|
|
|
emit_progress(30, "Scanning")
|
|
output = _paddleocr_vl_instance.predict(input_path)
|
|
emit_progress(70, "Extracting text")
|
|
|
|
text_parts = []
|
|
for res in output:
|
|
if hasattr(res, "parsing_res_list"):
|
|
for block in res.parsing_res_list:
|
|
content = block.get("block_content", "")
|
|
if content:
|
|
text_parts.append(content)
|
|
elif hasattr(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)
|
|
finally:
|
|
os.dup2(stdout_fd, 1)
|
|
os.close(stdout_fd)
|
|
|
|
return text
|
|
|
|
|
|
def main():
|
|
input_path = sys.argv[1]
|
|
settings = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
|
|
|
quality = settings.get("quality", None)
|
|
language = settings.get("language", "auto")
|
|
enhance = settings.get("enhance", True)
|
|
|
|
# Backward compat: old "engine" param maps to quality
|
|
if quality is None:
|
|
engine = settings.get("engine", "tesseract")
|
|
quality = "fast" if engine == "tesseract" else "balanced"
|
|
|
|
preprocessed_path = None
|
|
try:
|
|
emit_progress(5, "Preparing")
|
|
|
|
# Preprocessing (if enabled)
|
|
if enhance:
|
|
emit_progress(8, "Enhancing image")
|
|
try:
|
|
from ocr_preprocess import preprocess
|
|
preprocessed_path = input_path + "_enhanced.png"
|
|
preprocess(input_path, preprocessed_path)
|
|
input_path = preprocessed_path
|
|
except Exception as e:
|
|
print(json.dumps({"warning": f"Enhancement skipped: {e}"}), file=sys.stderr, flush=True)
|
|
preprocessed_path = None
|
|
|
|
# Language auto-detection
|
|
was_auto = language == "auto"
|
|
if was_auto:
|
|
emit_progress(10, "Detecting language")
|
|
language = auto_detect_language(input_path)
|
|
|
|
engine_used = quality
|
|
|
|
# Route to engine based on quality tier
|
|
if quality == "fast":
|
|
try:
|
|
text = run_tesseract(input_path, language, is_auto=was_auto)
|
|
engine_used = "tesseract"
|
|
except FileNotFoundError:
|
|
print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
|
|
sys.exit(1)
|
|
|
|
elif quality == "balanced":
|
|
try:
|
|
text = run_paddleocr_v5(input_path, language)
|
|
engine_used = "paddleocr-v5"
|
|
except ImportError as e:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": (
|
|
f"PaddleOCR is not installed: {e}. "
|
|
"Install the OCR feature or use quality=fast for Tesseract."
|
|
),
|
|
}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": (
|
|
f"PaddleOCR PP-OCRv5 failed: {type(e).__name__}: {e}. "
|
|
"Install the OCR feature or use quality=fast for Tesseract."
|
|
),
|
|
}))
|
|
sys.exit(1)
|
|
|
|
elif quality == "best":
|
|
try:
|
|
text = run_paddleocr_vl(input_path)
|
|
engine_used = "paddleocr-vl"
|
|
except ImportError as e:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": (
|
|
f"PaddleOCR-VL is not available: {e}. "
|
|
"Install the OCR feature or use quality=balanced for PP-OCRv5."
|
|
),
|
|
}))
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(json.dumps({
|
|
"success": False,
|
|
"error": (
|
|
f"PaddleOCR-VL failed: {type(e).__name__}: {e}. "
|
|
"Install the OCR feature or use quality=balanced for PP-OCRv5."
|
|
),
|
|
}))
|
|
sys.exit(1)
|
|
|
|
else:
|
|
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
|
|
sys.exit(1)
|
|
|
|
emit_progress(95, "Done")
|
|
print(json.dumps({"success": True, "text": text, "engine": engine_used}))
|
|
|
|
except Exception as e:
|
|
print(json.dumps({"success": False, "error": str(e)}))
|
|
sys.exit(1)
|
|
finally:
|
|
# Clean up preprocessed temp file
|
|
if preprocessed_path:
|
|
try:
|
|
os.remove(preprocessed_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|