#!/usr/bin/env python3 """Regression tests for window_postprocess (numpy/scipy only). Run with ``python3 test_window_postprocess.py``. These pin the behaviours that matter for painting safety: merging mullion-split panes, fitting an oriented rectangle at any angle, filtering specks and non-window shapes, and rasterising a clean keep-out. """ import numpy as np from PIL import Image, ImageDraw import window_postprocess as wp def rotated_rectangle(height, width, center, angle_deg, side_w, side_h): canvas = Image.new("L", (width, height), 0) draw = ImageDraw.Draw(canvas) theta = np.radians(angle_deg) axes = np.array([[np.cos(theta), np.sin(theta)], [-np.sin(theta), np.cos(theta)]]) half = np.array([side_w, side_h]) / 2.0 corners = [] for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)): point = np.array(center) + np.array([sx, sy]) * half @ axes corners.append(tuple(point)) draw.polygon(corners, fill=255) return np.asarray(canvas) > 0 def test_minimum_area_rectangle_recovers_orientation(): for angle in (0, 30, 45, 75): mask = rotated_rectangle(400, 400, (200, 200), angle, 140, 60) ys, xs = np.nonzero(mask) corners, width, height, fitted = wp.minimum_area_rectangle(np.c_[xs, ys]) long_side, short_side = max(width, height), min(width, height) assert abs(long_side - 140) < 8, (angle, width, height) assert abs(short_side - 60) < 8, (angle, width, height) # Angle is only defined modulo 90 degrees for an axis-aligned box. assert min(abs((fitted - angle) % 90), abs((fitted - angle) % 90 - 90)) < 6, (angle, fitted) def test_merge_fragments_joins_mullion_panes_only(): mask = np.zeros((120, 160), bool) mask[30:90, 10:60] = True # left pane mask[30:90, 66:120] = True # right pane, 6px gap merged = wp.merge_fragments(mask, gap=8) assert merged.max() == 1, "panes within the gap should merge" far = mask.copy() far[30:90, 140:159] = True # a third pane far away assert wp.merge_fragments(far, gap=8).max() == 2 def test_window_instances_merges_and_filters(): mask = np.zeros((200, 200), bool) mask[50:110, 40:80] = True mask[50:110, 84:130] = True # mullion gap of 4px mask[10:12, 10:12] = True # stray speck instances, labels = wp.window_instances(mask, min_area=64, merge_gap=8, min_fill=0.5, max_aspect=8.0) assert len(instances) == 1, instances window = instances[0] assert window["area_pixels"] > 5000 assert window["fill_ratio"] > 0.85 assert window["bbox_xyxy"][0] == 40 and window["bbox_xyxy"][2] == 129 def test_window_instances_rejects_elongated_shape(): mask = np.zeros((200, 400), bool) mask[95:105, 10:390] = True # a long thin stripe, aspect ~38 instances, _ = wp.window_instances(mask, min_area=64, merge_gap=0, max_aspect=8.0) assert instances == [] def test_regularized_mask_fills_mullion_gap(): mask = np.zeros((120, 200), bool) mask[30:90, 20:80] = True mask[30:90, 90:150] = True # 10px gap instances, _ = wp.window_instances(mask, min_area=64, merge_gap=14, min_fill=0.5) assert len(instances) == 1 regular = wp.regularized_mask(instances, mask.shape) # The fitted rectangle should cover the gap the raw mask left empty. assert regular[60, 85], "fitted window should fill the mullion gap" assert int(regular.sum()) > int(mask.sum()) def test_window_metrics(): target = np.zeros((50, 50), bool) target[10:40, 10:40] = True assert wp.window_metrics(target, target)["iou"] == 1.0 disjoint = np.zeros_like(target) disjoint[0:5, 0:5] = True result = wp.window_metrics(disjoint, target) assert result["precision"] == 0.0 and result["recall"] == 0.0 and result["iou"] == 0.0 def main(): tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")] for test in tests: test() print(f"ok {test.__name__}") print(f"{len(tests)} tests passed") if __name__ == "__main__": main()