feat: hard negative source_model tracking, training toggle

Add source_model column to hard_negatives table with migration. New
get_hard_negatives() returns full rows, delete_hard_negatives_by_ids()
for bulk deletion. get_training_data() gains use_hard_negatives param.
TrainDialog has "Use hard negatives" checkbox. Scan panel passes current
model name when marking negatives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 15:27:11 +02:00
parent 8ed9fbf557
commit edc5784ba6
3 changed files with 134 additions and 19 deletions
+20 -1
View File
@@ -372,6 +372,14 @@ class TrainDialog(QDialog):
self._chk_scan_exports.stateChanged.connect(lambda: self._debounce.start())
form.addRow("", self._chk_scan_exports)
self._chk_hard_negatives = QCheckBox("Use hard negatives in training")
self._chk_hard_negatives.setChecked(True)
self._chk_hard_negatives.setToolTip(
"When unchecked, manually marked hard negatives are excluded from training.\n"
"Useful when training a new model type where old negatives may not apply.")
self._chk_hard_negatives.stateChanged.connect(lambda: self._debounce.start())
form.addRow("", self._chk_hard_negatives)
# Video source directory (fallback for old DB rows without source_path)
self._txt_video_dir = QLineEdit(video_dir)
self._txt_video_dir.setPlaceholderText("Directory containing source videos")
@@ -464,15 +472,18 @@ class TrainDialog(QDialog):
return
neg_folder = self._cmb_negative.currentData() or ""
inc_scan = self._chk_scan_exports.isChecked()
use_neg = self._chk_hard_negatives.isChecked()
# First check without fallback to see if source_paths are sufficient
video_infos_no_fb = self._db.get_training_data(
self._profile, folder, negative_folder=neg_folder,
include_scan_exports=inc_scan,
use_hard_negatives=use_neg,
)
video_infos = self._db.get_training_data(
self._profile, folder, negative_folder=neg_folder,
fallback_video_dir=self._txt_video_dir.text(),
include_scan_exports=inc_scan,
use_hard_negatives=use_neg,
)
# Show video dir field only when the fallback helps find extra videos
needs_fallback = len(video_infos) > len(video_infos_no_fb) or len(video_infos_no_fb) == 0
@@ -526,6 +537,10 @@ class TrainDialog(QDialog):
def include_scan_exports(self) -> bool:
return self._chk_scan_exports.isChecked()
@property
def use_hard_negatives(self) -> bool:
return self._chk_hard_negatives.isChecked()
class TrainWorker(QThread):
"""Trains an audio classifier off the main thread."""
@@ -4007,8 +4022,10 @@ class MainWindow(QMainWindow):
if not self._file_path:
return
filename = os.path.basename(self._file_path)
source_model = self._scan_panel.current_model_name()
self._db.add_hard_negatives(filename, self._profile, times,
source_path=self._file_path)
source_path=self._file_path,
source_model=source_model)
self._timeline.set_scan_regions(
self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times,
@@ -4228,6 +4245,7 @@ class MainWindow(QMainWindow):
embed_model = dlg.embed_model
video_dir = dlg.video_dir
inc_scan = dlg.include_scan_exports
use_neg = dlg.use_hard_negatives
if not pos_folder:
self._show_status("No positive class selected")
return
@@ -4240,6 +4258,7 @@ class MainWindow(QMainWindow):
self._profile, pos_folder, negative_folder=neg_folder,
fallback_video_dir=video_dir,
include_scan_exports=inc_scan,
use_hard_negatives=use_neg,
)
if not video_infos:
self._show_status("No training data found for this subprofile")