- 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>
321 lines
12 KiB
Python
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
|