fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
383
klausur-service/backend/services/inpainting_service.py
Normal file
383
klausur-service/backend/services/inpainting_service.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Inpainting Service for Worksheet Cleanup
|
||||
|
||||
Removes handwriting from scanned worksheets using inpainting techniques.
|
||||
Supports multiple backends:
|
||||
1. OpenCV (Telea/NS algorithms) - Fast, CPU-based baseline
|
||||
2. LaMa (Large Mask Inpainting) - Optional, better quality
|
||||
|
||||
DATENSCHUTZ: All processing happens locally on Mac Mini.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import io
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
# OpenCV is optional - only required for actual inpainting
|
||||
try:
|
||||
import cv2
|
||||
CV2_AVAILABLE = True
|
||||
except ImportError:
|
||||
cv2 = None
|
||||
CV2_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InpaintingMethod(str, Enum):
|
||||
"""Available inpainting methods."""
|
||||
OPENCV_TELEA = "opencv_telea" # Fast, good for small regions
|
||||
OPENCV_NS = "opencv_ns" # Navier-Stokes, slower but smoother
|
||||
LAMA = "lama" # LaMa deep learning (if available)
|
||||
AUTO = "auto" # Automatically select best method
|
||||
|
||||
|
||||
@dataclass
|
||||
class InpaintingResult:
|
||||
"""Result of inpainting operation."""
|
||||
image: np.ndarray # Cleaned image (BGR)
|
||||
method_used: str # Which method was actually used
|
||||
processing_time_ms: float # Processing time in milliseconds
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
# Global LaMa model (lazy loaded)
|
||||
_lama_model = None
|
||||
_lama_available = None
|
||||
|
||||
|
||||
def check_lama_available() -> bool:
|
||||
"""Check if LaMa inpainting is available."""
|
||||
global _lama_available
|
||||
|
||||
if _lama_available is not None:
|
||||
return _lama_available
|
||||
|
||||
try:
|
||||
# Try to import lama-cleaner library
|
||||
from lama_cleaner.model_manager import ModelManager
|
||||
_lama_available = True
|
||||
logger.info("LaMa inpainting is available")
|
||||
except ImportError:
|
||||
_lama_available = False
|
||||
logger.info("LaMa not available, will use OpenCV fallback")
|
||||
except Exception as e:
|
||||
_lama_available = False
|
||||
logger.warning(f"LaMa check failed: {e}")
|
||||
|
||||
return _lama_available
|
||||
|
||||
|
||||
def inpaint_image(
|
||||
image_bytes: bytes,
|
||||
mask_bytes: bytes,
|
||||
method: InpaintingMethod = InpaintingMethod.AUTO,
|
||||
inpaint_radius: int = 3
|
||||
) -> InpaintingResult:
|
||||
"""
|
||||
Inpaint (remove) masked regions from an image.
|
||||
|
||||
Args:
|
||||
image_bytes: Source image as bytes
|
||||
mask_bytes: Binary mask where white (255) = regions to remove
|
||||
method: Inpainting method to use
|
||||
inpaint_radius: Radius for OpenCV inpainting (default 3)
|
||||
|
||||
Returns:
|
||||
InpaintingResult with cleaned image
|
||||
|
||||
Raises:
|
||||
ImportError: If OpenCV is not available
|
||||
"""
|
||||
if not CV2_AVAILABLE:
|
||||
raise ImportError(
|
||||
"OpenCV (cv2) is required for inpainting. "
|
||||
"Install with: pip install opencv-python-headless"
|
||||
)
|
||||
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Load image
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
img_array = np.array(img)
|
||||
|
||||
# Convert to BGR for OpenCV
|
||||
if len(img_array.shape) == 2:
|
||||
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR)
|
||||
elif img_array.shape[2] == 4:
|
||||
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGBA2BGR)
|
||||
elif img_array.shape[2] == 3:
|
||||
img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
|
||||
else:
|
||||
img_bgr = img_array
|
||||
|
||||
# Load mask
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes))
|
||||
mask_array = np.array(mask_img)
|
||||
|
||||
# Ensure mask is single channel
|
||||
if len(mask_array.shape) == 3:
|
||||
mask_array = cv2.cvtColor(mask_array, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Ensure mask is binary
|
||||
_, mask_binary = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# Resize mask if dimensions don't match
|
||||
if mask_binary.shape[:2] != img_bgr.shape[:2]:
|
||||
mask_binary = cv2.resize(
|
||||
mask_binary,
|
||||
(img_bgr.shape[1], img_bgr.shape[0]),
|
||||
interpolation=cv2.INTER_NEAREST
|
||||
)
|
||||
|
||||
# Select method
|
||||
if method == InpaintingMethod.AUTO:
|
||||
# Use LaMa if available and mask is large
|
||||
mask_ratio = np.sum(mask_binary > 0) / mask_binary.size
|
||||
if check_lama_available() and mask_ratio > 0.05:
|
||||
method = InpaintingMethod.LAMA
|
||||
else:
|
||||
method = InpaintingMethod.OPENCV_TELEA
|
||||
|
||||
# Perform inpainting
|
||||
if method == InpaintingMethod.LAMA:
|
||||
result_img, actual_method = _inpaint_lama(img_bgr, mask_binary)
|
||||
elif method == InpaintingMethod.OPENCV_NS:
|
||||
result_img = cv2.inpaint(
|
||||
img_bgr, mask_binary, inpaint_radius, cv2.INPAINT_NS
|
||||
)
|
||||
actual_method = "opencv_ns"
|
||||
else: # OPENCV_TELEA (default)
|
||||
result_img = cv2.inpaint(
|
||||
img_bgr, mask_binary, inpaint_radius, cv2.INPAINT_TELEA
|
||||
)
|
||||
actual_method = "opencv_telea"
|
||||
|
||||
processing_time = (time.time() - start_time) * 1000
|
||||
|
||||
logger.info(f"Inpainting completed: method={actual_method}, "
|
||||
f"time={processing_time:.1f}ms")
|
||||
|
||||
return InpaintingResult(
|
||||
image=result_img,
|
||||
method_used=actual_method,
|
||||
processing_time_ms=processing_time,
|
||||
success=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Inpainting failed: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
return InpaintingResult(
|
||||
image=None,
|
||||
method_used="none",
|
||||
processing_time_ms=0,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
|
||||
def _inpaint_lama(
|
||||
img_bgr: np.ndarray,
|
||||
mask: np.ndarray
|
||||
) -> Tuple[np.ndarray, str]:
|
||||
"""
|
||||
Inpaint using LaMa (Large Mask Inpainting).
|
||||
|
||||
Falls back to OpenCV if LaMa fails.
|
||||
"""
|
||||
global _lama_model
|
||||
|
||||
try:
|
||||
from lama_cleaner.model_manager import ModelManager
|
||||
from lama_cleaner.schema import Config, HDStrategy, LDMSampler
|
||||
|
||||
# Initialize model if needed
|
||||
if _lama_model is None:
|
||||
logger.info("Loading LaMa model...")
|
||||
_lama_model = ModelManager(
|
||||
name="lama",
|
||||
device="cpu", # Use CPU for Mac Mini compatibility
|
||||
)
|
||||
logger.info("LaMa model loaded")
|
||||
|
||||
# Prepare config
|
||||
config = Config(
|
||||
ldm_steps=25,
|
||||
ldm_sampler=LDMSampler.plms,
|
||||
hd_strategy=HDStrategy.ORIGINAL,
|
||||
hd_strategy_crop_margin=32,
|
||||
hd_strategy_crop_trigger_size=800,
|
||||
hd_strategy_resize_limit=800,
|
||||
)
|
||||
|
||||
# Convert BGR to RGB for LaMa
|
||||
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Run inpainting
|
||||
result_rgb = _lama_model(img_rgb, mask, config)
|
||||
|
||||
# Convert back to BGR
|
||||
result_bgr = cv2.cvtColor(result_rgb, cv2.COLOR_RGB2BGR)
|
||||
|
||||
return result_bgr, "lama"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"LaMa inpainting failed, falling back to OpenCV: {e}")
|
||||
# Fallback to OpenCV
|
||||
result = cv2.inpaint(img_bgr, mask, 3, cv2.INPAINT_TELEA)
|
||||
return result, "opencv_telea_fallback"
|
||||
|
||||
|
||||
def inpaint_opencv_telea(
|
||||
image_bytes: bytes,
|
||||
mask_bytes: bytes,
|
||||
radius: int = 3
|
||||
) -> bytes:
|
||||
"""
|
||||
Simple OpenCV Telea inpainting - fastest option.
|
||||
|
||||
Args:
|
||||
image_bytes: Source image
|
||||
mask_bytes: Binary mask (white = remove)
|
||||
radius: Inpainting radius
|
||||
|
||||
Returns:
|
||||
Inpainted image as PNG bytes
|
||||
"""
|
||||
result = inpaint_image(
|
||||
image_bytes,
|
||||
mask_bytes,
|
||||
method=InpaintingMethod.OPENCV_TELEA,
|
||||
inpaint_radius=radius
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise RuntimeError(f"Inpainting failed: {result.error_message}")
|
||||
|
||||
return image_to_png(result.image)
|
||||
|
||||
|
||||
def inpaint_opencv_ns(
|
||||
image_bytes: bytes,
|
||||
mask_bytes: bytes,
|
||||
radius: int = 3
|
||||
) -> bytes:
|
||||
"""
|
||||
OpenCV Navier-Stokes inpainting - smoother but slower.
|
||||
"""
|
||||
result = inpaint_image(
|
||||
image_bytes,
|
||||
mask_bytes,
|
||||
method=InpaintingMethod.OPENCV_NS,
|
||||
inpaint_radius=radius
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise RuntimeError(f"Inpainting failed: {result.error_message}")
|
||||
|
||||
return image_to_png(result.image)
|
||||
|
||||
|
||||
def remove_handwriting(
|
||||
image_bytes: bytes,
|
||||
mask: Optional[np.ndarray] = None,
|
||||
method: InpaintingMethod = InpaintingMethod.AUTO
|
||||
) -> Tuple[bytes, dict]:
|
||||
"""
|
||||
High-level function to remove handwriting from an image.
|
||||
|
||||
If no mask is provided, detects handwriting automatically.
|
||||
|
||||
Args:
|
||||
image_bytes: Source image
|
||||
mask: Optional pre-computed mask
|
||||
method: Inpainting method
|
||||
|
||||
Returns:
|
||||
Tuple of (cleaned image bytes, metadata dict)
|
||||
"""
|
||||
from services.handwriting_detection import detect_handwriting, mask_to_png
|
||||
|
||||
# Detect handwriting if no mask provided
|
||||
if mask is None:
|
||||
detection_result = detect_handwriting(image_bytes)
|
||||
mask = detection_result.mask
|
||||
detection_info = {
|
||||
"confidence": detection_result.confidence,
|
||||
"handwriting_ratio": detection_result.handwriting_ratio,
|
||||
"detection_method": detection_result.detection_method
|
||||
}
|
||||
else:
|
||||
detection_info = {"provided_mask": True}
|
||||
|
||||
# Check if there's anything to inpaint
|
||||
if np.sum(mask > 0) == 0:
|
||||
logger.info("No handwriting detected, returning original image")
|
||||
return image_bytes, {
|
||||
"inpainting_performed": False,
|
||||
"reason": "no_handwriting_detected",
|
||||
**detection_info
|
||||
}
|
||||
|
||||
# Convert mask to bytes for inpainting
|
||||
mask_bytes = mask_to_png(mask)
|
||||
|
||||
# Perform inpainting
|
||||
result = inpaint_image(image_bytes, mask_bytes, method=method)
|
||||
|
||||
if not result.success:
|
||||
raise RuntimeError(f"Inpainting failed: {result.error_message}")
|
||||
|
||||
# Convert result to PNG
|
||||
result_bytes = image_to_png(result.image)
|
||||
|
||||
metadata = {
|
||||
"inpainting_performed": True,
|
||||
"method_used": result.method_used,
|
||||
"processing_time_ms": result.processing_time_ms,
|
||||
**detection_info
|
||||
}
|
||||
|
||||
return result_bytes, metadata
|
||||
|
||||
|
||||
def image_to_png(img_bgr: np.ndarray) -> bytes:
|
||||
"""
|
||||
Convert BGR image array to PNG bytes.
|
||||
"""
|
||||
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
||||
img_pil = Image.fromarray(img_rgb)
|
||||
buffer = io.BytesIO()
|
||||
img_pil.save(buffer, format='PNG', optimize=True)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def dilate_mask(mask_bytes: bytes, iterations: int = 2) -> bytes:
|
||||
"""
|
||||
Dilate a mask to expand the removal region.
|
||||
|
||||
Useful to ensure complete handwriting removal including edges.
|
||||
"""
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes))
|
||||
mask_array = np.array(mask_img)
|
||||
|
||||
if len(mask_array.shape) == 3:
|
||||
mask_array = cv2.cvtColor(mask_array, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
dilated = cv2.dilate(mask_array, kernel, iterations=iterations)
|
||||
|
||||
img = Image.fromarray(dilated)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
return buffer.getvalue()
|
||||
Reference in New Issue
Block a user