""" 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