refactor(dewarp): replace displacement map with affine shear correction

The old displacement-map approach shifted entire rows by a parabolic
profile, creating a circle/barrel distortion. The actual problem is
a linear vertical shear: after deskew aligns horizontal lines, the
vertical column edges are still tilted by ~0.5°.

New approach:
- Detect shear angle from strongest vertical edge slope (not curvature)
- Apply cv2.warpAffine shear to straighten vertical features
- Manual slider: -2.0° to +2.0° in 0.05° steps
- Slider initializes to auto-detected shear angle
- Ground truth question: "Spalten vertikal ausgerichtet?"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-26 18:23:04 +01:00
parent ff2bb79a91
commit 09b820efbe
5 changed files with 109 additions and 279 deletions

View File

@@ -81,12 +81,12 @@ class DeskewGroundTruthRequest(BaseModel):
class ManualDewarpRequest(BaseModel):
scale: float
shear_degrees: float
class DewarpGroundTruthRequest(BaseModel):
is_correct: bool
corrected_scale: Optional[float] = None
corrected_shear: Optional[float] = None
notes: Optional[str] = None
@@ -132,7 +132,7 @@ async def create_session(file: UploadFile = File(...)):
"dewarped_bgr": None,
"dewarped_png": None,
"dewarp_result": None,
"displacement_map": None,
"auto_shear_degrees": None,
"ground_truth": {},
"current_step": 1,
}
@@ -352,7 +352,7 @@ async def save_deskew_ground_truth(session_id: str, req: DeskewGroundTruthReques
@router.post("/sessions/{session_id}/dewarp")
async def auto_dewarp(session_id: str):
"""Run both dewarp methods on the deskewed image and pick the best."""
"""Detect and correct vertical shear on the deskewed image."""
session = _get_session(session_id)
deskewed_bgr = session.get("deskewed_bgr")
if deskewed_bgr is None:
@@ -368,22 +368,22 @@ async def auto_dewarp(session_id: str):
session["dewarped_bgr"] = dewarped_bgr
session["dewarped_png"] = dewarped_png
session["auto_shear_degrees"] = dewarp_info.get("shear_degrees", 0.0)
session["dewarp_result"] = {
"method_used": dewarp_info["method"],
"curvature_px": dewarp_info["curvature_px"],
"shear_degrees": dewarp_info["shear_degrees"],
"confidence": dewarp_info["confidence"],
"duration_seconds": round(duration, 2),
}
session["displacement_map"] = dewarp_info.get("displacement_map")
logger.info(f"OCR Pipeline: dewarp session {session_id}: "
f"method={dewarp_info['method']} curvature={dewarp_info['curvature_px']:.1f}px "
f"method={dewarp_info['method']} shear={dewarp_info['shear_degrees']:.3f}° "
f"conf={dewarp_info['confidence']:.2f} ({duration:.2f}s)")
return {
"session_id": session_id,
"method_used": dewarp_info["method"],
"curvature_px": dewarp_info["curvature_px"],
"shear_degrees": dewarp_info["shear_degrees"],
"confidence": dewarp_info["confidence"],
"duration_seconds": round(duration, 2),
"dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped",
@@ -392,21 +392,19 @@ async def auto_dewarp(session_id: str):
@router.post("/sessions/{session_id}/dewarp/manual")
async def manual_dewarp(session_id: str, req: ManualDewarpRequest):
"""Apply dewarp with a manually scaled displacement map."""
"""Apply shear correction with a manual angle."""
session = _get_session(session_id)
deskewed_bgr = session.get("deskewed_bgr")
displacement_map = session.get("displacement_map")
if deskewed_bgr is None:
raise HTTPException(status_code=400, detail="Deskew must be completed before dewarp")
scale = max(0.0, min(2.0, req.scale))
shear_deg = max(-2.0, min(2.0, req.shear_degrees))
if displacement_map is None or scale < 0.01:
# No displacement map or zero scale — use deskewed as-is
if abs(shear_deg) < 0.001:
dewarped_bgr = deskewed_bgr
else:
dewarped_bgr = dewarp_image_manual(deskewed_bgr, displacement_map, scale)
dewarped_bgr = dewarp_image_manual(deskewed_bgr, shear_deg)
success, png_buf = cv2.imencode(".png", dewarped_bgr)
dewarped_png = png_buf.tobytes() if success else session.get("deskewed_png")
@@ -416,14 +414,14 @@ async def manual_dewarp(session_id: str, req: ManualDewarpRequest):
session["dewarp_result"] = {
**(session.get("dewarp_result") or {}),
"method_used": "manual",
"scale_applied": round(scale, 2),
"shear_degrees": round(shear_deg, 3),
}
logger.info(f"OCR Pipeline: manual dewarp session {session_id}: scale={scale:.2f}")
logger.info(f"OCR Pipeline: manual dewarp session {session_id}: shear={shear_deg:.3f}°")
return {
"session_id": session_id,
"scale_applied": round(scale, 2),
"shear_degrees": round(shear_deg, 3),
"method_used": "manual",
"dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped",
}
@@ -436,7 +434,7 @@ async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthReques
gt = {
"is_correct": req.is_correct,
"corrected_scale": req.corrected_scale,
"corrected_shear": req.corrected_shear,
"notes": req.notes,
"saved_at": datetime.utcnow().isoformat(),
"dewarp_result": session.get("dewarp_result"),
@@ -444,6 +442,6 @@ async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthReques
session["ground_truth"]["dewarp"] = gt
logger.info(f"OCR Pipeline: ground truth dewarp session {session_id}: "
f"correct={req.is_correct}, corrected_scale={req.corrected_scale}")
f"correct={req.is_correct}, corrected_shear={req.corrected_shear}")
return {"session_id": session_id, "ground_truth": gt}