From 037f542e7a24b7bc98d0d6d15caae7308556b546 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 22 Apr 2026 07:03:54 +0100 Subject: [PATCH] Add edge concentration and stroke wobble metrics --- src/repixelizer/compare.py | 2 ++ src/repixelizer/metrics.py | 55 ++++++++++++++++++++++++++++++++++++++ tests/test_metrics.py | 15 +++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/repixelizer/compare.py b/src/repixelizer/compare.py index 689fcee..d9bcc34 100644 --- a/src/repixelizer/compare.py +++ b/src/repixelizer/compare.py @@ -16,6 +16,7 @@ from .metrics import ( foreground_edge_position_error, foreground_motif_error, foreground_reconstruction_error, + foreground_stroke_wobble_error, reconstruction_error, ) from .palette import load_palette @@ -107,6 +108,7 @@ def run_compare( "foreground_reconstruction_error": foreground_reconstruction_error(preview, source), "foreground_edge_concentration": foreground_edge_concentration(image), "foreground_edge_position_error": foreground_edge_position_error(preview, source), + "foreground_stroke_wobble_error": foreground_stroke_wobble_error(preview, source), "foreground_adjacency_error": foreground_adjacency_error(preview, source), "foreground_motif_error": foreground_motif_error(preview, source), } diff --git a/src/repixelizer/metrics.py b/src/repixelizer/metrics.py index f49d777..c118d01 100644 --- a/src/repixelizer/metrics.py +++ b/src/repixelizer/metrics.py @@ -213,6 +213,30 @@ def foreground_edge_concentration( return float(np.sum(values * values) / total) +def foreground_stroke_wobble_error( + a: np.ndarray, + b: np.ndarray, + alpha_threshold: float = 0.05, + halo: int = 1, + radius: int = 2, + line_threshold: float = 0.12, +) -> float: + edge_a = _edge_strength_map(a) + edge_b = _edge_strength_map(b) + strength_a, centroid_a = _line_profile_signature(edge_a, radius=radius) + strength_b, centroid_b = _line_profile_signature(edge_b, radius=radius) + + orientation = np.argmax(strength_b, axis=0) + source_strength = np.take_along_axis(strength_b, orientation[None, ...], axis=0)[0] + source_centroid = np.take_along_axis(centroid_b, orientation[None, ...], axis=0)[0] + output_centroid = np.take_along_axis(centroid_a, orientation[None, ...], axis=0)[0] + + support_mask = sprite_mask(a, b, alpha_threshold=alpha_threshold, halo=halo) & (source_strength >= line_threshold) + if not np.any(support_mask): + return 0.0 + return float(np.mean(np.abs(output_centroid[support_mask] - source_centroid[support_mask]))) + + def source_lattice_consistency_breakdown( source_rgba: np.ndarray, output_rgba: np.ndarray, @@ -364,6 +388,37 @@ def _best_local_edge_support(edge: np.ndarray, *, radius: int, distance_penalty: return best +def _line_profile_signature(edge: np.ndarray, *, radius: int) -> tuple[np.ndarray, np.ndarray]: + offsets = np.arange(-radius, radius + 1, dtype=np.float32) + profiles = [] + for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)): + stack = np.stack([_shift2d(edge, int(dy * offset), int(dx * offset)) for offset in offsets], axis=0) + energy = np.sum(stack, axis=0) + safe_energy = np.maximum(energy, 1e-6) + centroid = np.sum(stack * offsets[:, None, None], axis=0) / safe_energy + spread = np.sum(stack * np.abs(offsets[:, None, None] - centroid[None, :, :]), axis=0) / safe_energy + strength = energy / (1.0 + spread * 2.0) + profiles.append((strength.astype(np.float32), centroid.astype(np.float32))) + strengths = np.stack([item[0] for item in profiles], axis=0) + centroids = np.stack([item[1] for item in profiles], axis=0) + return strengths, centroids + + +def _shift2d(arr: np.ndarray, dy: int, dx: int) -> np.ndarray: + height, width = arr.shape + shifted = np.zeros_like(arr, dtype=np.float32) + src_y0 = max(0, -dy) + src_y1 = min(height, height - dy) + src_x0 = max(0, -dx) + src_x1 = min(width, width - dx) + dst_y0 = max(0, dy) + dst_y1 = dst_y0 + (src_y1 - src_y0) + dst_x0 = max(0, dx) + dst_x1 = dst_x0 + (src_x1 - src_x0) + shifted[dst_y0:dst_y1, dst_x0:dst_x1] = arr[src_y0:src_y1, src_x0:src_x1] + return shifted + + def _motif_blocks(premul: np.ndarray) -> np.ndarray: blocks = np.stack( [ diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 4f4bb7d..905081e 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -10,6 +10,7 @@ from repixelizer.metrics import ( foreground_exact_match_ratio, foreground_motif_error, foreground_reconstruction_error, + foreground_stroke_wobble_error, source_lattice_consistency_breakdown, reconstruction_error, ) @@ -97,6 +98,20 @@ def test_edge_concentration_prefers_crisp_edges_to_blurry_smear() -> None: assert foreground_edge_concentration(shifted) > foreground_edge_concentration(blurred) +def test_stroke_wobble_metric_penalizes_local_line_jitter() -> None: + original = np.zeros((9, 9, 4), dtype=np.float32) + original[2:7, 4] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32) + + stable = original.copy() + + wobbly = np.zeros_like(original) + wobbly[2:4, 4] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32) + wobbly[4:7, 5] = np.asarray([1.0, 1.0, 1.0, 1.0], dtype=np.float32) + + assert foreground_stroke_wobble_error(original, stable) == 0.0 + assert foreground_stroke_wobble_error(original, wobbly) > 0.0 + + def test_source_lattice_consistency_prefers_matching_cell_structure() -> None: lowres = np.asarray( [