|
5 | 5 |
|
6 | 6 | from sleap_io import Instance, PredictedInstance, Skeleton, Track, Video |
7 | 7 | from sleap_io.model.labeled_frame import LabeledFrame |
| 8 | +from sleap_io.model.mask import PredictedSegmentationMask, UserSegmentationMask |
8 | 9 |
|
9 | 10 |
|
10 | 11 | def test_labeled_frame(): |
@@ -187,6 +188,74 @@ def test_labeled_frame_unused_predictions(): |
187 | 188 | assert (lf2.unused_predictions[0].numpy() == 1).all() |
188 | 189 |
|
189 | 190 |
|
| 191 | +def test_unused_predicted_masks_none_when_no_predictions(): |
| 192 | + """A frame with no predicted masks reports no unused predictions.""" |
| 193 | + video = Video("test.mp4") |
| 194 | + user = UserSegmentationMask.from_numpy(np.ones((5, 5), dtype=bool)) |
| 195 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[user]) |
| 196 | + assert lf.unused_predicted_masks == [] |
| 197 | + |
| 198 | + |
| 199 | +def test_unused_predicted_masks_unadopted_reported(): |
| 200 | + """A predicted mask with no adopting user mask is reported as unused.""" |
| 201 | + video = Video("test.mp4") |
| 202 | + pred = PredictedSegmentationMask.from_numpy(np.ones((5, 5), dtype=bool), score=0.9) |
| 203 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[pred]) |
| 204 | + assert lf.unused_predicted_masks == [pred] |
| 205 | + |
| 206 | + |
| 207 | +def test_unused_predicted_masks_excludes_linked(): |
| 208 | + """A predicted mask adopted via from_predicted is not reported (link-first).""" |
| 209 | + video = Video("test.mp4") |
| 210 | + pred = PredictedSegmentationMask.from_numpy(np.ones((5, 5), dtype=bool), score=0.9) |
| 211 | + user = pred.to_user() # sets user.from_predicted = pred |
| 212 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[pred, user]) |
| 213 | + assert lf.unused_predicted_masks == [] |
| 214 | + |
| 215 | + |
| 216 | +def test_unused_predicted_masks_link_overrides_distance(): |
| 217 | + """An explicit link counts as adopted even when the masks are far apart.""" |
| 218 | + video = Video("test.mp4") |
| 219 | + pred = PredictedSegmentationMask.from_numpy( |
| 220 | + np.ones((5, 5), dtype=bool), score=0.9, offset=(0.0, 0.0) |
| 221 | + ) |
| 222 | + user = pred.to_user() |
| 223 | + # Move the user mask far away; the from_predicted link should still count. |
| 224 | + user.offset = (500.0, 500.0) |
| 225 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[pred, user]) |
| 226 | + assert lf.unused_predicted_masks == [] |
| 227 | + |
| 228 | + |
| 229 | +def test_unused_predicted_masks_spatial_fallback(): |
| 230 | + """An unlinked user mask overlapping a prediction adopts it spatially.""" |
| 231 | + video = Video("test.mp4") |
| 232 | + pred = PredictedSegmentationMask.from_numpy( |
| 233 | + np.ones((10, 10), dtype=bool), score=0.9, offset=(5.0, 5.0) |
| 234 | + ) |
| 235 | + # Unlinked user mask with an overlapping bbox centroid (within 5 px). |
| 236 | + user = UserSegmentationMask.from_numpy( |
| 237 | + np.ones((10, 10), dtype=bool), offset=(6.0, 6.0) |
| 238 | + ) |
| 239 | + assert user.from_predicted is None |
| 240 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[pred, user]) |
| 241 | + assert lf.unused_predicted_masks == [] |
| 242 | + |
| 243 | + |
| 244 | +def test_unused_predicted_masks_mixed(): |
| 245 | + """Only the prediction without an adopting user mask is reported.""" |
| 246 | + video = Video("test.mp4") |
| 247 | + adopted = PredictedSegmentationMask.from_numpy( |
| 248 | + np.ones((5, 5), dtype=bool), score=0.9, offset=(0.0, 0.0) |
| 249 | + ) |
| 250 | + user = adopted.to_user() |
| 251 | + # A second prediction far from any user mask remains unused. |
| 252 | + orphan = PredictedSegmentationMask.from_numpy( |
| 253 | + np.ones((5, 5), dtype=bool), score=0.8, offset=(500.0, 500.0) |
| 254 | + ) |
| 255 | + lf = LabeledFrame(video=video, frame_idx=0, masks=[adopted, user, orphan]) |
| 256 | + assert lf.unused_predicted_masks == [orphan] |
| 257 | + |
| 258 | + |
190 | 259 | def test_labeled_frame_matches(): |
191 | 260 | """Test LabeledFrame.matches() method.""" |
192 | 261 | video1 = Video(filename="test1.mp4") |
@@ -1469,6 +1538,135 @@ def test_merge_annotations_auto_masks(): |
1469 | 1538 | assert not lf1.masks[0].is_predicted |
1470 | 1539 |
|
1471 | 1540 |
|
| 1541 | +def test_merge_annotations_auto_masks_link_overrides_distance(): |
| 1542 | + """from_predicted link replaces the source prediction despite far distance.""" |
| 1543 | + video = Video(filename="test.mp4", open_backend=False) |
| 1544 | + mask_data = np.ones((10, 10), dtype=bool) |
| 1545 | + # self holds the prediction; other holds a user correction adopted from it |
| 1546 | + # but moved far away (well beyond the 5 px spatial threshold). |
| 1547 | + self_pred = PredictedSegmentationMask.from_numpy( |
| 1548 | + mask_data, score=0.7, offset=(5.0, 5.0) |
| 1549 | + ) |
| 1550 | + other_user = self_pred.to_user() |
| 1551 | + other_user.offset = (500.0, 500.0) |
| 1552 | + |
| 1553 | + lf1 = LabeledFrame(video=video, frame_idx=0, masks=[self_pred]) |
| 1554 | + lf2 = LabeledFrame(video=video, frame_idx=0, masks=[other_user]) |
| 1555 | + |
| 1556 | + lf1._merge_annotations(lf2, strategy="auto") |
| 1557 | + |
| 1558 | + # Spatial matching alone would keep both (too far apart); the link resolves |
| 1559 | + # them as the same annotation and the user correction wins. |
| 1560 | + assert len(lf1.masks) == 1 |
| 1561 | + assert not lf1.masks[0].is_predicted |
| 1562 | + |
| 1563 | + |
| 1564 | +def test_merge_annotations_auto_masks_link_self_side(): |
| 1565 | + """from_predicted link is honored when the user correction lives in self.""" |
| 1566 | + video = Video(filename="test.mp4", open_backend=False) |
| 1567 | + mask_data = np.ones((10, 10), dtype=bool) |
| 1568 | + # other holds the source prediction; self holds the user correction adopted |
| 1569 | + # from it, moved far away (beyond the spatial threshold). |
| 1570 | + other_pred = PredictedSegmentationMask.from_numpy( |
| 1571 | + mask_data, score=0.7, offset=(5.0, 5.0) |
| 1572 | + ) |
| 1573 | + self_user = other_pred.to_user() |
| 1574 | + self_user.offset = (500.0, 500.0) |
| 1575 | + |
| 1576 | + lf1 = LabeledFrame(video=video, frame_idx=0, masks=[self_user]) |
| 1577 | + lf2 = LabeledFrame(video=video, frame_idx=0, masks=[other_pred]) |
| 1578 | + |
| 1579 | + lf1._merge_annotations(lf2, strategy="auto") |
| 1580 | + |
| 1581 | + # The user correction in self is kept and its linked source prediction from |
| 1582 | + # other is dropped, despite the large spatial distance. |
| 1583 | + assert len(lf1.masks) == 1 |
| 1584 | + assert not lf1.masks[0].is_predicted |
| 1585 | + |
| 1586 | + |
| 1587 | +def test_merge_annotations_auto_masks_link_beats_spatial_decoy(): |
| 1588 | + """The link pairs with the true source, not a closer spatial decoy.""" |
| 1589 | + video = Video(filename="test.mp4", open_backend=False) |
| 1590 | + mask_data = np.ones((6, 6), dtype=bool) |
| 1591 | + # True source the user adopted from, placed far from the user mask. |
| 1592 | + true_src = PredictedSegmentationMask.from_numpy( |
| 1593 | + mask_data, score=0.6, offset=(100.0, 100.0) |
| 1594 | + ) |
| 1595 | + # A decoy prediction sitting right on top of the user mask. |
| 1596 | + decoy = PredictedSegmentationMask.from_numpy( |
| 1597 | + mask_data, score=0.9, offset=(6.0, 6.0) |
| 1598 | + ) |
| 1599 | + user = true_src.to_user() |
| 1600 | + user.offset = (5.0, 5.0) # spatially nearest to `decoy` |
| 1601 | + |
| 1602 | + lf1 = LabeledFrame(video=video, frame_idx=0, masks=[true_src, decoy]) |
| 1603 | + lf2 = LabeledFrame(video=video, frame_idx=0, masks=[user]) |
| 1604 | + |
| 1605 | + lf1._merge_annotations(lf2, strategy="auto") |
| 1606 | + |
| 1607 | + # The user replaces its linked true source; the decoy stays as a prediction. |
| 1608 | + assert sum(not m.is_predicted for m in lf1.masks) == 1 |
| 1609 | + remaining_pred = [m for m in lf1.masks if m.is_predicted] |
| 1610 | + assert remaining_pred == [decoy] |
| 1611 | + |
| 1612 | + |
| 1613 | +def test_merge_annotations_auto_masks_link_multiple_pairs(): |
| 1614 | + """Independent from_predicted links resolve in both directions in one merge.""" |
| 1615 | + video = Video(filename="test.mp4", open_backend=False) |
| 1616 | + mask_data = np.ones((8, 8), dtype=bool) |
| 1617 | + # Two source predictions, each adopted by a user correction in the *other* |
| 1618 | + # frame, with every mask placed far apart so only the links can pair them. |
| 1619 | + self_pred = PredictedSegmentationMask.from_numpy( |
| 1620 | + mask_data, score=0.5, offset=(200.0, 200.0) |
| 1621 | + ) |
| 1622 | + other_pred = PredictedSegmentationMask.from_numpy( |
| 1623 | + mask_data, score=0.6, offset=(600.0, 600.0) |
| 1624 | + ) |
| 1625 | + self_user = other_pred.to_user() # self user adopted from other's prediction |
| 1626 | + self_user.offset = (10.0, 10.0) |
| 1627 | + other_user = self_pred.to_user() # other user adopted from self's prediction |
| 1628 | + other_user.offset = (400.0, 400.0) |
| 1629 | + |
| 1630 | + lf1 = LabeledFrame(video=video, frame_idx=0, masks=[self_user, self_pred]) |
| 1631 | + lf2 = LabeledFrame(video=video, frame_idx=0, masks=[other_user, other_pred]) |
| 1632 | + |
| 1633 | + lf1._merge_annotations(lf2, strategy="auto") |
| 1634 | + |
| 1635 | + # Both predictions are superseded by their linked corrections; only the two |
| 1636 | + # user masks remain. |
| 1637 | + assert len(lf1.masks) == 2 |
| 1638 | + assert all(not m.is_predicted for m in lf1.masks) |
| 1639 | + |
| 1640 | + |
| 1641 | +def test_merge_annotations_auto_masks_link_source_absent(): |
| 1642 | + """A from_predicted link to a prediction absent from the merge falls back. |
| 1643 | +
|
| 1644 | + When the linked source is not present in the opposing frame, no link match is |
| 1645 | + produced (the link cannot be honored) and matching falls back to spatial |
| 1646 | + behavior. |
| 1647 | + """ |
| 1648 | + video = Video(filename="test.mp4", open_backend=False) |
| 1649 | + mask_data = np.ones((8, 8), dtype=bool) |
| 1650 | + external = PredictedSegmentationMask.from_numpy(mask_data, score=0.5) |
| 1651 | + |
| 1652 | + # self's user links to `external` (not in other); other's user links to |
| 1653 | + # `external` too (not in self). Neither link can resolve to the opposing |
| 1654 | + # frame, and the two user masks are far apart. |
| 1655 | + self_user = external.to_user() |
| 1656 | + self_user.offset = (10.0, 10.0) |
| 1657 | + other_user = external.to_user() |
| 1658 | + other_user.offset = (900.0, 900.0) |
| 1659 | + |
| 1660 | + lf1 = LabeledFrame(video=video, frame_idx=0, masks=[self_user]) |
| 1661 | + lf2 = LabeledFrame(video=video, frame_idx=0, masks=[other_user]) |
| 1662 | + |
| 1663 | + lf1._merge_annotations(lf2, strategy="auto") |
| 1664 | + |
| 1665 | + # Unresolvable links + far apart → both user masks are kept. |
| 1666 | + assert len(lf1.masks) == 2 |
| 1667 | + assert all(not m.is_predicted for m in lf1.masks) |
| 1668 | + |
| 1669 | + |
1472 | 1670 | def test_merge_annotations_update_tracks_cascades(): |
1473 | 1671 | """Update_tracks updates annotation tracks from spatially matched other.""" |
1474 | 1672 | from sleap_io.model.centroid import UserCentroid |
|
0 commit comments