Spaces:
Runtime error
Runtime error
File size: 14,560 Bytes
ebd87b7 d6c386c bf442bf d6c386c bf442bf d6c386c ebd87b7 d6c386c bf442bf ebd87b7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 | """
tests/test_segmentation.py
----------------------------
Tests for the multi-signal sprite sheet segmentation pipeline.
Run with: pytest tests/test_segmentation.py -v
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from segmentation import (
GridLayout,
detect_separator_grid,
detect_tile_grid,
segment_grid,
segment_sheet,
)
def test_single_sprite():
"""A single solid block should be detected as exactly one sprite."""
mask = np.zeros((20, 20), dtype=bool)
mask[5:15, 5:15] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
assert detected[0].bbox.width == 10
assert detected[0].bbox.height == 10
def test_disconnected_parts_of_one_sprite_merge():
"""
A sprite made of two small blobs a couple of pixels apart (e.g. a
plant's leaf and stem) must be treated as ONE sprite, not two.
"""
mask = np.zeros((30, 30), dtype=bool)
mask[5:8, 5:8] = True # leaf
mask[10:12, 6:9] = True # stem, 2px gap
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
def test_two_far_sprites_stay_separate():
mask = np.zeros((30, 60), dtype=bool)
mask[5:10, 5:10] = True
mask[5:10, 40:45] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 2
def test_two_close_larger_sprites_stay_separate():
"""
Two side-by-side sprites, each a solid 10x10 block, with only a 3px
gap must NOT be merged into one -- this is the "yakın iki sprite"
case from the spec that a naive dilation-based merge would get wrong.
"""
mask = np.zeros((20, 40), dtype=bool)
mask[5:15, 5:15] = True
mask[5:15, 18:28] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 2
def test_hundred_small_sprites_grid():
"""100 small sprites tightly packed in a grid must all be found separately."""
mask = np.zeros((110, 110), dtype=bool)
count = 0
for row in range(10):
for col in range(10):
y, x = row * 11, col * 11
mask[y:y + 8, x:x + 8] = True
count += 1
detected, _, _ = segment_sheet(mask)
assert len(detected) == count
def test_multi_part_plants_in_grid_stay_separate_from_neighbors():
"""
Six plants, each made of two disconnected blobs, arranged in a grid.
Each plant's own parts must merge, but neighboring plants must not
merge with each other.
"""
mask = np.zeros((60, 60), dtype=bool)
positions = [(5, 5), (5, 20), (5, 35), (20, 5), (20, 20), (20, 35)]
for (y, x) in positions:
mask[y:y + 3, x + 2:x + 5] = True
mask[y + 4:y + 7, x:x + 3] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 6
def test_empty_mask_returns_no_sprites():
mask = np.zeros((20, 20), dtype=bool)
detected, _, _ = segment_sheet(mask)
assert len(detected) == 0
def test_single_pixel_noise_is_dropped():
"""A lone 1px speck should not be reported as a sprite."""
mask = np.zeros((20, 20), dtype=bool)
mask[10, 10] = True # single isolated pixel
detected, _, _ = segment_sheet(mask)
assert len(detected) == 0
def test_long_horizontal_fence_stays_one_sprite():
"""A long thin horizontal object (e.g. a fence) is a valid single sprite."""
mask = np.zeros((10, 100), dtype=bool)
mask[4:6, 5:95] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
assert detected[0].bbox.width == 90
def test_long_vertical_object_stays_one_sprite():
mask = np.zeros((100, 10), dtype=bool)
mask[5:95, 4:6] = True
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
assert detected[0].bbox.height == 90
def test_touching_sprites_merge_since_bboxes_overlap():
"""Two sprites whose pixels are directly touching (0 gap) are one region."""
mask = np.zeros((20, 20), dtype=bool)
mask[5:10, 5:10] = True
mask[5:10, 10:15] = True # directly adjacent, no gap
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
def test_sprites_are_numbered_in_reading_order():
"""Detected sprites should be ordered top-to-bottom, left-to-right."""
mask = np.zeros((40, 40), dtype=bool)
mask[25:30, 5:10] = True # bottom-left
mask[5:10, 25:30] = True # top-right
mask[5:10, 5:10] = True # top-left
detected, _, _ = segment_sheet(mask)
assert len(detected) == 3
xs_of_top_row = [d.bbox.x for d in detected if d.bbox.y < 15]
assert xs_of_top_row == sorted(xs_of_top_row)
def test_low_confidence_flagged_for_oversized_region():
"""A region covering most of the sheet should get a low confidence score."""
mask = np.ones((20, 20), dtype=bool)
mask[0, 0] = False # keep it from being literally 100% to avoid degenerate case
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
assert detected[0].confidence < 100
# ---------------------------------------------------------------------------
# Grid-corrected segmentation: animation frames and edge-to-edge tilesets
# ---------------------------------------------------------------------------
def _particle_frame(mask, x0, y0, w, h, rng):
"""A frame drawn as scattered 1px particles, like an explosion's debris."""
for _ in range(24):
mask[y0 + rng.integers(0, h), x0 + rng.integers(0, w)] = True
# anchor the frame's extent so its bbox is predictable
mask[y0, x0] = True
mask[y0 + h - 1, x0 + w - 1] = True
def test_particle_animation_frames_are_not_lost_as_noise():
"""
An animation strip whose frames are clouds of loose 1-2px particles.
Every one of those particles is individually below MIN_SPRITE_SIZE_PX,
so the gap pipeline drops them and whole frames vanish. The frame
gutters must be used instead, giving exactly one sprite per frame with
no ink thrown away.
"""
rng = np.random.default_rng(11)
mask = np.zeros((40, 200), dtype=bool)
for i in range(5):
_particle_frame(mask, 5 + i * 40, 5, 30, 30, rng)
detected, _, _ = segment_sheet(mask)
assert len(detected) == 5
covered = np.zeros_like(mask)
for d in detected:
covered[d.bbox.y:d.bbox.y2, d.bbox.x:d.bbox.x2] = True
assert (mask & ~covered).sum() == 0, "particles were dropped from the result"
def test_separator_grid_finds_phase_shifted_pitch():
"""
A lattice whose gutters sit at 8, 19, 30, ... -- period 11, phase 8.
Searching phase 0 only would report a much coarser (wrong) period.
"""
mask = np.zeros((110, 110), dtype=bool)
for row in range(10):
for col in range(10):
mask[row * 11:row * 11 + 8, col * 11:col * 11 + 8] = True
layout = detect_separator_grid(mask)
assert layout is not None
assert layout.cell_width == 11 and layout.cell_height == 11
assert layout.occupied_cells == 100
def test_frame_with_internal_gap_is_not_halved_by_the_grid():
"""
Frames separated by a wide gutter, each frame containing a 1px internal
gap. That gap offers a cut line which is technically legal (the row
really is empty) but slices every frame in half. The merge-only
regrouping must keep each frame whole regardless.
"""
mask = np.zeros((30, 120), dtype=bool)
for i in range(4):
x = i * 30
mask[5:15, x:x + 10] = True # upper half of the frame
mask[16:25, x:x + 10] = True # lower half, 1px gap at row 15
detected, _, _ = segment_sheet(mask)
assert len(detected) == 4
for d in detected:
assert d.bbox.y == 5 and d.bbox.y2 == 25
def test_shattered_frames_regroup_but_never_split_a_merged_sprite():
"""
The regrouping is merge-only: a sprite the gap stage already joined
across a background gap keeps its identity even when a grid line falls
between its parts.
"""
mask = np.zeros((30, 30), dtype=bool)
mask[5:8, 5:8] = True # leaf
mask[10:12, 6:9] = True # stem, 2px gap -- one sprite
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
assert detected[0].bbox.y == 5
assert detected[0].bbox.y2 == 12
def _tileset_rgba(tile: int, tiles_across: int, rng):
"""Edge-to-edge tileset: no background anywhere, tiles differ sharply."""
size = tile * tiles_across
rgba = np.zeros((size, size, 4), dtype=np.uint8)
rgba[:, :, 3] = 255
for r in range(tiles_across):
for c in range(tiles_across):
colour = rng.integers(0, 256, size=3, dtype=np.uint16).astype(np.uint8)
rgba[r * tile:(r + 1) * tile, c * tile:(c + 1) * tile, :3] = colour
return rgba
def test_tile_grid_recovers_pitch_of_edge_to_edge_tileset():
"""
A tileset drawn corner to corner has no background gutter at all, so
the gap pipeline sees one sheet-sized blob. The tile pitch has to come
from the sheet's own edge periodicity -- and must be the FUNDAMENTAL
pitch, not a multiple of it (16, never 32 or 80).
"""
rng = np.random.default_rng(5)
rgba = _tileset_rgba(16, 10, rng)
mask = np.ones(rgba.shape[:2], dtype=bool)
layout = detect_tile_grid(rgba, mask)
assert layout is not None
assert layout.cell_width == 16 and layout.cell_height == 16
detected, _, _ = segment_sheet(mask, rgba=rgba)
assert len(detected) == 100
def test_tile_grid_needs_pixels_and_is_skipped_without_them():
"""Mask-only callers keep the original behaviour, never a tile grid."""
rng = np.random.default_rng(5)
rgba = _tileset_rgba(16, 10, rng)
mask = np.ones(rgba.shape[:2], dtype=bool)
assert detect_tile_grid(None, mask) is None
detected, _, _ = segment_sheet(mask)
assert len(detected) == 1
def test_animation_sheet_does_not_get_a_tile_grid():
"""
A sheet with a strong pitch on ONE axis only (a horizontal strip) is
not a tileset; requiring both axes to agree is what keeps it out.
"""
rng = np.random.default_rng(9)
rgba = np.zeros((64, 512, 4), dtype=np.uint8)
for i in range(8):
colour = rng.integers(0, 256, size=3, dtype=np.uint16).astype(np.uint8)
rgba[10:54, i * 64 + 8:i * 64 + 56, :3] = colour
rgba[10:54, i * 64 + 8:i * 64 + 56, 3] = 255
mask = rgba[:, :, 3] > 0
assert detect_tile_grid(rgba, mask) is None
detected, _, _ = segment_sheet(mask, rgba=rgba)
assert len(detected) == 8
def test_two_close_sprites_are_not_fused_by_a_grid():
"""
The grid paths must never merge a couple of independent sprites that
the gap pipeline correctly kept apart.
"""
mask = np.zeros((20, 40), dtype=bool)
mask[5:15, 5:15] = True
mask[5:15, 18:28] = True
rgba = np.zeros((20, 40, 4), dtype=np.uint8)
rgba[mask] = (200, 120, 60, 255)
detected, _, _ = segment_sheet(mask, rgba=rgba)
assert len(detected) == 2
def test_grid_backed_sprites_are_not_flagged_low_confidence_for_being_sparse():
"""
A validated grid cut is more trustworthy than the gap heuristic, so a
frame that is legitimately a sparse cloud of particles must not be
pushed below the manual-review threshold just for being sparse.
"""
rng = np.random.default_rng(11)
mask = np.zeros((40, 200), dtype=bool)
for i in range(5):
_particle_frame(mask, 5 + i * 40, 5, 30, 30, rng)
detected, _, _ = segment_sheet(mask)
assert detected and all(d.confidence >= 70 for d in detected)
# ---------------------------------------------------------------------------
# Composite objects spanning several grid cells
# ---------------------------------------------------------------------------
def test_object_spanning_several_cells_becomes_one_sprite():
"""
A ladder drawn down three grid cells is ONE object. Cut per cell it
yields three fragments that mean nothing on their own.
"""
mask = np.zeros((64, 64), dtype=bool)
mask[8:56, 20:28] = True # ladder, 3 cells tall
for (r, c) in [(0, 0), (0, 3), (3, 3)]: # unrelated single tiles
mask[r * 16 + 3:r * 16 + 13, c * 16 + 3:c * 16 + 13] = True
layout = GridLayout(16, 16, 4, 4, 0, 0, 90.0, "tile")
detected = segment_grid(mask, layout)
tall = [d for d in detected if d.bbox.height > 20]
assert len(tall) == 1
assert tall[0].bbox.height == 48
assert tall[0].component_count == 4
assert any("Composite" in r for r in tall[0].confidence_reasons)
# The unrelated tiles are untouched.
assert len(detected) == 4
def test_tileable_terrain_is_not_fused_into_one_object():
"""
A large connected mass of terrain is exactly what the grid cut exists
to break up. It is connected the same way a composite object is, so
only its extent can tell them apart -- it must stay per-cell.
"""
mask = np.zeros((80, 80), dtype=bool)
mask[0:80, 0:80] = True # 5x5 cells of solid, connected floor
layout = GridLayout(16, 16, 5, 5, 0, 0, 90.0, "tile")
detected = segment_grid(mask, layout)
assert len(detected) == 25
assert all(d.component_count == 1 for d in detected)
def test_adjacent_flat_tiles_are_not_merged_by_touching_alone():
"""
Two flat filler tiles touch along their whole shared edge, exactly as a
composite object's halves do. Keying the merge on connected-component
identity plus extent is what keeps them apart from a real object --
here they are part of one big terrain mass.
"""
mask = np.zeros((48, 96), dtype=bool)
mask[:, :] = True
layout = GridLayout(16, 16, 6, 3, 0, 0, 90.0, "tile")
detected = segment_grid(mask, layout)
assert len(detected) == 18
def test_composite_merge_can_be_switched_off():
mask = np.zeros((48, 16), dtype=bool)
mask[4:44, 4:12] = True
layout = GridLayout(16, 16, 1, 3, 0, 0, 90.0, "tile")
assert len(segment_grid(mask, layout)) == 1
assert len(segment_grid(mask, layout, merge_composites=False)) == 3
if __name__ == "__main__":
# Minimal manual runner for environments without pytest installed.
import traceback
tests = [(name, fn) for name, fn in list(globals().items()) if name.startswith("test_") and callable(fn)]
passed, failed = 0, 0
for name, fn in tests:
try:
fn()
print(f"PASS {name}")
passed += 1
except Exception:
print(f"FAIL {name}")
traceback.print_exc()
failed += 1
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
|