accelerometry-annotator 3.2.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.
- accelerometry_annotator-3.2.0.dist-info/METADATA +218 -0
- accelerometry_annotator-3.2.0.dist-info/RECORD +21 -0
- accelerometry_annotator-3.2.0.dist-info/WHEEL +5 -0
- accelerometry_annotator-3.2.0.dist-info/licenses/LICENSE +22 -0
- accelerometry_annotator-3.2.0.dist-info/top_level.txt +1 -0
- visualize_accelerometry/__init__.py +1 -0
- visualize_accelerometry/app.py +1193 -0
- visualize_accelerometry/callbacks.py +631 -0
- visualize_accelerometry/config.py +79 -0
- visualize_accelerometry/data_loading.py +243 -0
- visualize_accelerometry/js/download.js +32 -0
- visualize_accelerometry/plotting.py +239 -0
- visualize_accelerometry/state.py +164 -0
- visualize_accelerometry/static/favicon.ico +0 -0
- visualize_accelerometry/static/favicon.svg +21 -0
- visualize_accelerometry/static/logo-dark.svg +41 -0
- visualize_accelerometry/static/logo.jpg +0 -0
- visualize_accelerometry/static/logo.svg +41 -0
- visualize_accelerometry/templates/index.html +26 -0
- visualize_accelerometry/templates/login.html +159 -0
- visualize_accelerometry/templates/logout.html +124 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI callback logic for annotation management.
|
|
3
|
+
|
|
4
|
+
Contains the ``CallbackManager`` class that wires widget events to
|
|
5
|
+
state mutations + view updates, plus helper functions for creating
|
|
6
|
+
annotation rows and building summary HTML.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import bokeh.plotting as bp
|
|
14
|
+
|
|
15
|
+
from .config import DISPLAYED_ANNOTATION_COLUMNS, TIME_FMT
|
|
16
|
+
from .data_loading import save_annotations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def capture_new_annotation(start_ts, end_ts, artifact, fname, uname):
|
|
20
|
+
"""Create a single-row DataFrame representing a new annotation.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
start_ts, end_ts : Timestamp
|
|
25
|
+
Time bounds of the annotated segment.
|
|
26
|
+
artifact : str
|
|
27
|
+
Activity type (e.g. ``"chair_stand"``, ``"tug"``).
|
|
28
|
+
fname : str
|
|
29
|
+
Path to the signal file (basename is extracted).
|
|
30
|
+
uname : str
|
|
31
|
+
Username of the annotator.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
DataFrame
|
|
36
|
+
One-row DataFrame matching ``ANNOTATION_COLUMNS``.
|
|
37
|
+
"""
|
|
38
|
+
pdf = pd.DataFrame(
|
|
39
|
+
{
|
|
40
|
+
"fname": os.path.basename(fname),
|
|
41
|
+
"artifact": artifact,
|
|
42
|
+
"segment": 0,
|
|
43
|
+
"scoring": 0,
|
|
44
|
+
"review": 0,
|
|
45
|
+
"start_epoch": pd.to_datetime(start_ts).timestamp(),
|
|
46
|
+
"end_epoch": pd.to_datetime(end_ts).timestamp(),
|
|
47
|
+
"start_time": str(start_ts),
|
|
48
|
+
"end_time": str(end_ts),
|
|
49
|
+
"annotated_at": str(datetime.now()),
|
|
50
|
+
"user": uname,
|
|
51
|
+
"notes": "",
|
|
52
|
+
},
|
|
53
|
+
index=[0],
|
|
54
|
+
)
|
|
55
|
+
return pdf
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_summary_html(state):
|
|
59
|
+
"""Build an HTML summary table for all annotations on the current file.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
state : AppState
|
|
64
|
+
Current session state.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
str
|
|
69
|
+
HTML string for the summary pane.
|
|
70
|
+
"""
|
|
71
|
+
pdf_annotations = state.pdf_annotations
|
|
72
|
+
fname = state.fname
|
|
73
|
+
file_start = state.file_start_timestamp
|
|
74
|
+
file_end = state.file_end_timestamp
|
|
75
|
+
|
|
76
|
+
artifacts = ""
|
|
77
|
+
notes = ""
|
|
78
|
+
reviews = ""
|
|
79
|
+
|
|
80
|
+
if pdf_annotations.shape[0] > 0:
|
|
81
|
+
pdf_sel = pdf_annotations.loc[
|
|
82
|
+
pdf_annotations["fname"] == os.path.basename(fname)
|
|
83
|
+
].reset_index(drop=True)
|
|
84
|
+
|
|
85
|
+
if pdf_sel.shape[0] > 0:
|
|
86
|
+
# Filter out rows with NaT timestamps (review-only flags)
|
|
87
|
+
# before calling dt.strftime, which would raise on NaT.
|
|
88
|
+
has_time = pdf_sel["start_time"].notna() & pdf_sel["end_time"].notna()
|
|
89
|
+
pdf_timed = pdf_sel.loc[has_time].copy()
|
|
90
|
+
|
|
91
|
+
if pdf_timed.shape[0] > 0:
|
|
92
|
+
pdf_timed = pdf_timed.assign(
|
|
93
|
+
**{
|
|
94
|
+
col: pdf_timed[col].dt.strftime("%d-%m %H:%M:%S")
|
|
95
|
+
for col in ["start_time", "end_time"]
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
pdf_timed = pdf_timed.assign(
|
|
99
|
+
annotations_txt=pdf_timed.apply(
|
|
100
|
+
lambda x: f"{x['start_time']} - {x['end_time']} ({x['user']})",
|
|
101
|
+
axis=1,
|
|
102
|
+
),
|
|
103
|
+
notes_txt=pdf_timed.apply(
|
|
104
|
+
lambda x: f"{x['notes']} ({x['user']})", axis=1
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
dct_artifacts = {
|
|
109
|
+
artifact: "<br/>".join(
|
|
110
|
+
pdf_timed.loc[
|
|
111
|
+
(pdf_timed["artifact"] == artifact)
|
|
112
|
+
& (pdf_timed["scoring"] == 0)
|
|
113
|
+
& (pdf_timed["segment"] == 0)
|
|
114
|
+
]["annotations_txt"].tolist()
|
|
115
|
+
)
|
|
116
|
+
for artifact in ["chair_stand", "6min_walk", "3m_walk", "tug"]
|
|
117
|
+
}
|
|
118
|
+
dct_artifacts = {k: v for k, v in dct_artifacts.items() if v}
|
|
119
|
+
|
|
120
|
+
artifacts = (
|
|
121
|
+
"<table cellpadding='2'>"
|
|
122
|
+
+ "<tr>"
|
|
123
|
+
+ "".join(f"<td><b>{a}</b></td>" for a in dct_artifacts)
|
|
124
|
+
+ "</tr><tr>"
|
|
125
|
+
+ "".join(f"<td>{dct_artifacts[a]}</td>" for a in dct_artifacts)
|
|
126
|
+
+ "</tr></table>"
|
|
127
|
+
)
|
|
128
|
+
notes = "<br/>".join(
|
|
129
|
+
pdf_timed.loc[pdf_timed["notes"].fillna("").str.strip() != ""][
|
|
130
|
+
"notes_txt"
|
|
131
|
+
].tolist()
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Reviews can exist without time ranges, so use the full pdf_sel
|
|
135
|
+
pdf_reviews = pdf_sel.loc[pdf_sel["review"] == 1].drop_duplicates(
|
|
136
|
+
subset=["user", "artifact"]
|
|
137
|
+
)
|
|
138
|
+
if pdf_reviews.shape[0] > 0:
|
|
139
|
+
pdf_reviews = (
|
|
140
|
+
pdf_reviews.groupby("artifact")["user"]
|
|
141
|
+
.apply(lambda x: ",".join(x))
|
|
142
|
+
.reset_index()
|
|
143
|
+
)
|
|
144
|
+
pdf_reviews = pdf_reviews.assign(
|
|
145
|
+
review_txt=pdf_reviews.apply(
|
|
146
|
+
lambda x: f"{x['artifact']} : {x['user']}", axis=1
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
reviews = "<br/>".join(pdf_reviews["review_txt"].tolist())
|
|
150
|
+
|
|
151
|
+
# Guard against None timestamps before first file load
|
|
152
|
+
start_str = (
|
|
153
|
+
pd.to_datetime(file_start).strftime("%d-%m-%Y %H:%M:%S")
|
|
154
|
+
if file_start else "N/A"
|
|
155
|
+
)
|
|
156
|
+
end_str = (
|
|
157
|
+
pd.to_datetime(file_end).strftime("%d-%m-%Y %H:%M:%S")
|
|
158
|
+
if file_end else "N/A"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return f"""
|
|
162
|
+
<table style="width:100%; border-collapse:collapse; font-size:12px;
|
|
163
|
+
margin-top:8px; font-family:'Montserrat',Helvetica,Arial,sans-serif;">
|
|
164
|
+
<tr style="background-color:#58595b;">
|
|
165
|
+
<th style="padding:5px 10px; color:#fff; font-size:11px; text-align:left;">Start Time</th>
|
|
166
|
+
<th style="padding:5px 10px; color:#fff; font-size:11px; text-align:left;">End Time</th>
|
|
167
|
+
<th style="padding:5px 10px; color:#fff; font-size:11px; text-align:left;">Annotations</th>
|
|
168
|
+
<th style="padding:5px 10px; color:#fff; font-size:11px; text-align:left;">Notes</th>
|
|
169
|
+
<th style="padding:5px 10px; color:#fff; font-size:11px; text-align:left;">Reviews</th>
|
|
170
|
+
</tr>
|
|
171
|
+
<tr>
|
|
172
|
+
<td style="padding:5px 10px; border-bottom:1px solid #e0e0e0; font-size:11px;">{start_str}</td>
|
|
173
|
+
<td style="padding:5px 10px; border-bottom:1px solid #e0e0e0; font-size:11px;">{end_str}</td>
|
|
174
|
+
<td style="padding:5px 10px; border-bottom:1px solid #e0e0e0; font-size:11px;">{artifacts}</td>
|
|
175
|
+
<td style="padding:5px 10px; border-bottom:1px solid #e0e0e0; font-size:11px;">{notes}</td>
|
|
176
|
+
<td style="padding:5px 10px; border-bottom:1px solid #e0e0e0; font-size:11px;">{reviews}</td>
|
|
177
|
+
</tr>
|
|
178
|
+
</table>
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _filter_annotations_in_range(pdf_annotations, start_ts, end_ts, uname, fname):
|
|
183
|
+
"""Return a boolean mask of annotations within a time range for one user/file.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
pdf_annotations : DataFrame
|
|
188
|
+
Full annotations DataFrame.
|
|
189
|
+
start_ts, end_ts : Timestamp
|
|
190
|
+
Time bounds of the selection.
|
|
191
|
+
uname : str
|
|
192
|
+
Filter to this user.
|
|
193
|
+
fname : str
|
|
194
|
+
Filter to this file (basename is extracted).
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
Series[bool]
|
|
199
|
+
Mask aligned with *pdf_annotations*.
|
|
200
|
+
"""
|
|
201
|
+
annot_start = pd.to_datetime(pdf_annotations["start_time"], errors="coerce")
|
|
202
|
+
annot_end = pd.to_datetime(pdf_annotations["end_time"], errors="coerce")
|
|
203
|
+
mask = (
|
|
204
|
+
annot_start.between(start_ts, end_ts, inclusive="both")
|
|
205
|
+
& annot_end.between(start_ts, end_ts, inclusive="both")
|
|
206
|
+
& (pdf_annotations["user"] == uname)
|
|
207
|
+
& (pdf_annotations["fname"] == os.path.basename(fname))
|
|
208
|
+
)
|
|
209
|
+
return mask
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CallbackManager:
|
|
213
|
+
"""Orchestrates UI callbacks between widget events and AppState.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
state : AppState
|
|
218
|
+
Per-session state object.
|
|
219
|
+
widgets : dict
|
|
220
|
+
Name-to-widget mapping populated by ``app.py``. Must include
|
|
221
|
+
keys for all buttons, labels, and layout containers.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, state, widgets):
|
|
225
|
+
self.state = state
|
|
226
|
+
self.w = widgets
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Plot lifecycle
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _notify(self, msg, duration=3000, kind="info"):
|
|
233
|
+
"""Show a toast notification in the bottom-right corner."""
|
|
234
|
+
import panel as pn
|
|
235
|
+
getattr(pn.state.notifications, kind)(msg, duration=duration)
|
|
236
|
+
|
|
237
|
+
def update_plot(self):
|
|
238
|
+
"""Load data for the current file/anchor and rebuild the plot.
|
|
239
|
+
|
|
240
|
+
If the file is empty or unreadable, shows a notification and
|
|
241
|
+
advances to the next file in the dropdown.
|
|
242
|
+
"""
|
|
243
|
+
self._notify("Building plot\u2026", duration=2000)
|
|
244
|
+
basename = os.path.splitext(os.path.basename(self.state.fname))[0]
|
|
245
|
+
# Find the file picker entry (e.g. "alan--060294-20221208125829")
|
|
246
|
+
# which includes the assigned user prefix
|
|
247
|
+
label = basename
|
|
248
|
+
for entry in self.state.lst_fnames:
|
|
249
|
+
if entry.endswith(basename):
|
|
250
|
+
label = entry
|
|
251
|
+
break
|
|
252
|
+
self.w["file_label"].object = f"### Annotating: {label}"
|
|
253
|
+
try:
|
|
254
|
+
pdf = self.state.load_file_data()
|
|
255
|
+
except Exception as ex:
|
|
256
|
+
pdf = None
|
|
257
|
+
print(f"Error loading file {self.state.fname}: {ex}")
|
|
258
|
+
|
|
259
|
+
if pdf is None or len(pdf) == 0:
|
|
260
|
+
self._handle_empty_file()
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
self._refresh_plot(pdf)
|
|
264
|
+
self.state.selection_bounds = None
|
|
265
|
+
self.update_annotations()
|
|
266
|
+
self._update_nav_buttons()
|
|
267
|
+
|
|
268
|
+
def _handle_empty_file(self):
|
|
269
|
+
"""Show a notification and skip to the next file when data is empty."""
|
|
270
|
+
import panel as pn
|
|
271
|
+
|
|
272
|
+
basename = os.path.basename(self.state.fname)
|
|
273
|
+
pn.state.notifications.warning(
|
|
274
|
+
f"File '{basename}' is empty or could not be loaded. Skipping to next file.",
|
|
275
|
+
duration=5000,
|
|
276
|
+
)
|
|
277
|
+
# Find the next file in the dropdown list
|
|
278
|
+
current_fnames = self.state.lst_fnames
|
|
279
|
+
current_basename = basename
|
|
280
|
+
# Find which entries match this file (ignoring user prefix)
|
|
281
|
+
current_idx = None
|
|
282
|
+
for i, fn in enumerate(current_fnames):
|
|
283
|
+
if fn.split("--")[1] == os.path.splitext(current_basename)[0]:
|
|
284
|
+
current_idx = i
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
if current_idx is not None and current_idx + 1 < len(current_fnames):
|
|
288
|
+
next_fname = current_fnames[current_idx + 1]
|
|
289
|
+
elif len(current_fnames) > 0:
|
|
290
|
+
# Wrap around to the first file
|
|
291
|
+
next_fname = current_fnames[0]
|
|
292
|
+
else:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
self.plot_new_file(next_fname)
|
|
296
|
+
|
|
297
|
+
def _refresh_plot(self, pdf):
|
|
298
|
+
"""Rebuild Bokeh figures with new signal data.
|
|
299
|
+
|
|
300
|
+
Swaps panes in the stable ``main_content`` Column by index so
|
|
301
|
+
that the Panel layout reference stays valid across rebuilds.
|
|
302
|
+
Re-wires the box-select callback on the new signal CDS.
|
|
303
|
+
"""
|
|
304
|
+
from .plotting import make_plot
|
|
305
|
+
main_pane, range_pane, main_fig, signal_cds = make_plot(
|
|
306
|
+
pdf, self.state.annotation_cds
|
|
307
|
+
)
|
|
308
|
+
# Swap panes by index in the stable parent Column
|
|
309
|
+
container = self.w["main_content"]
|
|
310
|
+
container[self.w["main_plot_idx"]] = main_pane
|
|
311
|
+
container[self.w["range_plot_idx"]] = range_pane
|
|
312
|
+
self.w["main_plot"] = main_pane
|
|
313
|
+
self.w["range_plot"] = range_pane
|
|
314
|
+
self.w["main_fig"] = main_fig
|
|
315
|
+
self.state.signal_cds = signal_cds
|
|
316
|
+
# Re-attach the selection callback to the new CDS
|
|
317
|
+
if self.w.get("_selection_wire_fn"):
|
|
318
|
+
signal_cds.selected.on_change("indices", self.w["_selection_wire_fn"])
|
|
319
|
+
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
# Annotation overlays (no plot rebuild — just CDS data updates)
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def update_annotations(self):
|
|
325
|
+
"""Sync annotation overlay CDS data and refresh selection state."""
|
|
326
|
+
self.state.update_annotation_sources()
|
|
327
|
+
self.update_selection()
|
|
328
|
+
|
|
329
|
+
def update_selection(self):
|
|
330
|
+
"""Update button states and selection tables based on current bounds.
|
|
331
|
+
|
|
332
|
+
Enables/disables annotation buttons depending on whether a region
|
|
333
|
+
is selected and whether existing annotations fall within it.
|
|
334
|
+
"""
|
|
335
|
+
s = self.state
|
|
336
|
+
w = self.w
|
|
337
|
+
|
|
338
|
+
s.pdf_displayed_annotations = s.get_displayed_annotations()
|
|
339
|
+
bounds = s.selection_bounds
|
|
340
|
+
|
|
341
|
+
pdf_sel_data = pd.DataFrame(columns=["start_time", "end_time"])
|
|
342
|
+
pdf_sel_annot = pd.DataFrame(columns=s.pdf_annotations.columns)
|
|
343
|
+
|
|
344
|
+
has_selection = bounds is not None and s.username != "None"
|
|
345
|
+
|
|
346
|
+
if has_selection:
|
|
347
|
+
start_ts, end_ts = bounds
|
|
348
|
+
# Enable annotation creation buttons
|
|
349
|
+
w["btn_clear"].disabled = False
|
|
350
|
+
w["btn_tug"].disabled = False
|
|
351
|
+
w["btn_3m_walk"].disabled = False
|
|
352
|
+
w["btn_6min_walk"].disabled = False
|
|
353
|
+
w["btn_chairstand"].disabled = False
|
|
354
|
+
|
|
355
|
+
pdf_sel_data = pd.DataFrame(
|
|
356
|
+
{"start_time": str(start_ts), "end_time": str(end_ts)}, index=[0]
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Find existing annotations within the selected bounds
|
|
360
|
+
disp_start = pd.to_datetime(s.pdf_displayed_annotations["start_time"], errors="coerce")
|
|
361
|
+
disp_end = pd.to_datetime(s.pdf_displayed_annotations["end_time"], errors="coerce")
|
|
362
|
+
pdf_sel_annot = s.pdf_displayed_annotations.loc[
|
|
363
|
+
disp_start.between(start_ts, end_ts, inclusive="both")
|
|
364
|
+
& disp_end.between(start_ts, end_ts, inclusive="both")
|
|
365
|
+
]
|
|
366
|
+
# Convert datetimes to strings for Bokeh DataTable display
|
|
367
|
+
pdf_sel_annot = pdf_sel_annot.assign(
|
|
368
|
+
**{col: pdf_sel_annot[col].astype(str) for col in ["start_time", "end_time"]}
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Modification buttons only enabled when annotations exist in the selection
|
|
372
|
+
has_annots = pdf_sel_annot.shape[0] > 0
|
|
373
|
+
w["btn_remove"].disabled = not has_annots
|
|
374
|
+
w["btn_segment"].disabled = not has_annots
|
|
375
|
+
w["btn_scoring"].disabled = not has_annots
|
|
376
|
+
w["btn_review"].disabled = not has_annots
|
|
377
|
+
w["btn_notes"].disabled = not has_annots
|
|
378
|
+
w["notes_input"].disabled = not has_annots
|
|
379
|
+
else:
|
|
380
|
+
for key in [
|
|
381
|
+
"btn_clear", "btn_tug", "btn_3m_walk",
|
|
382
|
+
"btn_6min_walk", "btn_chairstand", "btn_remove",
|
|
383
|
+
"btn_segment", "btn_scoring", "btn_review",
|
|
384
|
+
"btn_notes",
|
|
385
|
+
]:
|
|
386
|
+
w[key].disabled = True
|
|
387
|
+
w["notes_input"].disabled = True
|
|
388
|
+
|
|
389
|
+
# Push data to the Bokeh DataTable sources
|
|
390
|
+
s.selected_data.data = dict(bp.ColumnDataSource(pdf_sel_data).data)
|
|
391
|
+
annot_cols = [c for c in DISPLAYED_ANNOTATION_COLUMNS if c in pdf_sel_annot.columns]
|
|
392
|
+
if not annot_cols:
|
|
393
|
+
pdf_sel_annot = pd.DataFrame(columns=DISPLAYED_ANNOTATION_COLUMNS)
|
|
394
|
+
else:
|
|
395
|
+
pdf_sel_annot = pdf_sel_annot[annot_cols]
|
|
396
|
+
s.selected_annotations.data = dict(bp.ColumnDataSource(pdf_sel_annot).data)
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------
|
|
399
|
+
# Annotation CRUD
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def mark_annotation(self, artifact):
|
|
403
|
+
"""Add a new annotation for the selected time range."""
|
|
404
|
+
s = self.state
|
|
405
|
+
if s.selection_bounds:
|
|
406
|
+
start_ts, end_ts = s.selection_bounds
|
|
407
|
+
pdf_new = capture_new_annotation(
|
|
408
|
+
start_ts, end_ts, artifact, s.fname, s.username
|
|
409
|
+
)
|
|
410
|
+
s.pdf_annotations = pd.concat(
|
|
411
|
+
[s.pdf_annotations, pdf_new], ignore_index=True
|
|
412
|
+
)
|
|
413
|
+
self.update_annotations()
|
|
414
|
+
|
|
415
|
+
def toggle_flag(self, flag_name):
|
|
416
|
+
"""Toggle a boolean flag (segment/scoring/review) on selected annotations.
|
|
417
|
+
|
|
418
|
+
Parameters
|
|
419
|
+
----------
|
|
420
|
+
flag_name : str
|
|
421
|
+
Column name to toggle (``"segment"``, ``"scoring"``, or ``"review"``).
|
|
422
|
+
"""
|
|
423
|
+
s = self.state
|
|
424
|
+
if not s.selection_bounds:
|
|
425
|
+
self.update_annotations()
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
start_ts, end_ts = s.selection_bounds
|
|
429
|
+
mask = _filter_annotations_in_range(
|
|
430
|
+
s.pdf_annotations, start_ts, end_ts, s.username, s.fname
|
|
431
|
+
)
|
|
432
|
+
selected = s.pdf_annotations.loc[mask].copy()
|
|
433
|
+
s.pdf_annotations = s.pdf_annotations.loc[~mask]
|
|
434
|
+
# Flip: 0→1, 1→0
|
|
435
|
+
selected = selected.assign(
|
|
436
|
+
**{flag_name: (selected[flag_name] != 1).astype(int)}
|
|
437
|
+
)
|
|
438
|
+
s.pdf_annotations = pd.concat(
|
|
439
|
+
[s.pdf_annotations, selected], ignore_index=True
|
|
440
|
+
)
|
|
441
|
+
self.update_annotations()
|
|
442
|
+
|
|
443
|
+
def remove_selected_annotations(self):
|
|
444
|
+
"""Delete all annotations within the current selection bounds."""
|
|
445
|
+
s = self.state
|
|
446
|
+
if s.selection_bounds:
|
|
447
|
+
start_ts, end_ts = s.selection_bounds
|
|
448
|
+
mask = _filter_annotations_in_range(
|
|
449
|
+
s.pdf_annotations, start_ts, end_ts, s.username, s.fname
|
|
450
|
+
)
|
|
451
|
+
s.pdf_annotations = s.pdf_annotations.loc[~mask]
|
|
452
|
+
self.update_annotations()
|
|
453
|
+
|
|
454
|
+
def add_notes(self, notes_text=""):
|
|
455
|
+
"""Set notes text on all annotations within the selection."""
|
|
456
|
+
s = self.state
|
|
457
|
+
if not s.selection_bounds:
|
|
458
|
+
self.update_annotations()
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
start_ts, end_ts = s.selection_bounds
|
|
462
|
+
mask = _filter_annotations_in_range(
|
|
463
|
+
s.pdf_annotations, start_ts, end_ts, s.username, s.fname
|
|
464
|
+
)
|
|
465
|
+
selected = s.pdf_annotations.loc[mask].reset_index(drop=True)
|
|
466
|
+
s.pdf_annotations = s.pdf_annotations.loc[~mask]
|
|
467
|
+
selected = selected.assign(notes=notes_text)
|
|
468
|
+
s.pdf_annotations = pd.concat([s.pdf_annotations, selected])
|
|
469
|
+
self.w["notes_input"].value = ""
|
|
470
|
+
self.update_annotations()
|
|
471
|
+
|
|
472
|
+
def save(self):
|
|
473
|
+
"""Persist annotations to disk and refresh the summary."""
|
|
474
|
+
s = self.state
|
|
475
|
+
s.pdf_annotations = save_annotations(
|
|
476
|
+
s.pdf_annotations, s.username, s.fname
|
|
477
|
+
)
|
|
478
|
+
self.update_annotations()
|
|
479
|
+
self.w["summary"].object = build_summary_html(s)
|
|
480
|
+
self._notify("Annotations exported", duration=3000, kind="success")
|
|
481
|
+
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
# Navigation
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
def plot_new_file(self, fname_with_user):
|
|
487
|
+
"""Switch to a different file and rebuild the plot.
|
|
488
|
+
|
|
489
|
+
Parameters
|
|
490
|
+
----------
|
|
491
|
+
fname_with_user : str
|
|
492
|
+
``"username--filename"`` string from the file picker.
|
|
493
|
+
"""
|
|
494
|
+
self._notify("Loading file\u2026", duration=3000)
|
|
495
|
+
s = self.state
|
|
496
|
+
s.anchor_timestamp = None
|
|
497
|
+
s.fname = os.path.join(
|
|
498
|
+
os.path.dirname(s.fname),
|
|
499
|
+
fname_with_user.split("--")[1],
|
|
500
|
+
)
|
|
501
|
+
self.update_plot()
|
|
502
|
+
self.w["summary"].object = build_summary_html(s)
|
|
503
|
+
|
|
504
|
+
def move_next_window(self):
|
|
505
|
+
"""Advance the anchor timestamp by one full window, clamped to file end."""
|
|
506
|
+
s = self.state
|
|
507
|
+
if not s.file_end_timestamp:
|
|
508
|
+
return
|
|
509
|
+
anchor_dt = datetime.strptime(s.anchor_timestamp, TIME_FMT)
|
|
510
|
+
end_dt = datetime.strptime(s.file_end_timestamp, TIME_FMT)
|
|
511
|
+
new_anchor = anchor_dt + timedelta(seconds=s.windowsize)
|
|
512
|
+
# Don't advance past the point where the window would exceed file end
|
|
513
|
+
if new_anchor > end_dt:
|
|
514
|
+
new_anchor = end_dt - timedelta(seconds=s.windowsize / 2)
|
|
515
|
+
if new_anchor <= anchor_dt:
|
|
516
|
+
return
|
|
517
|
+
s.anchor_timestamp = new_anchor.strftime(TIME_FMT)
|
|
518
|
+
self.update_plot()
|
|
519
|
+
self._update_nav_buttons()
|
|
520
|
+
|
|
521
|
+
def move_prev_window(self):
|
|
522
|
+
"""Move the anchor timestamp back by one full window, clamped to file start."""
|
|
523
|
+
s = self.state
|
|
524
|
+
if not s.file_start_timestamp:
|
|
525
|
+
return
|
|
526
|
+
anchor_dt = datetime.strptime(s.anchor_timestamp, TIME_FMT)
|
|
527
|
+
start_dt = datetime.strptime(s.file_start_timestamp, TIME_FMT)
|
|
528
|
+
new_anchor = anchor_dt - timedelta(seconds=s.windowsize)
|
|
529
|
+
# Don't go before the point where the window would precede file start
|
|
530
|
+
if new_anchor < start_dt:
|
|
531
|
+
new_anchor = start_dt + timedelta(seconds=s.windowsize / 2)
|
|
532
|
+
if new_anchor >= anchor_dt:
|
|
533
|
+
return
|
|
534
|
+
s.anchor_timestamp = new_anchor.strftime(TIME_FMT)
|
|
535
|
+
self.update_plot()
|
|
536
|
+
self._update_nav_buttons()
|
|
537
|
+
|
|
538
|
+
def _update_nav_buttons(self):
|
|
539
|
+
"""Enable or disable prev/next buttons based on file boundaries."""
|
|
540
|
+
s = self.state
|
|
541
|
+
if not s.file_start_timestamp or not s.file_end_timestamp or not s.anchor_timestamp:
|
|
542
|
+
return
|
|
543
|
+
anchor_dt = datetime.strptime(s.anchor_timestamp, TIME_FMT)
|
|
544
|
+
start_dt = datetime.strptime(s.file_start_timestamp, TIME_FMT)
|
|
545
|
+
end_dt = datetime.strptime(s.file_end_timestamp, TIME_FMT)
|
|
546
|
+
half_win = timedelta(seconds=s.windowsize / 2)
|
|
547
|
+
self.w["btn_prev"].disabled = (anchor_dt - half_win) <= start_dt
|
|
548
|
+
self.w["btn_next"].disabled = (anchor_dt + half_win) >= end_dt
|
|
549
|
+
|
|
550
|
+
def update_anchor_timestamp(self, value):
|
|
551
|
+
"""Parse and store a user-entered anchor time string.
|
|
552
|
+
|
|
553
|
+
Parameters
|
|
554
|
+
----------
|
|
555
|
+
value : str
|
|
556
|
+
Time string in ``TIME_FMT``.
|
|
557
|
+
"""
|
|
558
|
+
try:
|
|
559
|
+
self.state.anchor_timestamp = datetime.strptime(
|
|
560
|
+
value, TIME_FMT
|
|
561
|
+
).strftime(TIME_FMT)
|
|
562
|
+
except Exception as ex:
|
|
563
|
+
print(f"Invalid time entered: {value} ({ex})")
|
|
564
|
+
|
|
565
|
+
def update_windowsize(self, value):
|
|
566
|
+
"""Parse and store a user-entered window size.
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
value : str
|
|
571
|
+
Numeric string, optionally suffixed with ``"s"``.
|
|
572
|
+
"""
|
|
573
|
+
try:
|
|
574
|
+
self.state.windowsize = float(str(value).strip().replace("s", ""))
|
|
575
|
+
except Exception as ex:
|
|
576
|
+
print(f"Invalid windowsize: {value} ({ex})")
|
|
577
|
+
|
|
578
|
+
def update_review_flags(self, new_reviews):
|
|
579
|
+
"""Sync file-level review flags with the multi-select widget.
|
|
580
|
+
|
|
581
|
+
Review flags are annotation rows with ``review=1`` and no time
|
|
582
|
+
range (``start_time`` is NaT). This method diffs the widget
|
|
583
|
+
state against the current review flags and adds/removes rows
|
|
584
|
+
accordingly.
|
|
585
|
+
|
|
586
|
+
Parameters
|
|
587
|
+
----------
|
|
588
|
+
new_reviews : list of str
|
|
589
|
+
Currently selected artifact types in the review widget.
|
|
590
|
+
"""
|
|
591
|
+
s = self.state
|
|
592
|
+
basename = os.path.basename(s.fname)
|
|
593
|
+
|
|
594
|
+
# Get the current review-flag artifacts (rows without time ranges)
|
|
595
|
+
current_reviews = s.pdf_annotations.loc[
|
|
596
|
+
(s.pdf_annotations["user"] == s.username)
|
|
597
|
+
& (s.pdf_annotations["fname"] == basename)
|
|
598
|
+
& (s.pdf_annotations["review"] == 1)
|
|
599
|
+
& (s.pdf_annotations["start_time"].isna())
|
|
600
|
+
]["artifact"].tolist()
|
|
601
|
+
|
|
602
|
+
if set(new_reviews) != set(current_reviews):
|
|
603
|
+
# Remove existing review-only rows for this user/file
|
|
604
|
+
s.pdf_annotations = s.pdf_annotations.loc[
|
|
605
|
+
~(
|
|
606
|
+
(s.pdf_annotations["user"] == s.username)
|
|
607
|
+
& (s.pdf_annotations["fname"] == basename)
|
|
608
|
+
& (s.pdf_annotations["review"] == 1)
|
|
609
|
+
& (s.pdf_annotations["start_time"].isna())
|
|
610
|
+
)
|
|
611
|
+
]
|
|
612
|
+
# Add new review-flag rows (no time range)
|
|
613
|
+
if new_reviews:
|
|
614
|
+
new_rows = pd.DataFrame(
|
|
615
|
+
[
|
|
616
|
+
{
|
|
617
|
+
"fname": basename,
|
|
618
|
+
"artifact": artifact,
|
|
619
|
+
"segment": 0,
|
|
620
|
+
"scoring": 0,
|
|
621
|
+
"review": 1,
|
|
622
|
+
"annotated_at": datetime.now(),
|
|
623
|
+
"user": s.username,
|
|
624
|
+
}
|
|
625
|
+
for artifact in new_reviews
|
|
626
|
+
]
|
|
627
|
+
)
|
|
628
|
+
s.pdf_annotations = pd.concat(
|
|
629
|
+
[s.pdf_annotations, new_rows], ignore_index=True
|
|
630
|
+
).reset_index(drop=True)
|
|
631
|
+
s.get_displayed_annotations()
|