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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user