Files
breakpilot-lehrer/klausur-service/backend/tests/test_cv_graphic_detect.py
Benjamin Admin 729ebff63c feat: add border ghost filter + graphic detection tests + structure overlay
- Add _filter_border_ghost_words() to remove OCR artefacts from box borders
  (vertical + horizontal edge detection, column cleanup, re-indexing)
- Add 20 tests for border ghost filter (basic filtering + column cleanup)
- Add 24 tests for cv_graphic_detect (color detection, word overlap, boxes)
- Clean up cv_graphic_detect.py logging (per-candidate → DEBUG)
- Add structure overlay layer to StepReconstruction (boxes + graphics toggle)
- Show border_ghosts_removed badge in StepStructureDetection
- Update MkDocs with structure detection documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:28:53 +01:00

321 lines
12 KiB
Python

"""
Tests for cv_graphic_detect.py — graphic element detection.
Lizenz: Apache 2.0
"""
import numpy as np
import pytest
import cv2
from cv_graphic_detect import detect_graphic_elements, GraphicElement, _dominant_color
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _white_image(width: int = 1200, height: int = 1800) -> np.ndarray:
"""Create a plain white BGR image."""
return np.ones((height, width, 3), dtype=np.uint8) * 255
def _draw_colored_circle(img: np.ndarray, cx: int, cy: int, radius: int,
color_bgr: tuple) -> np.ndarray:
"""Draw a filled colored circle (simulates a balloon / graphic)."""
cv2.circle(img, (cx, cy), radius, color_bgr, -1)
return img
def _draw_colored_region(img: np.ndarray, x: int, y: int, w: int, h: int,
color_bgr: tuple) -> np.ndarray:
"""Draw a filled colored rectangle (simulates an image region)."""
cv2.rectangle(img, (x, y), (x + w, y + h), color_bgr, -1)
return img
def _draw_black_illustration(img: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
"""Draw a large black filled shape (simulates a black-ink illustration)."""
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 0), -1)
return img
def _word_box(left: int, top: int, width: int, height: int) -> dict:
"""Create a word box dict matching OCR output format."""
return {"left": left, "top": top, "width": width, "height": height}
# ---------------------------------------------------------------------------
# _dominant_color tests
# ---------------------------------------------------------------------------
class TestDominantColor:
"""Tests for the _dominant_color helper."""
def test_empty_array(self):
hsv = np.array([], dtype=np.uint8).reshape(0, 3)
name, hex_val = _dominant_color(hsv)
assert name == "black"
assert hex_val == "#000000"
def test_low_saturation_returns_black(self):
"""Pixels with low saturation should be classified as black."""
# HSV: H=90 (irrelevant), S=10 (low), V=200
hsv = np.full((50, 50, 3), [90, 10, 200], dtype=np.uint8)
name, _ = _dominant_color(hsv)
assert name == "black"
def test_red_hue(self):
"""Pixels with hue ~0-10 or ~170+ should be red."""
hsv = np.full((50, 50, 3), [5, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "red"
assert hex_val == "#dc2626"
def test_blue_hue(self):
"""Pixels with hue ~100 should be blue."""
hsv = np.full((50, 50, 3), [110, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "blue"
assert hex_val == "#2563eb"
def test_green_hue(self):
"""Pixels with hue ~60 should be green."""
hsv = np.full((50, 50, 3), [60, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "green"
assert hex_val == "#16a34a"
def test_yellow_hue(self):
"""Pixels with hue ~30 should be yellow."""
hsv = np.full((50, 50, 3), [30, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "yellow"
def test_orange_hue(self):
"""Pixels with hue ~15 should be orange."""
hsv = np.full((50, 50, 3), [15, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "orange"
def test_purple_hue(self):
"""Pixels with hue ~140 should be purple."""
hsv = np.full((50, 50, 3), [140, 200, 200], dtype=np.uint8)
name, hex_val = _dominant_color(hsv)
assert name == "purple"
# ---------------------------------------------------------------------------
# detect_graphic_elements tests
# ---------------------------------------------------------------------------
class TestDetectGraphicElements:
"""Tests for the detect_graphic_elements() function."""
def test_none_image_returns_empty(self):
"""None input should return empty list."""
result = detect_graphic_elements(None, [])
assert result == []
def test_white_image_no_graphics(self):
"""A plain white image should produce no graphic elements."""
img = _white_image()
result = detect_graphic_elements(img, [])
assert result == []
def test_colored_region_detected_as_image(self):
"""A large colored rectangle should be detected as an image."""
img = _white_image()
# Draw a large red region (not text-like)
_draw_colored_region(img, x=100, y=300, w=200, h=200, color_bgr=(0, 0, 220))
result = detect_graphic_elements(img, word_boxes=[])
assert len(result) >= 1
graphic = result[0]
assert isinstance(graphic, GraphicElement)
assert graphic.shape == "image"
assert graphic.color_name == "red"
assert graphic.confidence > 0
def test_colored_text_excluded_by_word_overlap(self):
"""Colored regions that overlap heavily with word boxes should be skipped."""
img = _white_image()
# Draw colored region
_draw_colored_region(img, x=100, y=300, w=400, h=50, color_bgr=(0, 0, 220))
# Word boxes covering >50% of the colored region
words = [
_word_box(100, 300, 200, 50),
_word_box(300, 300, 200, 50),
]
result = detect_graphic_elements(img, word_boxes=words)
# Should be filtered out (word overlap > 50%)
for g in result:
# If anything is detected at that location, overlap check failed
if g.x >= 90 and g.x <= 110 and g.y >= 290 and g.y <= 310:
pytest.fail("Colored text region should be excluded by word overlap")
def test_colored_graphic_with_low_word_overlap_kept(self):
"""A colored region with low word overlap should be kept."""
img = _white_image()
# Draw a large colored circle
_draw_colored_circle(img, cx=300, cy=400, radius=80, color_bgr=(0, 200, 0))
# One small word box overlapping only a tiny portion
words = [_word_box(250, 390, 30, 20)]
result = detect_graphic_elements(img, word_boxes=words)
assert len(result) >= 1
assert result[0].shape == "image"
assert result[0].color_name == "green"
def test_black_illustration_detected(self):
"""A large black filled area should be detected as illustration."""
img = _white_image()
# Draw a large black rectangle (simulating an illustration)
_draw_black_illustration(img, x=200, y=400, w=300, h=300)
result = detect_graphic_elements(img, word_boxes=[])
assert len(result) >= 1
illust = [g for g in result if g.shape == "illustration"]
assert len(illust) >= 1
assert illust[0].color_name == "black"
def test_black_illustration_excluded_by_word_boxes(self):
"""Black ink in word regions should NOT be detected as illustration."""
img = _white_image()
# Draw black text-like region
_draw_black_illustration(img, x=100, y=300, w=400, h=60)
# Word boxes covering the same area
words = [
_word_box(100, 300, 200, 60),
_word_box(300, 300, 200, 60),
]
result = detect_graphic_elements(img, word_boxes=words)
# Should be empty — the word exclusion mask covers the ink
illust = [g for g in result if g.shape == "illustration"]
assert len(illust) == 0
def test_tiny_colored_region_filtered(self):
"""Very small colored regions (<200 colored pixels) should be filtered."""
img = _white_image()
# Draw a tiny colored dot (5x5 pixels)
_draw_colored_region(img, x=500, y=500, w=5, h=5, color_bgr=(220, 0, 0))
result = detect_graphic_elements(img, word_boxes=[])
assert result == []
def test_page_spanning_region_filtered(self):
"""Colored regions spanning >50% of width/height should be skipped."""
img = _white_image(width=1200, height=1800)
# Draw a region wider than 50% of the image
_draw_colored_region(img, x=50, y=300, w=700, h=100, color_bgr=(0, 0, 220))
result = detect_graphic_elements(img, word_boxes=[])
# Should be filtered as page-spanning
assert result == []
def test_multiple_graphics_detected(self):
"""Multiple separate colored regions should all be detected."""
img = _white_image()
# Three separate colored circles
_draw_colored_circle(img, cx=200, cy=300, radius=60, color_bgr=(0, 0, 220))
_draw_colored_circle(img, cx=500, cy=300, radius=60, color_bgr=(0, 200, 0))
_draw_colored_circle(img, cx=200, cy=600, radius=60, color_bgr=(220, 0, 0))
result = detect_graphic_elements(img, word_boxes=[])
# Should detect at least 2 (some may merge if dilation connects them)
assert len(result) >= 2
def test_results_sorted_by_area_descending(self):
"""Results should be sorted by area, largest first."""
img = _white_image()
# Small circle
_draw_colored_circle(img, cx=200, cy=300, radius=30, color_bgr=(0, 0, 220))
# Large circle
_draw_colored_circle(img, cx=600, cy=800, radius=100, color_bgr=(0, 200, 0))
result = detect_graphic_elements(img, word_boxes=[])
if len(result) >= 2:
assert result[0].area >= result[1].area
def test_max_elements_limit(self):
"""Should respect max_elements parameter."""
img = _white_image(width=2000, height=2000)
# Draw many colored regions
for i in range(10):
_draw_colored_circle(img, cx=100 + i * 180, cy=300, radius=40,
color_bgr=(0, 0, 220))
result = detect_graphic_elements(img, word_boxes=[], max_elements=3)
assert len(result) <= 3
def test_detected_boxes_excluded_from_ink(self):
"""Detected box regions should be excluded from ink illustration detection."""
img = _white_image()
# Draw a black rectangle well inside the "box" area (8px inset is used)
_draw_black_illustration(img, x=120, y=320, w=360, h=160)
# Mark the outer box — the 8px inset still covers the drawn region
detected_boxes = [{"x": 100, "y": 300, "w": 400, "h": 200}]
result = detect_graphic_elements(img, word_boxes=[], detected_boxes=detected_boxes)
illust = [g for g in result if g.shape == "illustration"]
assert len(illust) == 0
def test_deduplication_overlapping_regions(self):
"""Overlapping elements should be deduplicated."""
img = _white_image()
# Two overlapping colored regions
_draw_colored_region(img, x=200, y=300, w=200, h=200, color_bgr=(0, 0, 220))
_draw_colored_region(img, x=250, y=350, w=200, h=200, color_bgr=(0, 0, 220))
result = detect_graphic_elements(img, word_boxes=[])
# Should be merged/deduplicated into 1 element (heavy dilation merges them)
assert len(result) <= 2
def test_graphicelement_dataclass_fields(self):
"""GraphicElement should have all expected fields."""
elem = GraphicElement(
x=10, y=20, width=100, height=80,
area=5000, shape="image",
color_name="red", color_hex="#dc2626",
confidence=0.85,
)
assert elem.x == 10
assert elem.y == 20
assert elem.width == 100
assert elem.height == 80
assert elem.area == 5000
assert elem.shape == "image"
assert elem.color_name == "red"
assert elem.color_hex == "#dc2626"
assert elem.confidence == 0.85
assert elem.contour is None
def test_small_ink_area_filtered(self):
"""Black ink areas smaller than 5000px should be filtered."""
img = _white_image()
# Small black mark (50x50 = 2500 area, below 5000 threshold)
_draw_black_illustration(img, x=500, y=500, w=50, h=50)
result = detect_graphic_elements(img, word_boxes=[])
illust = [g for g in result if g.shape == "illustration"]
assert len(illust) == 0