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.
@@ -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()