Cnass commited on
Commit
d6c386c
·
verified ·
1 Parent(s): db30e58

Update tests/test_segmentation.py

Browse files
Files changed (1) hide show
  1. tests/test_segmentation.py +174 -1
tests/test_segmentation.py CHANGED
@@ -12,7 +12,11 @@ import numpy as np
12
 
13
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
 
15
- from segmentation import segment_sheet
 
 
 
 
16
 
17
 
18
  def test_single_sprite():
@@ -147,6 +151,175 @@ def test_low_confidence_flagged_for_oversized_region():
147
  assert detected[0].confidence < 100
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  if __name__ == "__main__":
151
  # Minimal manual runner for environments without pytest installed.
152
  import traceback
 
12
 
13
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
 
15
+ from segmentation import (
16
+ detect_separator_grid,
17
+ detect_tile_grid,
18
+ segment_sheet,
19
+ )
20
 
21
 
22
  def test_single_sprite():
 
151
  assert detected[0].confidence < 100
152
 
153
 
154
+ # ---------------------------------------------------------------------------
155
+ # Grid-corrected segmentation: animation frames and edge-to-edge tilesets
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def _particle_frame(mask, x0, y0, w, h, rng):
159
+ """A frame drawn as scattered 1px particles, like an explosion's debris."""
160
+ for _ in range(24):
161
+ mask[y0 + rng.integers(0, h), x0 + rng.integers(0, w)] = True
162
+ # anchor the frame's extent so its bbox is predictable
163
+ mask[y0, x0] = True
164
+ mask[y0 + h - 1, x0 + w - 1] = True
165
+
166
+
167
+ def test_particle_animation_frames_are_not_lost_as_noise():
168
+ """
169
+ An animation strip whose frames are clouds of loose 1-2px particles.
170
+ Every one of those particles is individually below MIN_SPRITE_SIZE_PX,
171
+ so the gap pipeline drops them and whole frames vanish. The frame
172
+ gutters must be used instead, giving exactly one sprite per frame with
173
+ no ink thrown away.
174
+ """
175
+ rng = np.random.default_rng(11)
176
+ mask = np.zeros((40, 200), dtype=bool)
177
+ for i in range(5):
178
+ _particle_frame(mask, 5 + i * 40, 5, 30, 30, rng)
179
+
180
+ detected, _, _ = segment_sheet(mask)
181
+ assert len(detected) == 5
182
+
183
+ covered = np.zeros_like(mask)
184
+ for d in detected:
185
+ covered[d.bbox.y:d.bbox.y2, d.bbox.x:d.bbox.x2] = True
186
+ assert (mask & ~covered).sum() == 0, "particles were dropped from the result"
187
+
188
+
189
+ def test_separator_grid_finds_phase_shifted_pitch():
190
+ """
191
+ A lattice whose gutters sit at 8, 19, 30, ... -- period 11, phase 8.
192
+ Searching phase 0 only would report a much coarser (wrong) period.
193
+ """
194
+ mask = np.zeros((110, 110), dtype=bool)
195
+ for row in range(10):
196
+ for col in range(10):
197
+ mask[row * 11:row * 11 + 8, col * 11:col * 11 + 8] = True
198
+ layout = detect_separator_grid(mask)
199
+ assert layout is not None
200
+ assert layout.cell_width == 11 and layout.cell_height == 11
201
+ assert layout.occupied_cells == 100
202
+
203
+
204
+ def test_frame_with_internal_gap_is_not_halved_by_the_grid():
205
+ """
206
+ Frames separated by a wide gutter, each frame containing a 1px internal
207
+ gap. That gap offers a cut line which is technically legal (the row
208
+ really is empty) but slices every frame in half. The merge-only
209
+ regrouping must keep each frame whole regardless.
210
+ """
211
+ mask = np.zeros((30, 120), dtype=bool)
212
+ for i in range(4):
213
+ x = i * 30
214
+ mask[5:15, x:x + 10] = True # upper half of the frame
215
+ mask[16:25, x:x + 10] = True # lower half, 1px gap at row 15
216
+ detected, _, _ = segment_sheet(mask)
217
+ assert len(detected) == 4
218
+ for d in detected:
219
+ assert d.bbox.y == 5 and d.bbox.y2 == 25
220
+
221
+
222
+ def test_shattered_frames_regroup_but_never_split_a_merged_sprite():
223
+ """
224
+ The regrouping is merge-only: a sprite the gap stage already joined
225
+ across a background gap keeps its identity even when a grid line falls
226
+ between its parts.
227
+ """
228
+ mask = np.zeros((30, 30), dtype=bool)
229
+ mask[5:8, 5:8] = True # leaf
230
+ mask[10:12, 6:9] = True # stem, 2px gap -- one sprite
231
+ detected, _, _ = segment_sheet(mask)
232
+ assert len(detected) == 1
233
+ assert detected[0].bbox.y == 5
234
+ assert detected[0].bbox.y2 == 12
235
+
236
+
237
+ def _tileset_rgba(tile: int, tiles_across: int, rng):
238
+ """Edge-to-edge tileset: no background anywhere, tiles differ sharply."""
239
+ size = tile * tiles_across
240
+ rgba = np.zeros((size, size, 4), dtype=np.uint8)
241
+ rgba[:, :, 3] = 255
242
+ for r in range(tiles_across):
243
+ for c in range(tiles_across):
244
+ colour = rng.integers(0, 256, size=3, dtype=np.uint16).astype(np.uint8)
245
+ rgba[r * tile:(r + 1) * tile, c * tile:(c + 1) * tile, :3] = colour
246
+ return rgba
247
+
248
+
249
+ def test_tile_grid_recovers_pitch_of_edge_to_edge_tileset():
250
+ """
251
+ A tileset drawn corner to corner has no background gutter at all, so
252
+ the gap pipeline sees one sheet-sized blob. The tile pitch has to come
253
+ from the sheet's own edge periodicity -- and must be the FUNDAMENTAL
254
+ pitch, not a multiple of it (16, never 32 or 80).
255
+ """
256
+ rng = np.random.default_rng(5)
257
+ rgba = _tileset_rgba(16, 10, rng)
258
+ mask = np.ones(rgba.shape[:2], dtype=bool)
259
+
260
+ layout = detect_tile_grid(rgba, mask)
261
+ assert layout is not None
262
+ assert layout.cell_width == 16 and layout.cell_height == 16
263
+
264
+ detected, _, _ = segment_sheet(mask, rgba=rgba)
265
+ assert len(detected) == 100
266
+
267
+
268
+ def test_tile_grid_needs_pixels_and_is_skipped_without_them():
269
+ """Mask-only callers keep the original behaviour, never a tile grid."""
270
+ rng = np.random.default_rng(5)
271
+ rgba = _tileset_rgba(16, 10, rng)
272
+ mask = np.ones(rgba.shape[:2], dtype=bool)
273
+ assert detect_tile_grid(None, mask) is None
274
+ detected, _, _ = segment_sheet(mask)
275
+ assert len(detected) == 1
276
+
277
+
278
+ def test_animation_sheet_does_not_get_a_tile_grid():
279
+ """
280
+ A sheet with a strong pitch on ONE axis only (a horizontal strip) is
281
+ not a tileset; requiring both axes to agree is what keeps it out.
282
+ """
283
+ rng = np.random.default_rng(9)
284
+ rgba = np.zeros((64, 512, 4), dtype=np.uint8)
285
+ for i in range(8):
286
+ colour = rng.integers(0, 256, size=3, dtype=np.uint16).astype(np.uint8)
287
+ rgba[10:54, i * 64 + 8:i * 64 + 56, :3] = colour
288
+ rgba[10:54, i * 64 + 8:i * 64 + 56, 3] = 255
289
+ mask = rgba[:, :, 3] > 0
290
+ assert detect_tile_grid(rgba, mask) is None
291
+ detected, _, _ = segment_sheet(mask, rgba=rgba)
292
+ assert len(detected) == 8
293
+
294
+
295
+ def test_two_close_sprites_are_not_fused_by_a_grid():
296
+ """
297
+ The grid paths must never merge a couple of independent sprites that
298
+ the gap pipeline correctly kept apart.
299
+ """
300
+ mask = np.zeros((20, 40), dtype=bool)
301
+ mask[5:15, 5:15] = True
302
+ mask[5:15, 18:28] = True
303
+ rgba = np.zeros((20, 40, 4), dtype=np.uint8)
304
+ rgba[mask] = (200, 120, 60, 255)
305
+ detected, _, _ = segment_sheet(mask, rgba=rgba)
306
+ assert len(detected) == 2
307
+
308
+
309
+ def test_grid_backed_sprites_are_not_flagged_low_confidence_for_being_sparse():
310
+ """
311
+ A validated grid cut is more trustworthy than the gap heuristic, so a
312
+ frame that is legitimately a sparse cloud of particles must not be
313
+ pushed below the manual-review threshold just for being sparse.
314
+ """
315
+ rng = np.random.default_rng(11)
316
+ mask = np.zeros((40, 200), dtype=bool)
317
+ for i in range(5):
318
+ _particle_frame(mask, 5 + i * 40, 5, 30, 30, rng)
319
+ detected, _, _ = segment_sheet(mask)
320
+ assert detected and all(d.confidence >= 70 for d in detected)
321
+
322
+
323
  if __name__ == "__main__":
324
  # Minimal manual runner for environments without pytest installed.
325
  import traceback