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)