trackin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trackin/__init__.py +1 -0
- trackin/_sample_data.py +22 -0
- trackin/_tests/__init__.py +0 -0
- trackin/_tests/test_sample_data.py +7 -0
- trackin/_tests/test_utils.py +169 -0
- trackin/_tests/test_widget.py +377 -0
- trackin/_tests/test_writer.py +7 -0
- trackin/_version.py +24 -0
- trackin/_widget.py +1291 -0
- trackin/_writer.py +65 -0
- trackin/napari.yaml +12 -0
- trackin/shared_state.py +26 -0
- trackin/tracking.py +223 -0
- trackin/utils.py +226 -0
- trackin-0.1.0.dist-info/METADATA +811 -0
- trackin-0.1.0.dist-info/RECORD +20 -0
- trackin-0.1.0.dist-info/WHEEL +5 -0
- trackin-0.1.0.dist-info/entry_points.txt +2 -0
- trackin-0.1.0.dist-info/licenses/LICENSE +675 -0
- trackin-0.1.0.dist-info/top_level.txt +1 -0
trackin/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# __init__.py
|
trackin/_sample_data.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module is an example of a barebones sample data provider for napari.
|
|
3
|
+
|
|
4
|
+
It implements the "sample data" specification.
|
|
5
|
+
see: https://napari.org/stable/plugins/guides.html?#sample-data
|
|
6
|
+
|
|
7
|
+
Replace code below according to your needs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_sample_data():
|
|
16
|
+
"""Generates an image"""
|
|
17
|
+
# Return list of tuples
|
|
18
|
+
# [(data1, add_image_kwargs1), (data2, add_image_kwargs2)]
|
|
19
|
+
# Check the documentation for more information about the
|
|
20
|
+
# add_image_kwargs
|
|
21
|
+
# https://napari.org/stable/api/napari.Viewer.html#napari.Viewer.add_image
|
|
22
|
+
return [(numpy.random.rand(512, 512), {})]
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from trackin.shared_state import shared_state
|
|
4
|
+
from trackin.tracking import generate_graph
|
|
5
|
+
from trackin.utils import accept_track, delete_all_connections, delete_segment, save_segment
|
|
6
|
+
|
|
7
|
+
SHARED_STATE_FIELDS = [
|
|
8
|
+
"DATA",
|
|
9
|
+
"track",
|
|
10
|
+
"csv_folder_to_save",
|
|
11
|
+
"SESSION_FILE",
|
|
12
|
+
"UPDATED_DATA_FILE",
|
|
13
|
+
"N_TRACKS",
|
|
14
|
+
"TRACKED",
|
|
15
|
+
"MAX_TRACK_ID",
|
|
16
|
+
"UPD_TRACK_FILE",
|
|
17
|
+
"NUM_DETS",
|
|
18
|
+
"G",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def staged_shared_state(tmp_path):
|
|
24
|
+
"""Seed shared_state with a minimal two-frame, two-detection scenario
|
|
25
|
+
for accept_track(), and restore the original values afterward -- it's
|
|
26
|
+
a module-level singleton shared across tests."""
|
|
27
|
+
original = {field: getattr(shared_state, field) for field in SHARED_STATE_FIELDS}
|
|
28
|
+
|
|
29
|
+
shared_state.DATA = [[(10, 20, 0, 0)], [(30, 40, 0, 0)]]
|
|
30
|
+
shared_state.track = [0, 0] # accept the single detection in each frame
|
|
31
|
+
shared_state.csv_folder_to_save = str(tmp_path)
|
|
32
|
+
shared_state.SESSION_FILE = "session.csv"
|
|
33
|
+
shared_state.UPDATED_DATA_FILE = "updated.csv"
|
|
34
|
+
shared_state.N_TRACKS = 0
|
|
35
|
+
shared_state.TRACKED = False
|
|
36
|
+
shared_state.MAX_TRACK_ID = None # no prior track file loaded -> skip UPD_TRACK_FILE
|
|
37
|
+
shared_state.G = generate_graph(
|
|
38
|
+
shared_state.DATA, max_score=1600, score_func="squared", tracked=False
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
yield tmp_path
|
|
42
|
+
|
|
43
|
+
for field, value in original.items():
|
|
44
|
+
setattr(shared_state, field, value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_accept_track_writes_session_file_and_marks_detections_consumed(
|
|
48
|
+
staged_shared_state,
|
|
49
|
+
):
|
|
50
|
+
tmp_path = staged_shared_state
|
|
51
|
+
|
|
52
|
+
accept_track()
|
|
53
|
+
|
|
54
|
+
session_lines = (tmp_path / "session.csv").read_text().splitlines()
|
|
55
|
+
assert session_lines[0] == "tframe,y,x,displ_y,displ_x,track_no"
|
|
56
|
+
assert session_lines[1] == "0,10,20,0,0,1"
|
|
57
|
+
assert session_lines[2] == "1,30,40,0,0,1"
|
|
58
|
+
|
|
59
|
+
updated_lines = (tmp_path / "updated.csv").read_text().splitlines()
|
|
60
|
+
assert updated_lines == ["tframe,y,x,displ_y,displ_x"] # both detections consumed
|
|
61
|
+
|
|
62
|
+
assert shared_state.DATA[0][0] == (-1000000, -1000000, 0, 0)
|
|
63
|
+
assert shared_state.DATA[1][0] == (-1000000, -1000000, 0, 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_accept_track_does_not_duplicate_header_when_session_file_has_content(
|
|
67
|
+
staged_shared_state,
|
|
68
|
+
):
|
|
69
|
+
tmp_path = staged_shared_state
|
|
70
|
+
session_path = tmp_path / "session.csv"
|
|
71
|
+
session_path.write_text("tframe,y,x,displ_y,displ_x,track_no\n0,1,2,0,0,1\n")
|
|
72
|
+
|
|
73
|
+
accept_track()
|
|
74
|
+
|
|
75
|
+
session_lines = session_path.read_text().splitlines()
|
|
76
|
+
assert session_lines.count("tframe,y,x,displ_y,displ_x,track_no") == 1
|
|
77
|
+
assert session_lines[0] == "tframe,y,x,displ_y,displ_x,track_no"
|
|
78
|
+
assert session_lines[1] == "0,1,2,0,0,1" # pre-existing row untouched
|
|
79
|
+
assert session_lines[2] == "0,10,20,0,0,1" # newly accepted row appended
|
|
80
|
+
assert session_lines[3] == "1,30,40,0,0,1"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_accept_track_adds_header_when_session_file_exists_but_empty(
|
|
84
|
+
staged_shared_state,
|
|
85
|
+
):
|
|
86
|
+
tmp_path = staged_shared_state
|
|
87
|
+
session_path = tmp_path / "session.csv"
|
|
88
|
+
session_path.touch() # exists on disk, but 0 bytes
|
|
89
|
+
|
|
90
|
+
accept_track()
|
|
91
|
+
|
|
92
|
+
session_lines = session_path.read_text().splitlines()
|
|
93
|
+
assert session_lines[0] == "tframe,y,x,displ_y,displ_x,track_no"
|
|
94
|
+
assert session_lines[1] == "0,10,20,0,0,1"
|
|
95
|
+
assert session_lines[2] == "1,30,40,0,0,1"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_accept_track_writes_upd_track_file_with_offset_track_id(staged_shared_state):
|
|
99
|
+
tmp_path = staged_shared_state
|
|
100
|
+
shared_state.MAX_TRACK_ID = 5
|
|
101
|
+
shared_state.UPD_TRACK_FILE = "upd_tracks.csv"
|
|
102
|
+
|
|
103
|
+
accept_track()
|
|
104
|
+
|
|
105
|
+
# updated_track_id = MAX_TRACK_ID + N_TRACKS = 5 + 1 (accept_track increments
|
|
106
|
+
# N_TRACKS from 0 to 1 before this file is written). Unlike SESSION_FILE, this
|
|
107
|
+
# file has no header -- it's opened in append mode and rows are written as-is.
|
|
108
|
+
upd_track_lines = (tmp_path / "upd_tracks.csv").read_text().splitlines()
|
|
109
|
+
assert upd_track_lines == [
|
|
110
|
+
"0,10,20,0,0,6",
|
|
111
|
+
"1,30,40,0,0,6",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_accept_track_no_op_when_track_is_empty_list(staged_shared_state):
|
|
116
|
+
"""Right after images are reloaded (before a new CSV is loaded),
|
|
117
|
+
shared_state.track is [] rather than None -- accept_track() must treat
|
|
118
|
+
that the same way, not silently create/write into the *previous*
|
|
119
|
+
dataset's SESSION_FILE."""
|
|
120
|
+
tmp_path = staged_shared_state
|
|
121
|
+
shared_state.track = []
|
|
122
|
+
|
|
123
|
+
accept_track()
|
|
124
|
+
|
|
125
|
+
assert not (tmp_path / "session.csv").exists()
|
|
126
|
+
assert shared_state.N_TRACKS == 0 # must not increment on a no-op
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_delete_segment_no_op_when_track_is_empty_list_even_with_stale_num_dets(
|
|
130
|
+
staged_shared_state,
|
|
131
|
+
):
|
|
132
|
+
"""NUM_DETS can still hold a stale nonzero value from the *previous*
|
|
133
|
+
dataset right after images are reloaded -- delete_segment() must not
|
|
134
|
+
rely on NUM_DETS alone, since that would let it through to
|
|
135
|
+
truncate-and-overwrite that dataset's UPDATED_DATA_FILE."""
|
|
136
|
+
tmp_path = staged_shared_state
|
|
137
|
+
shared_state.track = []
|
|
138
|
+
shared_state.NUM_DETS = 5 # stale nonzero value
|
|
139
|
+
|
|
140
|
+
updated_path = tmp_path / "updated.csv"
|
|
141
|
+
updated_path.write_text("tframe,y,x,displ_y,displ_x\n0,99,99,0,0\n")
|
|
142
|
+
|
|
143
|
+
delete_segment()
|
|
144
|
+
|
|
145
|
+
assert updated_path.read_text() == "tframe,y,x,displ_y,displ_x\n0,99,99,0,0\n"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_save_segment_no_op_when_track_is_empty_list(staged_shared_state):
|
|
149
|
+
"""Right after images are reloaded (before a new CSV is loaded),
|
|
150
|
+
shared_state.track is [] and shared_state.G is None. The segment-
|
|
151
|
+
building part is a harmless no-op on an empty track either way, but
|
|
152
|
+
save_segment() unconditionally ended with send_track(), which calls
|
|
153
|
+
.copy() on shared_state.G -- an AttributeError crash when G is None."""
|
|
154
|
+
shared_state.track = []
|
|
155
|
+
shared_state.G = None
|
|
156
|
+
|
|
157
|
+
save_segment() # must not raise
|
|
158
|
+
|
|
159
|
+
assert shared_state.track == [] # send_track() never ran to reassign it
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_delete_all_connections_no_op_when_track_is_empty_list(staged_shared_state):
|
|
163
|
+
"""Right after images are reloaded (before a new CSV is loaded),
|
|
164
|
+
shared_state.track is [] -- delete_all_connections() indexed into it
|
|
165
|
+
with shared_state.track[shared_state.current_index] unconditionally,
|
|
166
|
+
an IndexError crash on an empty list."""
|
|
167
|
+
shared_state.track = []
|
|
168
|
+
|
|
169
|
+
delete_all_connections() # must not raise
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
import trackin._widget as widget
|
|
7
|
+
from trackin._widget import (
|
|
8
|
+
add_white_border,
|
|
9
|
+
check_and_add_displ_cols,
|
|
10
|
+
generate_upd_track_filename,
|
|
11
|
+
write_updated_detections_to_file,
|
|
12
|
+
)
|
|
13
|
+
from trackin.shared_state import shared_state
|
|
14
|
+
from trackin.tracking import generate_graph
|
|
15
|
+
|
|
16
|
+
SHARED_STATE_FIELDS = [
|
|
17
|
+
"DATA",
|
|
18
|
+
"track",
|
|
19
|
+
"track_lines",
|
|
20
|
+
"NUM_DET_PER_FRAME",
|
|
21
|
+
"TRACKED",
|
|
22
|
+
"MAX_TRACK_ID",
|
|
23
|
+
"G",
|
|
24
|
+
"N_TRACKS",
|
|
25
|
+
"SESSION_FILE",
|
|
26
|
+
"UPDATED_DATA_FILE",
|
|
27
|
+
"UPD_TRACK_FILE",
|
|
28
|
+
"NUM_DETS",
|
|
29
|
+
"NUM_CONN",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_check_and_add_displ_cols_adds_missing_columns():
|
|
34
|
+
df = pd.DataFrame({"tframe": [0, 1], "y": [1, 2], "x": [3, 4]})
|
|
35
|
+
result = check_and_add_displ_cols(df)
|
|
36
|
+
assert (result["displ_x"] == 0).all()
|
|
37
|
+
assert (result["displ_y"] == 0).all()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_check_and_add_displ_cols_preserves_existing_columns():
|
|
41
|
+
df = pd.DataFrame(
|
|
42
|
+
{"tframe": [0], "y": [1], "x": [3], "displ_x": [5], "displ_y": [7]}
|
|
43
|
+
)
|
|
44
|
+
result = check_and_add_displ_cols(df)
|
|
45
|
+
assert result["displ_x"].tolist() == [5]
|
|
46
|
+
assert result["displ_y"].tolist() == [7]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_add_white_border_grayscale():
|
|
50
|
+
image = np.zeros((10, 10), dtype=np.uint8)
|
|
51
|
+
bordered = add_white_border(image, border_size=2)
|
|
52
|
+
assert bordered.shape == (14, 14)
|
|
53
|
+
assert (bordered[0, :] == 255).all() # border row is white
|
|
54
|
+
assert (bordered[2:-2, 2:-2] == 0).all() # original data preserved
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_add_white_border_rgb():
|
|
58
|
+
image = np.zeros((10, 10, 3), dtype=np.uint8)
|
|
59
|
+
bordered = add_white_border(image, border_size=2)
|
|
60
|
+
assert bordered.shape == (14, 14, 3)
|
|
61
|
+
assert (bordered[0, :, :] == 255).all()
|
|
62
|
+
assert (bordered[2:-2, 2:-2, :] == 0).all()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_write_updated_detections_to_file(tmp_path):
|
|
66
|
+
data = [
|
|
67
|
+
[(10, 20, 1, -1), (-1000000, -1000000, 0, 0)], # frame 0: one real, one deleted
|
|
68
|
+
[(30, 40, 0, 2)], # frame 1: one real
|
|
69
|
+
]
|
|
70
|
+
write_updated_detections_to_file(data, "out.csv", str(tmp_path))
|
|
71
|
+
|
|
72
|
+
lines = (tmp_path / "out.csv").read_text().splitlines()
|
|
73
|
+
|
|
74
|
+
# Header row
|
|
75
|
+
assert lines[0] == "tframe,y,x,displ_y,displ_x"
|
|
76
|
+
# Deleted detection (-1000000 sentinel) excluded, only the two real ones written
|
|
77
|
+
assert len(lines) == 3
|
|
78
|
+
# Real detections written with correct tframe,y,x,displ_y,displ_x values
|
|
79
|
+
assert lines[1] == "0,10,20,1,-1"
|
|
80
|
+
assert lines[2] == "1,30,40,0,2"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_write_updated_detections_to_file_empty_data_writes_header_only(tmp_path):
|
|
84
|
+
write_updated_detections_to_file([], "out.csv", str(tmp_path))
|
|
85
|
+
|
|
86
|
+
lines = (tmp_path / "out.csv").read_text().splitlines()
|
|
87
|
+
|
|
88
|
+
assert lines == ["tframe,y,x,displ_y,displ_x"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_generate_upd_track_filename_normal_path():
|
|
92
|
+
result = generate_upd_track_filename(
|
|
93
|
+
"/home/user/data/tracks.csv", "20250101_120000"
|
|
94
|
+
)
|
|
95
|
+
assert result == "with_new_tracks_added_tracks_20250101_120000.csv"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_generate_upd_track_filename_windows_style_path():
|
|
99
|
+
result = generate_upd_track_filename(
|
|
100
|
+
"C:\\Users\\test\\my_tracks.csv", "20250101_120000"
|
|
101
|
+
)
|
|
102
|
+
assert result == "with_new_tracks_added_my_tracks_20250101_120000.csv"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_generate_upd_track_filename_multiple_dots_in_filename():
|
|
106
|
+
result = generate_upd_track_filename(
|
|
107
|
+
"/home/user/data/my.tracks.v2.csv", "20250101_120000"
|
|
108
|
+
)
|
|
109
|
+
assert result == "with_new_tracks_added_my.tracks.v2_20250101_120000.csv"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _FakeViewer:
|
|
113
|
+
"""Minimal stand-in for a napari viewer -- clear_detections_and_tracks()
|
|
114
|
+
only needs `layers` to support `in`, it never touches anything else."""
|
|
115
|
+
|
|
116
|
+
def __init__(self):
|
|
117
|
+
self.layers = {}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_clear_detections_and_tracks_resets_shared_state_and_ui():
|
|
121
|
+
original_viewer = widget.viewer
|
|
122
|
+
original_widgets = {
|
|
123
|
+
name: getattr(widget, name)
|
|
124
|
+
for name in (
|
|
125
|
+
"track_file_label",
|
|
126
|
+
"leftover_row",
|
|
127
|
+
"leftover_path_field",
|
|
128
|
+
"session_row",
|
|
129
|
+
"session_path_field",
|
|
130
|
+
"upd_track_row",
|
|
131
|
+
"upd_track_path_field",
|
|
132
|
+
"dets_label",
|
|
133
|
+
"conns_label",
|
|
134
|
+
"session_files_header",
|
|
135
|
+
"session_files_content",
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
original_shared_state = {field: getattr(shared_state, field) for field in SHARED_STATE_FIELDS}
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
widget.viewer = _FakeViewer()
|
|
142
|
+
|
|
143
|
+
widget.track_file_label = widget.ElidedPathLabel()
|
|
144
|
+
widget.track_file_label.set_path("Track file loaded: old_tracks.csv")
|
|
145
|
+
|
|
146
|
+
widget.leftover_row, widget.leftover_path_field = widget.create_path_row(
|
|
147
|
+
"Leftover detections", "..."
|
|
148
|
+
)
|
|
149
|
+
widget.session_row, widget.session_path_field = widget.create_path_row(
|
|
150
|
+
"Tracks accepted this session", "..."
|
|
151
|
+
)
|
|
152
|
+
widget.upd_track_row, widget.upd_track_path_field = widget.create_path_row(
|
|
153
|
+
"Combined tracks", "..."
|
|
154
|
+
)
|
|
155
|
+
for row in (widget.leftover_row, widget.session_row, widget.upd_track_row):
|
|
156
|
+
row.setVisible(True) # simulate them being shown from a prior load
|
|
157
|
+
|
|
158
|
+
widget.dets_label = widget.QLabel("Detections: 42")
|
|
159
|
+
widget.conns_label = widget.QLabel("Connections: 99")
|
|
160
|
+
|
|
161
|
+
widget.session_files_header = widget.QPushButton("")
|
|
162
|
+
widget.session_files_header.setVisible(True) # simulate it showing from a prior load
|
|
163
|
+
widget.session_files_content = widget.QWidget()
|
|
164
|
+
widget.set_session_files_expanded(True) # simulate it being expanded from a prior load
|
|
165
|
+
|
|
166
|
+
# Seed state as if a full session (images, detections, and a loaded
|
|
167
|
+
# track file) had already happened for the *previous* dataset
|
|
168
|
+
shared_state.DATA = [[(1, 2, 0, 0)]]
|
|
169
|
+
shared_state.track = [0]
|
|
170
|
+
shared_state.track_lines = np.array([[[1, 2], [3, 4]]])
|
|
171
|
+
shared_state.NUM_DET_PER_FRAME = [1]
|
|
172
|
+
shared_state.TRACKED = True
|
|
173
|
+
shared_state.MAX_TRACK_ID = 7
|
|
174
|
+
shared_state.G = object() # value doesn't matter, only that it's reset to None
|
|
175
|
+
shared_state.N_TRACKS = 3
|
|
176
|
+
shared_state.SESSION_FILE = "track_session_old_20250101_000000.csv"
|
|
177
|
+
shared_state.UPDATED_DATA_FILE = "upd_old_20250101_000000.csv"
|
|
178
|
+
shared_state.UPD_TRACK_FILE = "with_new_tracks_added_old_20250101_000000.csv"
|
|
179
|
+
shared_state.NUM_DETS = 42
|
|
180
|
+
shared_state.NUM_CONN = 99
|
|
181
|
+
|
|
182
|
+
widget.clear_detections_and_tracks()
|
|
183
|
+
|
|
184
|
+
assert shared_state.DATA == []
|
|
185
|
+
assert shared_state.track == []
|
|
186
|
+
assert shared_state.track_lines is None
|
|
187
|
+
assert shared_state.NUM_DET_PER_FRAME == []
|
|
188
|
+
assert shared_state.TRACKED is False
|
|
189
|
+
assert shared_state.MAX_TRACK_ID is None
|
|
190
|
+
assert shared_state.G is None
|
|
191
|
+
assert shared_state.N_TRACKS == 0
|
|
192
|
+
assert shared_state.SESSION_FILE == ''
|
|
193
|
+
assert shared_state.UPDATED_DATA_FILE == ''
|
|
194
|
+
assert shared_state.UPD_TRACK_FILE == ''
|
|
195
|
+
assert shared_state.NUM_DETS is None
|
|
196
|
+
assert shared_state.NUM_CONN is None
|
|
197
|
+
|
|
198
|
+
assert widget.track_file_label.full_path == ""
|
|
199
|
+
assert widget.leftover_row.isVisible() is False
|
|
200
|
+
assert widget.session_row.isVisible() is False
|
|
201
|
+
assert widget.upd_track_row.isVisible() is False
|
|
202
|
+
|
|
203
|
+
# The Detections/Connections labels only update in response to
|
|
204
|
+
# graph_updated_event -- clear_detections_and_tracks() must fire it
|
|
205
|
+
# itself, or these stay showing the *previous* dataset's stale
|
|
206
|
+
# counts until some unrelated keypress happens to fire it later.
|
|
207
|
+
assert widget.dets_label is None
|
|
208
|
+
assert widget.conns_label is None
|
|
209
|
+
|
|
210
|
+
# The Session Files card should disappear entirely (not just
|
|
211
|
+
# collapse) -- there's nothing left in it to show until a new CSV
|
|
212
|
+
# is loaded.
|
|
213
|
+
assert widget.session_files_header.isVisible() is False
|
|
214
|
+
assert widget.session_files_content.isVisible() is False
|
|
215
|
+
assert widget.session_files_header.text() == "▸ Session Files"
|
|
216
|
+
finally:
|
|
217
|
+
widget.viewer = original_viewer
|
|
218
|
+
for name, value in original_widgets.items():
|
|
219
|
+
setattr(widget, name, value)
|
|
220
|
+
for field, value in original_shared_state.items():
|
|
221
|
+
setattr(shared_state, field, value)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_delete_detection_d_key_no_op_when_track_is_empty_list():
|
|
225
|
+
"""Right after images are reloaded (before a new CSV is loaded),
|
|
226
|
+
shared_state.track is [] -- the 'D' key path indexed into it with
|
|
227
|
+
curr_track[shared_state.current_index] unconditionally, an IndexError
|
|
228
|
+
crash on an empty list."""
|
|
229
|
+
original_track = shared_state.track
|
|
230
|
+
try:
|
|
231
|
+
shared_state.track = []
|
|
232
|
+
widget.delete_detection(None, use_key=True) # must not raise
|
|
233
|
+
finally:
|
|
234
|
+
shared_state.track = original_track
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _FakeLayer:
|
|
238
|
+
"""Minimal stand-in for a napari Points layer -- delete_det_by_key/
|
|
239
|
+
delete_det_by_mouse only need .data (mutable, indexable) and .refresh()."""
|
|
240
|
+
|
|
241
|
+
def __init__(self, data):
|
|
242
|
+
self.data = data
|
|
243
|
+
|
|
244
|
+
def refresh(self):
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_delete_det_by_key_writes_updated_detections_file(tmp_path):
|
|
249
|
+
"""Deleting a detection via the 'D' key marked it as removed in
|
|
250
|
+
shared_state.DATA but never persisted that to UPDATED_DATA_FILE --
|
|
251
|
+
unlike adding a detection, which already did via the same function."""
|
|
252
|
+
original = {
|
|
253
|
+
field: getattr(shared_state, field)
|
|
254
|
+
for field in (
|
|
255
|
+
"current_index", "DATA", "TRACKED", "G", "track",
|
|
256
|
+
"csv_folder_to_save", "UPDATED_DATA_FILE",
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
original_clicked_index = getattr(widget, "clicked_index", None)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
shared_state.current_index = 0
|
|
263
|
+
shared_state.DATA = [[(10, 20, 0, 0), (30, 40, 0, 0)]]
|
|
264
|
+
shared_state.TRACKED = False
|
|
265
|
+
shared_state.track = [-1]
|
|
266
|
+
shared_state.csv_folder_to_save = str(tmp_path)
|
|
267
|
+
shared_state.UPDATED_DATA_FILE = "updated.csv"
|
|
268
|
+
shared_state.G = generate_graph(
|
|
269
|
+
shared_state.DATA, max_score=1600, score_func="squared", tracked=False
|
|
270
|
+
)
|
|
271
|
+
widget.clicked_index = 0
|
|
272
|
+
|
|
273
|
+
layer = _FakeLayer(np.array([[10.0, 20.0], [30.0, 40.0]]))
|
|
274
|
+
widget.delete_det_by_key(layer)
|
|
275
|
+
|
|
276
|
+
lines = (tmp_path / "updated.csv").read_text().splitlines()
|
|
277
|
+
assert lines[0] == "tframe,y,x,displ_y,displ_x"
|
|
278
|
+
assert lines[1] == "0,30,40,0,0" # the deleted detection is excluded
|
|
279
|
+
finally:
|
|
280
|
+
for field, value in original.items():
|
|
281
|
+
setattr(shared_state, field, value)
|
|
282
|
+
widget.clicked_index = original_clicked_index
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_delete_det_by_mouse_writes_updated_detections_file(tmp_path):
|
|
286
|
+
"""Same bug as the 'D' key path, via right-click deletion instead."""
|
|
287
|
+
original = {
|
|
288
|
+
field: getattr(shared_state, field)
|
|
289
|
+
for field in (
|
|
290
|
+
"current_index", "DATA", "TRACKED", "G",
|
|
291
|
+
"csv_folder_to_save", "UPDATED_DATA_FILE",
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
original_clicked_index = getattr(widget, "clicked_index", None)
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
shared_state.current_index = 0
|
|
298
|
+
shared_state.DATA = [[(10, 20, 0, 0), (30, 40, 0, 0)]]
|
|
299
|
+
shared_state.TRACKED = False
|
|
300
|
+
shared_state.csv_folder_to_save = str(tmp_path)
|
|
301
|
+
shared_state.UPDATED_DATA_FILE = "updated.csv"
|
|
302
|
+
shared_state.G = generate_graph(
|
|
303
|
+
shared_state.DATA, max_score=1600, score_func="squared", tracked=False
|
|
304
|
+
)
|
|
305
|
+
widget.clicked_index = 1
|
|
306
|
+
|
|
307
|
+
layer = _FakeLayer(np.array([[10.0, 20.0], [30.0, 40.0]]))
|
|
308
|
+
event = SimpleNamespace(handled=False)
|
|
309
|
+
widget.delete_det_by_mouse(layer, event)
|
|
310
|
+
|
|
311
|
+
lines = (tmp_path / "updated.csv").read_text().splitlines()
|
|
312
|
+
assert lines[0] == "tframe,y,x,displ_y,displ_x"
|
|
313
|
+
assert lines[1] == "0,10,20,0,0" # the deleted detection (index 1) is excluded
|
|
314
|
+
assert event.handled is True
|
|
315
|
+
finally:
|
|
316
|
+
for field, value in original.items():
|
|
317
|
+
setattr(shared_state, field, value)
|
|
318
|
+
widget.clicked_index = original_clicked_index
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_update_file_paths_display_auto_expands_session_files_card(tmp_path):
|
|
322
|
+
"""A newly added file (e.g. from "Add Track File") should be immediately
|
|
323
|
+
visible, not hidden behind a collapsed header the user has to think to
|
|
324
|
+
open -- update_file_paths_display() must expand the card whenever it
|
|
325
|
+
actually updates a row's content."""
|
|
326
|
+
original_csv_loaded = widget.csv_loaded
|
|
327
|
+
original_widgets = {
|
|
328
|
+
name: getattr(widget, name)
|
|
329
|
+
for name in (
|
|
330
|
+
"leftover_row",
|
|
331
|
+
"leftover_path_field",
|
|
332
|
+
"session_row",
|
|
333
|
+
"session_path_field",
|
|
334
|
+
"upd_track_row",
|
|
335
|
+
"upd_track_path_field",
|
|
336
|
+
"session_files_header",
|
|
337
|
+
"session_files_content",
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
original_shared_state = {
|
|
341
|
+
field: getattr(shared_state, field)
|
|
342
|
+
for field in ("csv_folder_to_save", "UPDATED_DATA_FILE", "SESSION_FILE", "MAX_TRACK_ID", "UPD_TRACK_FILE")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
widget.csv_loaded = True
|
|
347
|
+
shared_state.csv_folder_to_save = str(tmp_path)
|
|
348
|
+
shared_state.UPDATED_DATA_FILE = "updated.csv"
|
|
349
|
+
shared_state.SESSION_FILE = "session.csv"
|
|
350
|
+
shared_state.MAX_TRACK_ID = None
|
|
351
|
+
|
|
352
|
+
widget.leftover_row, widget.leftover_path_field = widget.create_path_row(
|
|
353
|
+
"Leftover detections", "..."
|
|
354
|
+
)
|
|
355
|
+
widget.session_row, widget.session_path_field = widget.create_path_row(
|
|
356
|
+
"Tracks accepted this session", "..."
|
|
357
|
+
)
|
|
358
|
+
widget.upd_track_row, widget.upd_track_path_field = widget.create_path_row(
|
|
359
|
+
"Combined tracks", "..."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
widget.session_files_header = widget.QPushButton("")
|
|
363
|
+
widget.session_files_header.setVisible(False) # hidden until a CSV is loaded, as it would be by default
|
|
364
|
+
widget.session_files_content = widget.QWidget()
|
|
365
|
+
widget.set_session_files_expanded(False) # start collapsed, as it would be by default
|
|
366
|
+
|
|
367
|
+
widget.update_file_paths_display()
|
|
368
|
+
|
|
369
|
+
assert widget.session_files_header.isVisible() is True
|
|
370
|
+
assert widget.session_files_content.isVisible() is True
|
|
371
|
+
assert widget.session_files_header.text() == "▾ Session Files"
|
|
372
|
+
finally:
|
|
373
|
+
widget.csv_loaded = original_csv_loaded
|
|
374
|
+
for name, value in original_widgets.items():
|
|
375
|
+
setattr(widget, name, value)
|
|
376
|
+
for field, value in original_shared_state.items():
|
|
377
|
+
setattr(shared_state, field, value)
|
trackin/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|