690611:1705 ADR-035-235 #00 [skip CI]
CI / CD Pipeline / build (push) Has been skipped
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-06-11 17:05:17 +07:00
parent cd7d20ccd4
commit 71c5e88181
14 changed files with 1422 additions and 682 deletions
@@ -1,5 +1,5 @@
# File: specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/Dockerfile
# Tesseract OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF/Image
# Typhoon OCR Sidecar — HTTP API server สำหรับสกัดข้อความจาก PDF ผ่าน np-dms-ocr (Ollama)
# รันบน Desk-5439 ตาม ADR-023A
# Change Log:
# - 2026-05-25: Initial Dockerfile สำหรับ OCR sidecar (port 8765)
@@ -7,23 +7,17 @@
# - 2026-05-30: เพิ่ม system dependencies สำหรับ OpenCV (libsm6, libxext6, libxrender1, libfontconfig1, libx11-6)
# - 2026-05-30: Typhoon OCR ใช้ httpx เรียก Ollama ผ่าน OLLAMA_API_URL (T009a, ADR-032)
# Container รันบน CPU เท่านั้น ไม่ต้องการ CUDA/GPU ใน container
# - 2026-06-11: เพิ่ม typhoon-ocr ใน requirements.txt — poppler-utils มีอยู่แล้ว (ใช้โดย prepare_ocr_messages)
# - 2026-06-11: ตัด tesseract-ocr, tesseract-ocr-tha, tesseract-ocr-eng, libsm6, libxext6, libxrender1, libfontconfig1, libx11-6 — ไม่ใช้ Tesseract อีกต่อไป
FROM python:3.10-slim
# ติดตั้ง system dependencies สำหรับ PDF processing, Tesseract OCR, ภาษาไทย และ OpenCV
# ติดตั้ง system dependencies สำหรับ PDF processing และ PyMuPDF
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgl1 \
libgomp1 \
poppler-utils \
tesseract-ocr \
tesseract-ocr-tha \
tesseract-ocr-eng \
libsm6 \
libxext6 \
libxrender1 \
libfontconfig1 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -1,6 +1,6 @@
# File: specs/04-Infrastructure-OPS/04-00-docker-compose\Desk-5439\ocr-sidecar\app.py
# Tesseract OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
# ตาม ADR-023A: OCR auto-detect (PyMuPDF chars > 100 → Fast path, else Tesseract)
# Typhoon OCR HTTP Sidecar API — รับ POST /ocr แล้วคืนข้อความที่สกัดจาก PDF/Image
# ตาม ADR-023A (revised 2026-06-11): ใช้ typhoon_ocr library + np-dms-ocr (Ollama) แทน Tesseract
# Change Log:
# - 2026-05-25: Initial FastAPI server สำหรับ Tesseract OCR sidecar
# - 2026-05-30: เปลี่ยน lang='en' เป็น lang='ch' (CTJK) เพื่อรองรับภาษาไทย
@@ -20,20 +20,21 @@
# - 2026-06-02: เพิ่มการตรวจสอบ API Key (X-API-Key Header) สำหรับ endpoints หลัก เพื่อความมั่นคงปลอดภัยตามข้อเสนอแนะ Code Review
# - 2026-06-05: เพิ่ม Option 2 (aggressive preprocessing: deskew + Otsu threshold + morphology) และ Option 3 (smart post-processing: regex-based hallucination removal) เพื่อลด Tesseract noise/hallucination (T025)
# - 2026-06-06: เปลี่ยน keep_alive จาก 300s เป็น 0 เพื่อ unload model ทันทีหลังเสร็จงาน (แก้ปัญหา VRAM ไม่พอเมื่อ typhoon2.5-np-dms load พร้อมกัน)
# - 2026-06-11: เปลี่ยน process_with_typhoon_ocr ให้ใช้ prepare_ocr_messages จาก typhoon_ocr library + inject DMS tags; เปลี่ยน endpoint เป็น /v1/chat/completions
import os
import logging
import re
import base64
import fitz # PyMuPDF
import json
import tempfile
import fitz # PyMuPDF (ใช้สำหรับ page count + fast-path text extraction)
import httpx
from pathlib import Path
from typing import Optional
from PIL import Image
import pytesseract
import io
import cv2
import numpy as np
from typhoon_ocr import prepare_ocr_messages
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends, Security, status
from fastapi.security.api_key import APIKeyHeader
@@ -46,7 +47,7 @@ from FlagEmbedding import BGEM3FlagModel, FlagReranker
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ocr-sidecar")
app = FastAPI(title="Tesseract OCR Sidecar", version="1.0.0")
app = FastAPI(title="Typhoon OCR Sidecar", version="2.0.0")
# Initialize BGE-M3 and Reranker singletons
bge_model = None
@@ -79,162 +80,25 @@ async def get_api_key(api_key: str = Security(api_key_header)):
# อ่านค่า config จาก environment
OCR_CHAR_THRESHOLD = int(os.getenv("OCR_CHAR_THRESHOLD", "100"))
MAX_PAGES = int(os.getenv("OCR_MAX_PAGES", "0")) # 0 = ทุกหน้า
OCR_LANG = os.getenv("OCR_LANG", "tha+eng") # Tesseract language code (tha+eng = Thai + English)
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "http://host.docker.internal:11434")
TYPHOON_OCR_MODEL = os.getenv("TYPHOON_OCR_MODEL", "typhoon-np-dms-ocr:latest")
TYPHOON_OCR_TIMEOUT = int(os.getenv("TYPHOON_OCR_TIMEOUT", "360")) # รองรับ cold-start ~65s + inference ~30s/page
# DPI สำหรับ Typhoon OCR — ต่ำกว่า Tesseract เพราะ vision model ใช้ image patches (150 DPI ลด token ~4x)
TYPHOON_OCR_DPI = int(os.getenv("TYPHOON_OCR_DPI", "150"))
# PSM mode: 3 (default, fully automatic) หรือ 6 (assume single column, ลด noise)
TESSERACT_PSM = os.getenv("TESSERACT_PSM", "3")
# PSM 3 = Fully automatic page segmentation (เหมาะกับเอกสารที่มี layout หลายส่วน เช่น วันที่/เลขที่)
# PSM 6 = Assume single column of text (ลด hallucination จาก noise)
# OEM 1 = LSTM only (ดีกว่า legacy engine)
TESSERACT_CONFIG = f"--psm {TESSERACT_PSM} --oem 1"
# Crop margin: ตัด header/afooter (บน 5%, ล่าง 2%)
CROP_TOP_RATIO = 0.05
CROP_BOTTOM_RATIO = 0.02
# Enable aggressive preprocessing (Option 2) สำหรับ Tesseract
USE_AGGRESSIVE_PREPROCESSING = os.getenv("TESSERACT_AGGRESSIVE_PREPROCESS", "true").lower() == "true"
# Enable smart post-processing (Option 3) สำหรับลบ hallucination
USE_SMART_CLEANING = os.getenv("TESSERACT_SMART_CLEAN", "true").lower() == "true"
logger.info(f"Tesseract OCR Sidecar initialized (lang={OCR_LANG}, config={TESSERACT_CONFIG}, aggressive={USE_AGGRESSIVE_PREPROCESSING}, smart_clean={USE_SMART_CLEANING})")
logger.info(f"Typhoon OCR Sidecar initialized (model={TYPHOON_OCR_MODEL}, ollama={OLLAMA_API_URL})")
def filter_ocr_noise(text: str) -> str:
"""Filter ขยะ OCR เช่น บรรทัดสั้น/สัญลักษณ์ที่ไม่มีความหมาย"""
"""กรองสัญลักษณ์ที่ไม่มีความหมายออกจาก Markdown output"""
lines = text.split("\n")
filtered_lines = []
filtered = []
for line in lines:
line = line.strip()
if not line:
continue
# ลบบรรทัดที่สั้นเกินไป (น้อยกว่า 3 ตัวอักษร)
if len(line) < 3:
continue
# ลบบรรทัดที่มีแต่สัญลักษณ์/ตัวเลขโดดๆ (ไม่มีตัวอักษรภาษาไทย/อังกฤษ)
thai_chars = sum(1 for c in line if '\u0E00' <= c <= '\u0E7F')
english_chars = sum(1 for c in line if c.isalpha() and c.isascii())
total_chars = len(line)
# ถ้ามีตัวอักษรภาษาไทยหรืออังกฤษน้อยกว่า 20% ของบรรทัด ให้ถือว่าเป็นขยะ
if total_chars > 0 and (thai_chars + english_chars) / total_chars < 0.2:
continue
filtered_lines.append(line)
return "\n".join(filtered_lines)
def crop_header_footer(pil_image: Image.Image, top_ratio: float = 0.10, bottom_ratio: float = 0.10) -> Image.Image:
"""Crop header/footer ออกจาก image เพื่อลบข้อความที่ไม่จำเป็น"""
width, height = pil_image.size
top_crop = int(height * top_ratio)
bottom_crop = int(height * bottom_ratio)
# Crop: (left, top, right, bottom)
cropped = pil_image.crop((0, top_crop, width, height - bottom_crop))
return cropped
def preprocess_image(pil_image: Image.Image) -> Image.Image:
"""Preprocess image ด้วย OpenCV เพื่อเพิ่มความแม่นยำ OCR (แบบธรรมชาติ)"""
# แปลง PIL Image เป็น numpy array (OpenCV format)
img_array = np.array(pil_image)
# แปลงเป็น grayscale
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
# Denoise ด้วย median blur (เบางๆ เพื่อลบ noise แต่ไม่ทำลายตัวอักษร)
denoised = cv2.medianBlur(gray, 3)
# ใช้ grayscale เท่านั้น (ไม่ใช้ adaptive threshold เพราะทำให้ตัวอักษรเสียรูป)
# แปลงกลับเป็น PIL Image
return Image.fromarray(denoised)
def preprocess_image_aggressive(pil_image: Image.Image) -> Image.Image:
"""
Aggressive preprocessing (Option 2) — ลด hallucination โดย:
1. Deskew ถ้าหน้าเอียง
2. Denoise ด้วย bilateral filter
3. Otsu adaptive threshold
4. Morphological operations
"""
img_array = np.array(pil_image)
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
# 1. Deskew ถ้าหน้าเอียง (detect angle จาก Canny edges + Hough lines)
try:
edges = cv2.Canny(gray, 100, 200)
lines = cv2.HoughLinesP(edges, 1, np.pi/180, 100, minLineLength=100, maxLineGap=10)
if lines is not None and len(lines) > 0:
angles = [np.arctan2(y2-y1, x2-x1) for x1,y1,x2,y2 in lines[:min(10, len(lines))]]
angle = np.median(angles) * 180 / np.pi
if abs(angle) > 0.5: # มุมเอียงน้อย ≥ 0.5 องศา
h, w = gray.shape
M = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0)
gray = cv2.warpAffine(gray, M, (w, h), borderMode=cv2.BORDER_REFLECT)
logger.info(f"[PREPROCESS] Deskewed {angle:.1f}°")
except Exception as e:
logger.warning(f"[PREPROCESS] Deskew failed: {e}")
# 2. Denoise — median blur + bilateral filter
denoised = cv2.medianBlur(gray, 3)
denoised = cv2.bilateralFilter(denoised, 9, 75, 75)
# 3. Otsu threshold (adaptive, ไม่ fixed value)
_, thresh = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 4. Morphological operations — ลบ line noise ขนาดเล็ก (ต้าน speckle artifacts)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2))
morph = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) # ลบ small white noise
morph = cv2.morphologyEx(morph, cv2.MORPH_CLOSE, kernel) # ลบ small black hole
logger.info(f"[PREPROCESS] Aggressive: Otsu threshold + morphology applied")
return Image.fromarray(morph)
def clean_ocr_output(text: str) -> str:
"""
Smart post-processing (Option 3) — ลบ Tesseract hallucination โดย:
1. ลบ line ที่เป็นแค่สัญลักษณ์ repeated
2. ลบ line ที่เป็นแค่สัญลักษณ์แปลก
3. ลบ line ที่ซ้ำตัวอักษรเดียว (artifact noise)
"""
lines = text.split("\n")
cleaned = []
for line in lines:
line = line.strip()
if not line:
continue
# ✗ ลบ line ที่เป็นแค่สัญลักษณ์/punctuation เดี่ยวๆ ไม่มีตัวอักษร
alphanumeric_part = re.sub(r'[^\w\u0E00-\u0E7F]', '', line)
if len(alphanumeric_part) < 2:
logger.debug(f"[CLEAN] Reject (no alphanum): {line[:50]}")
continue
# ✗ ลบ line ที่เป็น repeated pattern — ถ้า unique char ≤ 20% (e.g., "-----", ">>>>>>>")
unique_chars = len(set(line))
if unique_chars < max(2, len(line) // 5):
logger.debug(f"[CLEAN] Reject (repeated pattern): {line[:50]}")
continue
# ✗ ลบ line ที่เป็นสัญลักษณ์แปลก (< 20% Thai/English alphanumeric)
thai_chars = sum(1 for c in line if '\u0E00' <= c <= '\u0E7F')
eng_chars = sum(1 for c in line if c.isascii() and c.isalnum())
if len(line) > 0 and (thai_chars + eng_chars) / len(line) < 0.2:
logger.debug(f"[CLEAN] Reject (low language content): {line[:50]}")
continue
# ✓ ปล่อยผ่าน
cleaned.append(line)
result = "\n".join(cleaned)
logger.info(f"[CLEAN] Input {len(lines)} lines → {len(cleaned)} lines")
return result
filtered.append(line)
return "\n".join(filtered)
class OcrRequest(BaseModel):
pdfPath: str
@@ -252,11 +116,9 @@ class OcrResponse(BaseModel):
def health():
return {
"status": "ok",
"engines": ["tesseract", "typhoon-np-dms-ocr"],
"engine": "typhoon-np-dms-ocr",
"typhoonModel": TYPHOON_OCR_MODEL,
"tesseractConfig": TESSERACT_CONFIG,
"aggressivePreprocess": USE_AGGRESSIVE_PREPROCESSING,
"smartCleaning": USE_SMART_CLEANING,
"ollamaUrl": OLLAMA_API_URL,
}
# alias map สำหรับ engine name เก่า → canonical name
@@ -266,7 +128,7 @@ _ENGINE_ALIASES: dict[str, str] = {
"typhoon_ocr": "typhoon-np-dms-ocr",
}
def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}) -> OcrResponse:
def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}, pdf_path: str | None = None) -> OcrResponse:
"""ประมวลผล fitz.Document ด้วย engine ที่เลือก — shared logic สำหรับ /ocr และ /ocr-upload"""
selected_engine = _ENGINE_ALIASES.get(selected_engine, selected_engine)
pages_to_process = list(range(min(len(doc), max_pages) if max_pages > 0 else len(doc)))
@@ -291,15 +153,13 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, t
)
if selected_engine == "typhoon-np-dms-ocr":
# ใช้ prepare_ocr_messages รับ PDF path โดยตรง — ไม่ต้องแปลง PIL Image อีกต่อไป
resolved_path = pdf_path or (str(doc.name) if hasattr(doc, 'name') and doc.name else None)
if not resolved_path:
raise ValueError("ไม่สามารถหา PDF path — ต้องส่ง pdf_path เข้ามาด้วย")
typhoon_text_parts = []
for i in pages_to_process:
page = doc[i]
pix = page.get_pixmap(dpi=TYPHOON_OCR_DPI)
img_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(img_bytes))
# ส่ง color image ตรงๆ — Typhoon OCR (vision model) ต้องการ color ไม่ใช่ grayscale binarized
cropped_img = crop_header_footer(img, CROP_TOP_RATIO, CROP_BOTTOM_RATIO)
typhoon_text_parts.append(process_with_typhoon_ocr(cropped_img, typhoon_options))
typhoon_text_parts.append(process_with_typhoon_ocr(resolved_path, page_num=i + 1, options_override=typhoon_options))
typhoon_text = filter_ocr_noise("\n".join(typhoon_text_parts).strip())
return OcrResponse(
text=typhoon_text,
@@ -309,89 +169,65 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, t
engineUsed=selected_engine,
)
logger.info(f"Slow path (Tesseract): {total_chars} chars too few")
ocr_text_parts = []
# ถ้าไม่ใช่ engine ที่รู้จัก ให้ใช้ typhoon-np-dms-ocr เป็น fallback
logger.warning(f"Unknown engine '{selected_engine}' — fallback to typhoon-np-dms-ocr")
resolved_path = pdf_path or (str(doc.name) if hasattr(doc, 'name') and doc.name else None)
if not resolved_path:
raise ValueError("ไม่สามารถหา PDF path — ต้องส่ง pdf_path เข้ามาด้วย")
fallback_parts = []
for i in pages_to_process:
page = doc[i]
pix = page.get_pixmap(dpi=300)
img_bytes = pix.tobytes("png")
img = Image.open(io.BytesIO(img_bytes))
cropped_img = crop_header_footer(img, CROP_TOP_RATIO, CROP_BOTTOM_RATIO)
# Option 2: Choose preprocessing strategy
if USE_AGGRESSIVE_PREPROCESSING:
processed_img = preprocess_image_aggressive(cropped_img)
else:
processed_img = preprocess_image(cropped_img)
text = pytesseract.image_to_string(processed_img, lang=OCR_LANG, config=TESSERACT_CONFIG)
ocr_text_parts.append(text.strip())
ocr_text = "\n".join(ocr_text_parts).strip()
# Option 3: Apply smart post-processing
if USE_SMART_CLEANING:
ocr_text = clean_ocr_output(ocr_text)
else:
ocr_text = filter_ocr_noise(ocr_text)
logger.info(f"Tesseract extracted {len(ocr_text)} chars")
fallback_parts.append(process_with_typhoon_ocr(resolved_path, page_num=i + 1, options_override=typhoon_options))
fallback_text = filter_ocr_noise("\n".join(fallback_parts).strip())
return OcrResponse(
text=ocr_text,
text=fallback_text,
ocrUsed=True,
pageCount=page_count,
charCount=len(ocr_text),
engineUsed="tesseract",
charCount=len(fallback_text),
engineUsed="typhoon-np-dms-ocr",
)
def process_with_typhoon_ocr(pil_image: Image.Image, options_override: dict = {}) -> str:
"""เรียก Typhoon OCR ผ่าน Ollama — ใช้ SYSTEM ใน Modelfile เป็น instruction หลัก; options_override ยัง override ค่า Modelfile ได้"""
def process_with_typhoon_ocr(pdf_path: str, page_num: int = 1, options_override: dict = {}) -> str:
"""เรียก Typhoon OCR ผ่าน Ollama /v1/chat/completions — รับ PDF path โดยตรง ไม่ต้องแปลง PIL Image"""
model_name = TYPHOON_OCR_MODEL
img_buffer = io.BytesIO()
pil_image.save(img_buffer, format="PNG")
image_base64 = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
# ค่า default ตาม Modelfile; frontend override ได้บางส่วนหรือทั้งหมด
options = {
"temperature": 0.1,
"top_p": 0.1,
"repeat_penalty": 1.1,
"num_gpu": 99, # บังคับ GPU layers สูงสุด — ป้องกัน Ollama fallback ไป CPU โดยไม่จำเป็น
"num_ctx": 4096, # image tokens ~2772 → ต้องการ context > 2048; 4096 รองรับ image + output โดยไม่ truncate
**options_override,
}
# prepare_ocr_messages จัดการ PDF → image ผ่าน poppler/pdftoppm ภายใน
messages = prepare_ocr_messages(pdf_path, task_type="structure", page_num=page_num)
# inject DMS-specific extraction tags ต่อท้าย content
messages[0]["content"].append({
"type": "text",
"text": (
"Additionally:\n"
"- Wrap document number with <document_number>...</document_number>\n"
"- Wrap document date with <document_date>...</document_date>\n"
"- Wrap received date with <received_date>...</received_date>\n"
"If a field is not found, omit the tag."
),
})
# ค่า default ตาม official; options_override ยัง override ได้บางส่วน
payload = {
"model": model_name,
"prompt": """You are an expert in structuring Thai documents
Task: Extract the information from the image in the most correct and organized format.
Output Rules:
- Return ONLY clean Markdown output
- Include ALL information visible on the page
- Preserve document structure and hierarchy
- Do NOT add explanations or interpretations
- Do NOT include these instructions in your response
Formatting:
- Tables: Use HTML <table> tags
- Math: $inline$ and $$block$$ LaTeX
- Figures: <figure>Thai description</figure>
- Pages: <page_number>N</page_number>
- Boxes: ☐ / ☑
- Unclear: [unclear: context]
- Signatures/Stamps: Describe location and context
Extract all text from this image.""",
"images": [image_base64],
"messages": messages,
"max_tokens": 16000,
"stream": False,
"options": options,
"keep_alive": 0, # Unload model ทันทีหลังเสร็จงานเพื่อคืน VRAM ให้ typhoon2.5-np-dms ใช้งานได้
"repetition_penalty": options_override.get("repeat_penalty", 1.2),
"temperature": options_override.get("temperature", 0.1),
"top_p": options_override.get("top_p", 0.6),
"keep_alive": 0, # Unload model ทันทีหลังเสร็จงานเพื่อคืน VRAM ให้ np-dms-ai ใช้งานได้
}
# ใช้ Ollama OpenAI-compatible endpoint (/v1/chat/completions)
with httpx.Client(timeout=TYPHOON_OCR_TIMEOUT) as client:
response = client.post(f"{OLLAMA_API_URL}/api/generate", json=payload)
response = client.post(
f"{OLLAMA_API_URL}/v1/chat/completions",
json=payload,
headers={"Authorization": "Bearer ollama"},
)
response.raise_for_status()
data = response.json()
result_text = str(data.get("response", "")).strip()
raw_text = str(data.get("choices", [{}])[0].get("message", {}).get("content", "")).strip()
# parse JSON output จาก model (format: {"natural_text": "..."})
try:
result_text = json.loads(raw_text).get("natural_text", raw_text)
except (json.JSONDecodeError, AttributeError):
result_text = raw_text
logger.info(
f"[DIAG] Ollama response — model={model_name} "
f"textLen={len(result_text)} "
@@ -440,12 +276,22 @@ def ocr_upload(
if repeatPenalty is not None:
typhoon_options["repeat_penalty"] = repeatPenalty
pdf_bytes = file.file.read()
import tempfile
tmp_pdf_path: str | None = None
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as e:
raise HTTPException(status_code=422, detail=f"เปิดไฟล์ PDF ล้มเหลว: {e}")
logger.info(f"OCR upload: {file.filename} engine={selected_engine} options={typhoon_options or 'modelfile-defaults'}")
return _process_pdf_doc(doc, selected_engine, max_pages, typhoon_options)
# บันทึก PDF เป็น temp file เพื่อให้ prepare_ocr_messages อ่านได้ผ่าน path
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_bytes)
tmp_pdf_path = tmp.name
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as e:
raise HTTPException(status_code=422, detail=f"เปิดไฟล์ PDF ล้มเหลว: {e}")
logger.info(f"OCR upload: {file.filename} engine={selected_engine} options={typhoon_options or 'modelfile-defaults'}")
return _process_pdf_doc(doc, selected_engine, max_pages, typhoon_options, pdf_path=tmp_pdf_path)
finally:
if tmp_pdf_path:
Path(tmp_pdf_path).unlink(missing_ok=True)
class NormalizeRequest(BaseModel):
text: str
@@ -1,18 +1,18 @@
# OCR Sidecar Requirements (Tesseract-based)
# OCR Sidecar Requirements (Typhoon OCR via Ollama)
# Change Log:
# - 2026-05-30: เปลี่ยนจาก PaddleOCR เป็น Tesseract OCR เพื่อความเข้ากันได้กับ CPU เก่า (ไม่ต้องการ AVX)
# - 2026-05-30: ลบ paddlepaddle/paddleocr dependencies เนื่องจาก SIGILL บน CPU ที่ไม่รองรับ AVX
# - 2026-05-30: เพิ่ม opencv-python สำหรับ image preprocessing (threshold, denoise) เพื่อเพิ่มความแม่นยำ OCR
# - 2026-06-11: เพิ่ม typhoon-ocr สำหรับ prepare_ocr_messages (official prompt builder สำหรับ typhoon-ocr1.5-3b)
# - 2026-06-11: ตัด pytesseract, opencv-python, numpy ออก — ไม่ใช้ Tesseract อีกต่อไป
numpy<2.0
PyMuPDF==1.24.0
pytesseract==0.3.13
fastapi==0.111.0
uvicorn[standard]==0.30.1
python-multipart==0.0.9
pythainlp==5.0.4
httpx==0.27.0
Pillow==10.0.0
opencv-python==4.8.1.78
FlagEmbedding>=1.2.0
typhoon-ocr>=0.4.1