Each module is under 1050 lines: - ocr_pipeline_common.py (354) - shared state, cache, models, helpers - ocr_pipeline_sessions.py (483) - session CRUD, image serving, doc-type - ocr_pipeline_geometry.py (1025) - deskew, dewarp, structure, columns - ocr_pipeline_rows.py (348) - row detection, box-overlay helper - ocr_pipeline_words.py (876) - word detection (SSE), paddle-direct - ocr_pipeline_ocr_merge.py (615) - merge helpers, kombi endpoints - ocr_pipeline_postprocess.py (929) - LLM review, reconstruction, export - ocr_pipeline_auto.py (705) - auto-mode orchestrator, reprocess ocr_pipeline_api.py is now a 61-line thin wrapper that re-exports router, _cache, and test-imported symbols for backward compatibility. No changes needed in main.py or tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
616 lines
22 KiB
Python
616 lines
22 KiB
Python
"""
|
|
OCR Merge Helpers and Kombi Endpoints.
|
|
|
|
Contains merge helper functions for combining PaddleOCR/RapidOCR with Tesseract
|
|
results, plus the paddle-kombi and rapid-kombi endpoints.
|
|
|
|
Extracted from ocr_pipeline_api.py for modularity.
|
|
|
|
Lizenz: Apache 2.0
|
|
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any, Dict, List
|
|
|
|
import cv2
|
|
import httpx
|
|
import numpy as np
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
from cv_words_first import build_grid_from_words
|
|
from ocr_pipeline_common import _cache, _append_pipeline_log
|
|
from ocr_pipeline_session_store import get_session_image, update_session_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/ocr-pipeline", tags=["ocr-pipeline"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merge helper functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _split_paddle_multi_words(words: list) -> list:
|
|
"""Split PaddleOCR multi-word boxes into individual word boxes.
|
|
|
|
PaddleOCR often returns entire phrases as a single box, e.g.
|
|
"More than 200 singers took part in the" with one bounding box.
|
|
This splits them into individual words with proportional widths.
|
|
Also handles leading "!" (e.g. "!Betonung" → ["!", "Betonung"])
|
|
and IPA brackets (e.g. "badge[bxd3]" → ["badge", "[bxd3]"]).
|
|
"""
|
|
import re
|
|
|
|
result = []
|
|
for w in words:
|
|
raw_text = w.get("text", "").strip()
|
|
if not raw_text:
|
|
continue
|
|
# Split on whitespace, before "[" (IPA), and after "!" before letter
|
|
tokens = re.split(
|
|
r'\s+|(?=\[)|(?<=!)(?=[A-Za-z\u00c0-\u024f])', raw_text
|
|
)
|
|
tokens = [t for t in tokens if t]
|
|
|
|
if len(tokens) <= 1:
|
|
result.append(w)
|
|
else:
|
|
# Split proportionally by character count
|
|
total_chars = sum(len(t) for t in tokens)
|
|
if total_chars == 0:
|
|
continue
|
|
n_gaps = len(tokens) - 1
|
|
gap_px = w["width"] * 0.02
|
|
usable_w = w["width"] - gap_px * n_gaps
|
|
cursor = w["left"]
|
|
for t in tokens:
|
|
token_w = max(1, usable_w * len(t) / total_chars)
|
|
result.append({
|
|
"text": t,
|
|
"left": round(cursor),
|
|
"top": w["top"],
|
|
"width": round(token_w),
|
|
"height": w["height"],
|
|
"conf": w.get("conf", 0),
|
|
})
|
|
cursor += token_w + gap_px
|
|
return result
|
|
|
|
|
|
def _group_words_into_rows(words: list, row_gap: int = 12) -> list:
|
|
"""Group words into rows by Y-position clustering.
|
|
|
|
Words whose vertical centers are within `row_gap` pixels are on the same row.
|
|
Returns list of rows, each row is a list of words sorted left-to-right.
|
|
"""
|
|
if not words:
|
|
return []
|
|
# Sort by vertical center
|
|
sorted_words = sorted(words, key=lambda w: w["top"] + w.get("height", 0) / 2)
|
|
rows: list = []
|
|
current_row: list = [sorted_words[0]]
|
|
current_cy = sorted_words[0]["top"] + sorted_words[0].get("height", 0) / 2
|
|
|
|
for w in sorted_words[1:]:
|
|
cy = w["top"] + w.get("height", 0) / 2
|
|
if abs(cy - current_cy) <= row_gap:
|
|
current_row.append(w)
|
|
else:
|
|
# Sort current row left-to-right before saving
|
|
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
|
current_row = [w]
|
|
current_cy = cy
|
|
if current_row:
|
|
rows.append(sorted(current_row, key=lambda w: w["left"]))
|
|
return rows
|
|
|
|
|
|
def _row_center_y(row: list) -> float:
|
|
"""Average vertical center of a row of words."""
|
|
if not row:
|
|
return 0.0
|
|
return sum(w["top"] + w.get("height", 0) / 2 for w in row) / len(row)
|
|
|
|
|
|
def _merge_row_sequences(paddle_row: list, tess_row: list) -> list:
|
|
"""Merge two word sequences from the same row using sequence alignment.
|
|
|
|
Both sequences are sorted left-to-right. Walk through both simultaneously:
|
|
- If words match (same/similar text): take Paddle text with averaged coords
|
|
- If they don't match: the extra word is unique to one engine, include it
|
|
|
|
This prevents duplicates because both engines produce words in the same order.
|
|
"""
|
|
merged = []
|
|
pi, ti = 0, 0
|
|
|
|
while pi < len(paddle_row) and ti < len(tess_row):
|
|
pw = paddle_row[pi]
|
|
tw = tess_row[ti]
|
|
|
|
# Check if these are the same word
|
|
pt = pw.get("text", "").lower().strip()
|
|
tt = tw.get("text", "").lower().strip()
|
|
|
|
# Same text or one contains the other
|
|
is_same = (pt == tt) or (len(pt) > 1 and len(tt) > 1 and (pt in tt or tt in pt))
|
|
|
|
# Spatial overlap check: if words overlap >= 40% horizontally,
|
|
# they're the same physical word regardless of OCR text differences.
|
|
# (40% catches borderline cases like "Stick"/"Stück" at 48% overlap)
|
|
spatial_match = False
|
|
if not is_same:
|
|
overlap_left = max(pw["left"], tw["left"])
|
|
overlap_right = min(
|
|
pw["left"] + pw.get("width", 0),
|
|
tw["left"] + tw.get("width", 0),
|
|
)
|
|
overlap_w = max(0, overlap_right - overlap_left)
|
|
min_w = min(pw.get("width", 1), tw.get("width", 1))
|
|
if min_w > 0 and overlap_w / min_w >= 0.4:
|
|
is_same = True
|
|
spatial_match = True
|
|
|
|
if is_same:
|
|
# Matched — average coordinates weighted by confidence
|
|
pc = pw.get("conf", 80)
|
|
tc = tw.get("conf", 50)
|
|
total = pc + tc
|
|
if total == 0:
|
|
total = 1
|
|
# Text: prefer higher-confidence engine when texts differ
|
|
# (e.g. Tesseract "Stück" conf=98 vs PaddleOCR "Stick" conf=80)
|
|
if spatial_match and pc < tc:
|
|
best_text = tw["text"]
|
|
else:
|
|
best_text = pw["text"]
|
|
merged.append({
|
|
"text": best_text,
|
|
"left": round((pw["left"] * pc + tw["left"] * tc) / total),
|
|
"top": round((pw["top"] * pc + tw["top"] * tc) / total),
|
|
"width": round((pw["width"] * pc + tw["width"] * tc) / total),
|
|
"height": round((pw["height"] * pc + tw["height"] * tc) / total),
|
|
"conf": max(pc, tc),
|
|
})
|
|
pi += 1
|
|
ti += 1
|
|
else:
|
|
# Different text — one engine found something extra
|
|
# Look ahead: is the current Paddle word somewhere in Tesseract ahead?
|
|
paddle_ahead = any(
|
|
tess_row[t].get("text", "").lower().strip() == pt
|
|
for t in range(ti + 1, min(ti + 4, len(tess_row)))
|
|
)
|
|
# Is the current Tesseract word somewhere in Paddle ahead?
|
|
tess_ahead = any(
|
|
paddle_row[p].get("text", "").lower().strip() == tt
|
|
for p in range(pi + 1, min(pi + 4, len(paddle_row)))
|
|
)
|
|
|
|
if paddle_ahead and not tess_ahead:
|
|
# Tesseract has an extra word (e.g. "!" or bullet) → include it
|
|
if tw.get("conf", 0) >= 30:
|
|
merged.append(tw)
|
|
ti += 1
|
|
elif tess_ahead and not paddle_ahead:
|
|
# Paddle has an extra word → include it
|
|
merged.append(pw)
|
|
pi += 1
|
|
else:
|
|
# Both have unique words or neither found ahead → take leftmost first
|
|
if pw["left"] <= tw["left"]:
|
|
merged.append(pw)
|
|
pi += 1
|
|
else:
|
|
if tw.get("conf", 0) >= 30:
|
|
merged.append(tw)
|
|
ti += 1
|
|
|
|
# Remaining words from either engine
|
|
while pi < len(paddle_row):
|
|
merged.append(paddle_row[pi])
|
|
pi += 1
|
|
while ti < len(tess_row):
|
|
tw = tess_row[ti]
|
|
if tw.get("conf", 0) >= 30:
|
|
merged.append(tw)
|
|
ti += 1
|
|
|
|
return merged
|
|
|
|
|
|
def _merge_paddle_tesseract(paddle_words: list, tess_words: list) -> list:
|
|
"""Merge word boxes from PaddleOCR and Tesseract using row-based sequence alignment.
|
|
|
|
Strategy:
|
|
1. Group each engine's words into rows (by Y-position clustering)
|
|
2. Match rows between engines (by vertical center proximity)
|
|
3. Within each matched row: merge sequences left-to-right, deduplicating
|
|
words that appear in both engines at the same sequence position
|
|
4. Unmatched rows from either engine: keep as-is
|
|
|
|
This prevents:
|
|
- Cross-line averaging (words from different lines being merged)
|
|
- Duplicate words (same word from both engines shown twice)
|
|
"""
|
|
if not paddle_words and not tess_words:
|
|
return []
|
|
if not paddle_words:
|
|
return [w for w in tess_words if w.get("conf", 0) >= 40]
|
|
if not tess_words:
|
|
return list(paddle_words)
|
|
|
|
# Step 1: Group into rows
|
|
paddle_rows = _group_words_into_rows(paddle_words)
|
|
tess_rows = _group_words_into_rows(tess_words)
|
|
|
|
# Step 2: Match rows between engines by vertical center proximity
|
|
used_tess_rows: set = set()
|
|
merged_all: list = []
|
|
|
|
for pr in paddle_rows:
|
|
pr_cy = _row_center_y(pr)
|
|
best_dist, best_tri = float("inf"), -1
|
|
for tri, tr in enumerate(tess_rows):
|
|
if tri in used_tess_rows:
|
|
continue
|
|
tr_cy = _row_center_y(tr)
|
|
dist = abs(pr_cy - tr_cy)
|
|
if dist < best_dist:
|
|
best_dist, best_tri = dist, tri
|
|
|
|
# Row height threshold — rows must be within ~1.5x typical line height
|
|
max_row_dist = max(
|
|
max((w.get("height", 20) for w in pr), default=20),
|
|
15,
|
|
)
|
|
|
|
if best_tri >= 0 and best_dist <= max_row_dist:
|
|
# Matched row — merge sequences
|
|
tr = tess_rows[best_tri]
|
|
used_tess_rows.add(best_tri)
|
|
merged_all.extend(_merge_row_sequences(pr, tr))
|
|
else:
|
|
# No matching Tesseract row — keep Paddle row as-is
|
|
merged_all.extend(pr)
|
|
|
|
# Add unmatched Tesseract rows
|
|
for tri, tr in enumerate(tess_rows):
|
|
if tri not in used_tess_rows:
|
|
for tw in tr:
|
|
if tw.get("conf", 0) >= 40:
|
|
merged_all.append(tw)
|
|
|
|
return merged_all
|
|
|
|
|
|
def _deduplicate_words(words: list) -> list:
|
|
"""Remove duplicate words with same text at overlapping positions.
|
|
|
|
PaddleOCR can return overlapping phrases (e.g. "von jm." and "jm. =")
|
|
that produce duplicate words after splitting. This pass removes them.
|
|
|
|
A word is a duplicate only when BOTH horizontal AND vertical overlap
|
|
exceed 50% — same text on the same visual line at the same position.
|
|
"""
|
|
if not words:
|
|
return words
|
|
|
|
result: list = []
|
|
for w in words:
|
|
wt = w.get("text", "").lower().strip()
|
|
if not wt:
|
|
continue
|
|
is_dup = False
|
|
w_right = w["left"] + w.get("width", 0)
|
|
w_bottom = w["top"] + w.get("height", 0)
|
|
for existing in result:
|
|
et = existing.get("text", "").lower().strip()
|
|
if wt != et:
|
|
continue
|
|
# Horizontal overlap
|
|
ox_l = max(w["left"], existing["left"])
|
|
ox_r = min(w_right, existing["left"] + existing.get("width", 0))
|
|
ox = max(0, ox_r - ox_l)
|
|
min_w = min(w.get("width", 1), existing.get("width", 1))
|
|
if min_w <= 0 or ox / min_w < 0.5:
|
|
continue
|
|
# Vertical overlap — must also be on the same line
|
|
oy_t = max(w["top"], existing["top"])
|
|
oy_b = min(w_bottom, existing["top"] + existing.get("height", 0))
|
|
oy = max(0, oy_b - oy_t)
|
|
min_h = min(w.get("height", 1), existing.get("height", 1))
|
|
if min_h > 0 and oy / min_h >= 0.5:
|
|
is_dup = True
|
|
break
|
|
if not is_dup:
|
|
result.append(w)
|
|
|
|
removed = len(words) - len(result)
|
|
if removed:
|
|
logger.info("dedup: removed %d duplicate words", removed)
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kombi endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/sessions/{session_id}/paddle-kombi")
|
|
async def paddle_kombi(session_id: str):
|
|
"""Run PaddleOCR + Tesseract on the preprocessed image and merge results.
|
|
|
|
Both engines run on the same preprocessed (cropped/dewarped) image.
|
|
Word boxes are matched by IoU and coordinates are averaged weighted by
|
|
confidence. Unmatched Tesseract words (bullets, symbols) are added.
|
|
"""
|
|
img_png = await get_session_image(session_id, "cropped")
|
|
if not img_png:
|
|
img_png = await get_session_image(session_id, "dewarped")
|
|
if not img_png:
|
|
img_png = await get_session_image(session_id, "original")
|
|
if not img_png:
|
|
raise HTTPException(status_code=404, detail="No image found for this session")
|
|
|
|
img_arr = np.frombuffer(img_png, dtype=np.uint8)
|
|
img_bgr = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
|
if img_bgr is None:
|
|
raise HTTPException(status_code=400, detail="Failed to decode image")
|
|
|
|
img_h, img_w = img_bgr.shape[:2]
|
|
|
|
from cv_ocr_engines import ocr_region_paddle
|
|
|
|
t0 = time.time()
|
|
|
|
# --- PaddleOCR ---
|
|
paddle_words = await ocr_region_paddle(img_bgr, region=None)
|
|
if not paddle_words:
|
|
paddle_words = []
|
|
|
|
# --- Tesseract ---
|
|
from PIL import Image
|
|
import pytesseract
|
|
|
|
pil_img = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
|
|
data = pytesseract.image_to_data(
|
|
pil_img, lang="eng+deu",
|
|
config="--psm 6 --oem 3",
|
|
output_type=pytesseract.Output.DICT,
|
|
)
|
|
tess_words = []
|
|
for i in range(len(data["text"])):
|
|
text = str(data["text"][i]).strip()
|
|
conf_raw = str(data["conf"][i])
|
|
conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1
|
|
if not text or conf < 20:
|
|
continue
|
|
tess_words.append({
|
|
"text": text,
|
|
"left": data["left"][i],
|
|
"top": data["top"][i],
|
|
"width": data["width"][i],
|
|
"height": data["height"][i],
|
|
"conf": conf,
|
|
})
|
|
|
|
# --- Split multi-word Paddle boxes into individual words ---
|
|
paddle_words_split = _split_paddle_multi_words(paddle_words)
|
|
logger.info(
|
|
"paddle_kombi: split %d paddle boxes → %d individual words",
|
|
len(paddle_words), len(paddle_words_split),
|
|
)
|
|
|
|
# --- Merge ---
|
|
if not paddle_words_split and not tess_words:
|
|
raise HTTPException(status_code=400, detail="Both OCR engines returned no words")
|
|
|
|
merged_words = _merge_paddle_tesseract(paddle_words_split, tess_words)
|
|
merged_words = _deduplicate_words(merged_words)
|
|
|
|
cells, columns_meta = build_grid_from_words(merged_words, img_w, img_h)
|
|
duration = time.time() - t0
|
|
|
|
for cell in cells:
|
|
cell["ocr_engine"] = "kombi"
|
|
|
|
n_rows = len(set(c["row_index"] for c in cells)) if cells else 0
|
|
n_cols = len(columns_meta)
|
|
col_types = {c.get("type") for c in columns_meta}
|
|
is_vocab = bool(col_types & {"column_en", "column_de"})
|
|
|
|
word_result = {
|
|
"cells": cells,
|
|
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
|
"columns_used": columns_meta,
|
|
"layout": "vocab" if is_vocab else "generic",
|
|
"image_width": img_w,
|
|
"image_height": img_h,
|
|
"duration_seconds": round(duration, 2),
|
|
"ocr_engine": "kombi",
|
|
"grid_method": "kombi",
|
|
"raw_paddle_words": paddle_words,
|
|
"raw_paddle_words_split": paddle_words_split,
|
|
"raw_tesseract_words": tess_words,
|
|
"summary": {
|
|
"total_cells": len(cells),
|
|
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
|
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
|
"paddle_words": len(paddle_words),
|
|
"paddle_words_split": len(paddle_words_split),
|
|
"tesseract_words": len(tess_words),
|
|
"merged_words": len(merged_words),
|
|
},
|
|
}
|
|
|
|
await update_session_db(
|
|
session_id,
|
|
word_result=word_result,
|
|
cropped_png=img_png,
|
|
current_step=8,
|
|
)
|
|
# Update in-memory cache so detect-structure can access word_result
|
|
if session_id in _cache:
|
|
_cache[session_id]["word_result"] = word_result
|
|
|
|
logger.info(
|
|
"paddle_kombi session %s: %d cells (%d rows, %d cols) in %.2fs "
|
|
"[paddle=%d, tess=%d, merged=%d]",
|
|
session_id, len(cells), n_rows, n_cols, duration,
|
|
len(paddle_words), len(tess_words), len(merged_words),
|
|
)
|
|
|
|
await _append_pipeline_log(session_id, "paddle_kombi", {
|
|
"total_cells": len(cells),
|
|
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
|
"paddle_words": len(paddle_words),
|
|
"tesseract_words": len(tess_words),
|
|
"merged_words": len(merged_words),
|
|
"ocr_engine": "kombi",
|
|
}, duration_ms=int(duration * 1000))
|
|
|
|
return {"session_id": session_id, **word_result}
|
|
|
|
|
|
@router.post("/sessions/{session_id}/rapid-kombi")
|
|
async def rapid_kombi(session_id: str):
|
|
"""Run RapidOCR + Tesseract on the preprocessed image and merge results.
|
|
|
|
Same merge logic as paddle-kombi, but uses local RapidOCR (ONNX Runtime)
|
|
instead of remote PaddleOCR service.
|
|
"""
|
|
img_png = await get_session_image(session_id, "cropped")
|
|
if not img_png:
|
|
img_png = await get_session_image(session_id, "dewarped")
|
|
if not img_png:
|
|
img_png = await get_session_image(session_id, "original")
|
|
if not img_png:
|
|
raise HTTPException(status_code=404, detail="No image found for this session")
|
|
|
|
img_arr = np.frombuffer(img_png, dtype=np.uint8)
|
|
img_bgr = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
|
|
if img_bgr is None:
|
|
raise HTTPException(status_code=400, detail="Failed to decode image")
|
|
|
|
img_h, img_w = img_bgr.shape[:2]
|
|
|
|
from cv_ocr_engines import ocr_region_rapid
|
|
from cv_vocab_types import PageRegion
|
|
|
|
t0 = time.time()
|
|
|
|
# --- RapidOCR (local, synchronous) ---
|
|
full_region = PageRegion(
|
|
type="full_page", x=0, y=0, width=img_w, height=img_h,
|
|
)
|
|
rapid_words = ocr_region_rapid(img_bgr, full_region)
|
|
if not rapid_words:
|
|
rapid_words = []
|
|
|
|
# --- Tesseract ---
|
|
from PIL import Image
|
|
import pytesseract
|
|
|
|
pil_img = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
|
|
data = pytesseract.image_to_data(
|
|
pil_img, lang="eng+deu",
|
|
config="--psm 6 --oem 3",
|
|
output_type=pytesseract.Output.DICT,
|
|
)
|
|
tess_words = []
|
|
for i in range(len(data["text"])):
|
|
text = str(data["text"][i]).strip()
|
|
conf_raw = str(data["conf"][i])
|
|
conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1
|
|
if not text or conf < 20:
|
|
continue
|
|
tess_words.append({
|
|
"text": text,
|
|
"left": data["left"][i],
|
|
"top": data["top"][i],
|
|
"width": data["width"][i],
|
|
"height": data["height"][i],
|
|
"conf": conf,
|
|
})
|
|
|
|
# --- Split multi-word RapidOCR boxes into individual words ---
|
|
rapid_words_split = _split_paddle_multi_words(rapid_words)
|
|
logger.info(
|
|
"rapid_kombi: split %d rapid boxes → %d individual words",
|
|
len(rapid_words), len(rapid_words_split),
|
|
)
|
|
|
|
# --- Merge ---
|
|
if not rapid_words_split and not tess_words:
|
|
raise HTTPException(status_code=400, detail="Both OCR engines returned no words")
|
|
|
|
merged_words = _merge_paddle_tesseract(rapid_words_split, tess_words)
|
|
merged_words = _deduplicate_words(merged_words)
|
|
|
|
cells, columns_meta = build_grid_from_words(merged_words, img_w, img_h)
|
|
duration = time.time() - t0
|
|
|
|
for cell in cells:
|
|
cell["ocr_engine"] = "rapid_kombi"
|
|
|
|
n_rows = len(set(c["row_index"] for c in cells)) if cells else 0
|
|
n_cols = len(columns_meta)
|
|
col_types = {c.get("type") for c in columns_meta}
|
|
is_vocab = bool(col_types & {"column_en", "column_de"})
|
|
|
|
word_result = {
|
|
"cells": cells,
|
|
"grid_shape": {"rows": n_rows, "cols": n_cols, "total_cells": len(cells)},
|
|
"columns_used": columns_meta,
|
|
"layout": "vocab" if is_vocab else "generic",
|
|
"image_width": img_w,
|
|
"image_height": img_h,
|
|
"duration_seconds": round(duration, 2),
|
|
"ocr_engine": "rapid_kombi",
|
|
"grid_method": "rapid_kombi",
|
|
"raw_rapid_words": rapid_words,
|
|
"raw_rapid_words_split": rapid_words_split,
|
|
"raw_tesseract_words": tess_words,
|
|
"summary": {
|
|
"total_cells": len(cells),
|
|
"non_empty_cells": sum(1 for c in cells if c.get("text")),
|
|
"low_confidence": sum(1 for c in cells if 0 < c.get("confidence", 0) < 50),
|
|
"rapid_words": len(rapid_words),
|
|
"rapid_words_split": len(rapid_words_split),
|
|
"tesseract_words": len(tess_words),
|
|
"merged_words": len(merged_words),
|
|
},
|
|
}
|
|
|
|
await update_session_db(
|
|
session_id,
|
|
word_result=word_result,
|
|
cropped_png=img_png,
|
|
current_step=8,
|
|
)
|
|
# Update in-memory cache so detect-structure can access word_result
|
|
if session_id in _cache:
|
|
_cache[session_id]["word_result"] = word_result
|
|
|
|
logger.info(
|
|
"rapid_kombi session %s: %d cells (%d rows, %d cols) in %.2fs "
|
|
"[rapid=%d, tess=%d, merged=%d]",
|
|
session_id, len(cells), n_rows, n_cols, duration,
|
|
len(rapid_words), len(tess_words), len(merged_words),
|
|
)
|
|
|
|
await _append_pipeline_log(session_id, "rapid_kombi", {
|
|
"total_cells": len(cells),
|
|
"non_empty_cells": word_result["summary"]["non_empty_cells"],
|
|
"rapid_words": len(rapid_words),
|
|
"tesseract_words": len(tess_words),
|
|
"merged_words": len(merged_words),
|
|
"ocr_engine": "rapid_kombi",
|
|
}, duration_ms=int(duration * 1000))
|
|
|
|
return {"session_id": session_id, **word_result}
|