Spaces:
Paused
Paused
Download tests/test_segmentation.py from Cnass/sprite: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/Cnass/sprite/resolve/main/tests/test_segmentation.py
- Command line
-
hf download hf://spaces/Cnass/sprite/tests/test_segmentation.py
-
curl -L -o test_segmentation.py https://huggingface.co/spaces/Cnass/sprite/resolve/main/tests/test_segmentation.py
14.6 kB
| """ | |
| 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) | |