feat: scan version selector in results panel

Each model tab now has a version combo showing scan history. When multiple
versions exist for a (file, model), users can switch between them to
compare results across training iterations. Added _current_table() and
_tab_table() helpers to unwrap the new container→table widget hierarchy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 15:22:46 +02:00
parent 4fb2ae144f
commit 8ed9fbf557
+157 -35
View File
@@ -629,6 +629,28 @@ class ScanResultsPanel(QWidget):
pass pass
return None return None
def _current_table(self) -> QTableWidget | None:
"""Return the QTableWidget from the active tab (unwrapping container)."""
w = self._tabs.currentWidget()
if isinstance(w, QTableWidget):
return w
if w is not None:
table = w.findChild(QTableWidget)
if table is not None:
return table
return None
def _tab_table(self, index: int) -> QTableWidget | None:
"""Return the QTableWidget from a tab by index."""
w = self._tabs.widget(index)
if isinstance(w, QTableWidget):
return w
if w is not None:
table = w.findChild(QTableWidget)
if table is not None:
return table
return None
def load_for_file(self, filename: str, profile: str) -> None: def load_for_file(self, filename: str, profile: str) -> None:
"""Load saved scan results from DB for a file.""" """Load saved scan results from DB for a file."""
self._filename = filename self._filename = filename
@@ -638,6 +660,7 @@ class ScanResultsPanel(QWidget):
results = self._db.get_scan_results(filename, profile) results = self._db.get_scan_results(filename, profile)
for model, rows in results.items(): for model, rows in results.items():
self._add_tab(model, rows) self._add_tab(model, rows)
self._populate_version_combos()
def add_scan_results(self, model: str, def add_scan_results(self, model: str,
regions: list[tuple[float, float, float]]) -> None: regions: list[tuple[float, float, float]]) -> None:
@@ -650,6 +673,7 @@ class ScanResultsPanel(QWidget):
self._tabs.removeTab(i) self._tabs.removeTab(i)
break break
self._add_tab(model, rows) self._add_tab(model, rows)
self._populate_version_combos()
for i in range(self._tabs.count()): for i in range(self._tabs.count()):
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model: if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
self._tabs.setCurrentIndex(i) self._tabs.setCurrentIndex(i)
@@ -657,10 +681,23 @@ class ScanResultsPanel(QWidget):
def _add_tab(self, model: str, def _add_tab(self, model: str,
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None: rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
"""Create a table tab. """Create a table tab wrapped in a container with a version combo.
rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...] rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]
""" """
container = QWidget()
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(2)
cmb_version = QComboBox()
cmb_version.setMaximumWidth(260)
cmb_version.setToolTip("Scan version history")
cmb_version.hide() # Hidden when only 1 version
cmb_version.currentIndexChanged.connect(
lambda idx, m=model: self._on_version_changed(m, idx))
container_layout.addWidget(cmb_version)
table = QTableWidget(len(rows), 3) table = QTableWidget(len(rows), 3)
table.setHorizontalHeaderLabels(["Time", "End", "Score"]) table.setHorizontalHeaderLabels(["Time", "End", "Score"])
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
@@ -706,7 +743,88 @@ class ScanResultsPanel(QWidget):
lambda t=table: self._on_selection_changed(t)) lambda t=table: self._on_selection_changed(t))
table.cellChanged.connect( table.cellChanged.connect(
lambda r, c, t=table: self._on_cell_changed(t, r, c)) lambda r, c, t=table: self._on_cell_changed(t, r, c))
self._tabs.addTab(table, f"{model} ({len(rows)})") container_layout.addWidget(table)
self._tabs.addTab(container, f"{model} ({len(rows)})")
def _populate_version_combos(self) -> None:
"""Populate version combo boxes for all tabs from DB."""
for i in range(self._tabs.count()):
w = self._tabs.widget(i)
if w is None:
continue
cmb = w.findChild(QComboBox)
if cmb is None:
continue
model = self._tabs.tabText(i).rsplit(" (", 1)[0]
versions = self._db.get_scan_versions(
self._filename, self._profile, model)
cmb.blockSignals(True)
cmb.clear()
for v in versions:
ts = v["timestamp"]
# Format: "2026-04-19 14:30 (12 regions, best: 0.95)"
label = (f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}"
f" ({v['count']} regions, best: {v['max_score']:.2f})")
cmb.addItem(label, userData=ts)
cmb.blockSignals(False)
cmb.setVisible(cmb.count() > 1)
def _on_version_changed(self, model: str, idx: int) -> None:
"""Reload a tab's results when the user selects a different version."""
if idx < 0:
return
# Find the tab for this model
for i in range(self._tabs.count()):
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
w = self._tabs.widget(i)
cmb = w.findChild(QComboBox) if w else None
if cmb is None:
return
ts = cmb.itemData(idx)
if ts is None:
return
results = self._db.get_scan_results(
self._filename, self._profile, scan_timestamp=ts)
rows = results.get(model, [])
# Replace the table contents
table = self._tab_table(i)
if table is None:
return
self._editing = True
table.setRowCount(len(rows))
red = QColor(220, 60, 60)
gray = QColor(100, 100, 100)
for r, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
t_item = QTableWidgetItem(format_time(start))
t_item.setData(Qt.ItemDataRole.UserRole, row_id)
t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
table.setItem(r, 0, t_item)
e_item = QTableWidgetItem(format_time(end))
e_item.setData(Qt.ItemDataRole.UserRole, end)
table.setItem(r, 1, e_item)
sc_item = QTableWidgetItem(f"{score:.2f}")
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(r, 2, sc_item)
if disabled:
for col in range(3):
table.item(r, col).setForeground(gray)
elif start in self._neg_times:
for col in range(3):
table.item(r, col).setForeground(red)
self._editing = False
self._tabs.setTabText(i, f"{model} ({len(rows)})")
self.regions_edited.emit()
return
def current_model_name(self) -> str:
"""Return the model name of the currently active tab."""
idx = self._tabs.currentIndex()
if idx >= 0:
return self._tabs.tabText(idx).split(" (")[0]
return ""
def _on_selection_changed(self, table: QTableWidget) -> None: def _on_selection_changed(self, table: QTableWidget) -> None:
items = table.selectedItems() items = table.selectedItems()
@@ -735,7 +853,7 @@ class ScanResultsPanel(QWidget):
self._editing = False self._editing = False
return return
# Record undo: (action, tab_index, row, col, old_value) # Record undo: (action, tab_index, row, col, old_value)
tab_idx = self._tabs.indexOf(table) tab_idx = self._tabs.indexOf(table.parent() or table)
self._undo_stack.append(("resize", tab_idx, row, col, float(old_val))) self._undo_stack.append(("resize", tab_idx, row, col, float(old_val)))
# Update stored data # Update stored data
self._editing = True self._editing = True
@@ -755,8 +873,8 @@ class ScanResultsPanel(QWidget):
def toggle_disable_selected(self) -> None: def toggle_disable_selected(self) -> None:
"""Toggle disabled state on selected rows.""" """Toggle disabled state on selected rows."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()}) selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
if not selected_rows: if not selected_rows:
@@ -791,8 +909,8 @@ class ScanResultsPanel(QWidget):
def delete_selected(self) -> None: def delete_selected(self) -> None:
"""Permanently delete selected rows from active tab and DB.""" """Permanently delete selected rows from active tab and DB."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
rows_to_delete = sorted( rows_to_delete = sorted(
{idx.row() for idx in table.selectedIndexes()}, reverse=True) {idx.row() for idx in table.selectedIndexes()}, reverse=True)
@@ -810,8 +928,8 @@ class ScanResultsPanel(QWidget):
def filter_by_threshold(self, threshold: float) -> None: def filter_by_threshold(self, threshold: float) -> None:
"""Show/hide rows based on score threshold across all tabs.""" """Show/hide rows based on score threshold across all tabs."""
for i in range(self._tabs.count()): for i in range(self._tabs.count()):
table = self._tabs.widget(i) table = self._tab_table(i)
if not isinstance(table, QTableWidget): if table is None:
continue continue
visible = 0 visible = 0
for row in range(table.rowCount()): for row in range(table.rowCount()):
@@ -844,8 +962,8 @@ class ScanResultsPanel(QWidget):
def current_regions_with_orig(self) -> list[tuple[float, float, float, float, float]]: def current_regions_with_orig(self) -> list[tuple[float, float, float, float, float]]:
"""Return (start, end, score, orig_start, orig_end) for enabled, visible rows.""" """Return (start, end, score, orig_start, orig_end) for enabled, visible rows."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return [] return []
regions = [] regions = []
for row in range(table.rowCount()): for row in range(table.rowCount()):
@@ -870,8 +988,8 @@ class ScanResultsPanel(QWidget):
def update_region_times(self, start_match: float, end_match: float, def update_region_times(self, start_match: float, end_match: float,
new_start: float, new_end: float) -> None: new_start: float, new_end: float) -> None:
"""Update the table row matching (start, end) with new times. Called from timeline drag.""" """Update the table row matching (start, end) with new times. Called from timeline drag."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
for row in range(table.rowCount()): for row in range(table.rowCount()):
item0 = table.item(row, 0) item0 = table.item(row, 0)
@@ -881,7 +999,7 @@ class ScanResultsPanel(QWidget):
continue continue
if abs(float(s) - start_match) < 0.01 and abs(float(e) - end_match) < 0.01: if abs(float(s) - start_match) < 0.01 and abs(float(e) - end_match) < 0.01:
# Record undo # Record undo
tab_idx = self._tabs.indexOf(table) tab_idx = self._tabs.currentIndex()
self._undo_stack.append(("drag", tab_idx, row, float(s), float(e))) self._undo_stack.append(("drag", tab_idx, row, float(s), float(e)))
# Update stored values # Update stored values
self._editing = True self._editing = True
@@ -898,8 +1016,8 @@ class ScanResultsPanel(QWidget):
def _on_add_negatives(self) -> None: def _on_add_negatives(self) -> None:
"""Toggle selected rows as hard negatives (red = negative, toggle off to remove).""" """Toggle selected rows as hard negatives (red = negative, toggle off to remove)."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()}) selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
if not selected_rows: if not selected_rows:
@@ -938,8 +1056,8 @@ class ScanResultsPanel(QWidget):
self.negatives_removed.emit(remove_times) self.negatives_removed.emit(remove_times)
def _on_export(self) -> None: def _on_export(self) -> None:
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
# _get_tab_regions already skips disabled; also skip negatives # _get_tab_regions already skips disabled; also skip negatives
regions = [r for r in self._get_tab_regions(table) if r[0] not in self._neg_times] regions = [r for r in self._get_tab_regions(table) if r[0] not in self._neg_times]
@@ -948,22 +1066,22 @@ class ScanResultsPanel(QWidget):
def current_regions(self) -> list[tuple[float, float, float]]: def current_regions(self) -> list[tuple[float, float, float]]:
"""Return (start, end, score) for enabled rows in the active tab.""" """Return (start, end, score) for enabled rows in the active tab."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return [] return []
return self._get_tab_regions(table) return self._get_tab_regions(table)
def all_regions(self) -> list[tuple[float, float, float]]: def all_regions(self) -> list[tuple[float, float, float]]:
"""Return (start, end, score) for ALL rows including disabled.""" """Return (start, end, score) for ALL rows including disabled."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return [] return []
return self._get_tab_regions(table, include_disabled=True) return self._get_tab_regions(table, include_disabled=True)
def highlight_time(self, t: float) -> None: def highlight_time(self, t: float) -> None:
"""Select the row containing time t, scrolling to it.""" """Select the row containing time t, scrolling to it."""
table = self._tabs.currentWidget() table = self._current_table()
if not isinstance(table, QTableWidget): if table is None:
return return
for row in range(table.rowCount()): for row in range(table.rowCount()):
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1) start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
@@ -994,8 +1112,8 @@ class ScanResultsPanel(QWidget):
kind = action[0] kind = action[0]
if kind == "disable": if kind == "disable":
_, tab_idx, prev = action _, tab_idx, prev = action
table = self._tabs.widget(tab_idx) table = self._tab_table(tab_idx)
if not isinstance(table, QTableWidget): if table is None:
return return
gray = QColor(100, 100, 100) gray = QColor(100, 100, 100)
red = QColor(220, 60, 60) red = QColor(220, 60, 60)
@@ -1021,8 +1139,8 @@ class ScanResultsPanel(QWidget):
elif kind == "resize": elif kind == "resize":
_, tab_idx, row, col, old_val = action _, tab_idx, row, col, old_val = action
table = self._tabs.widget(tab_idx) table = self._tab_table(tab_idx)
if not isinstance(table, QTableWidget) or row >= table.rowCount(): if table is None or row >= table.rowCount():
return return
self._editing = True self._editing = True
if col == 0: if col == 0:
@@ -1041,8 +1159,8 @@ class ScanResultsPanel(QWidget):
elif kind == "drag": elif kind == "drag":
_, tab_idx, row, old_start, old_end = action _, tab_idx, row, old_start, old_end = action
table = self._tabs.widget(tab_idx) table = self._tab_table(tab_idx)
if not isinstance(table, QTableWidget) or row >= table.rowCount(): if table is None or row >= table.rowCount():
return return
self._editing = True self._editing = True
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_start) table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_start)
@@ -1057,8 +1175,8 @@ class ScanResultsPanel(QWidget):
elif kind == "neg": elif kind == "neg":
_, tab_idx, was_neg = action _, tab_idx, was_neg = action
table = self._tabs.widget(tab_idx) table = self._tab_table(tab_idx)
if not isinstance(table, QTableWidget): if table is None:
return return
add_back: list[float] = [] add_back: list[float] = []
remove_back: list[float] = [] remove_back: list[float] = []
@@ -4409,8 +4527,11 @@ class MainWindow(QMainWindow):
folder = self._txt_folder.text() folder = self._txt_folder.text()
name = self._txt_name.text() or "clip" name = self._txt_name.text() or "clip"
is_seq = self._cmb_format.currentText() == "WebP sequence" is_seq = self._cmb_format.currentText() == "WebP sequence"
# Find the first counter whose group folder does not exist on disk. # Start from the highest counter the DB knows about, so we never
self._export_counter = 1 # reuse a counter if the folder is temporarily empty / unmounted.
db_max = self._db.get_max_counter(folder, name) if self._db else 0
self._export_counter = max(1, db_max + 1)
# Then also skip any directories that exist on disk.
while True: while True:
group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}") group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
if not os.path.exists(group_dir): if not os.path.exists(group_dir):
@@ -4482,7 +4603,8 @@ class MainWindow(QMainWindow):
n_clips = self._spn_clips.value() n_clips = self._spn_clips.value()
# For subprofile exports, calculate counter independently. # For subprofile exports, calculate counter independently.
if folder_suffix: if folder_suffix:
counter = 1 db_max_sub = self._db.get_max_counter(folder, name) if self._db else 0
counter = max(1, db_max_sub + 1)
while True: while True:
if image_sequence: if image_sequence:
p = build_sequence_dir(folder, name, counter, sub=0) p = build_sequence_dir(folder, name, counter, sub=0)