diff --git a/app/processors/video_processor.py b/app/processors/video_processor.py index 61a5f226..e0f367fa 100644 --- a/app/processors/video_processor.py +++ b/app/processors/video_processor.py @@ -1969,9 +1969,6 @@ def stop_processing(self) -> bool: self.recording = False self.triggered_by_job_manager = False self.active_output_folder = "" - # Disarm the batch recognition flag to prevent UI side-effects after an abort - if hasattr(self.main_window, "force_recognition_in_batch"): - self.main_window.force_recognition_in_batch = False self._cancel_single_frame_preview_state() # 2. Stop utility timers and audio diff --git a/app/processors/video_utils/sequential_detector.py b/app/processors/video_utils/sequential_detector.py index 0c75e7d1..2a152cad 100644 --- a/app/processors/video_utils/sequential_detector.py +++ b/app/processors/video_utils/sequential_detector.py @@ -30,6 +30,8 @@ def __init__(self, main_window): self._smoothed_kps: Dict[int, numpy.ndarray] = {} self._smoothed_dense_kps: Dict[int, numpy.ndarray] = {} self._smoothed_dense_kps_203: Dict[int, numpy.ndarray] = {} + # Temporal state to bridge ArcFace gaps (profiles, occlusions) + self._temporal_memory: List[Dict[str, Any]] = [] def reset_state(self): """ @@ -41,6 +43,12 @@ def reset_state(self): self._smoothed_dense_kps.clear() self._smoothed_dense_kps_203.clear() + # Safely clear the advanced temporal tracking memory + if hasattr(self, "_temporal_memory"): + self._temporal_memory.clear() + else: + self._temporal_memory = [] + if hasattr(self.main_window, "models_processor") and hasattr( self.main_window.models_processor, "face_detectors" ): @@ -157,8 +165,9 @@ def run( bypass_bytetrack=False, ) - # --- STEP 2: SMART FILTERING (ArcFace Recognition) --- + # --- STEP 2: SMART FILTERING & TRACKING (Hybrid Architecture) --- valid_indices: list = [] + temporal_memory_this_frame: list = [] # Safely copy the target faces dictionary to avoid concurrent access issues from the UI thread. try: @@ -169,48 +178,23 @@ def run( rec_model = str(control.get("RecognitionModelSelection", "ArcFace")) default_params = dict(self.main_window.default_parameters.data) - # --- BATCH VIDEO FIX: Enforce Identity Anchor --- - # Read the global flag injected during a batch process - force_recognition = getattr( - self.main_window, "force_recognition_in_batch", False - ) - - # 2a. Fast-path: skip ArcFace identity verification when the scene contains exactly - # one detected face and the user has configured exactly one target face. This saves - # one ArcFace inference per frame at ~5-10 ms each — significant headroom on webcam - # at 30 fps. The FrameWorker still re-verifies identity per face before swapping, - # so a wrong-identity slip-through is corrected downstream. - # Toggle name FastPathSingleFaceToggle (default True). Power users can disable. - fast_path_enabled = bool(control.get("FastPathSingleFaceToggle", True)) - - # --- BATCH VIDEO FIX: Disable Fast-path to guarantee actual identity check --- - if force_recognition: - fast_path_enabled = False - - used_fast_path = False if ( - fast_path_enabled - and isinstance(bboxes, numpy.ndarray) - and bboxes.shape[0] == 1 - and len(target_faces) == 1 - ): - valid_indices = [0] - used_fast_path = True - - # 2b. Strict ArcFace pre-filter (multi-face or multi-target case). - if ( - not used_fast_path - and isinstance(bboxes, numpy.ndarray) + isinstance(bboxes, numpy.ndarray) and bboxes.shape[0] > 0 and len(target_faces) > 0 ): + unverified_current_indices = [] + unverified_embeddings = {} + for i in range(len(kpss_5)): - # Ultra-fast recognition pass using only the 5 basic keypoints + current_bbox = bboxes[i] + current_kps5 = kpss_5[i] + + # 1. Strict Identity Anchor Pass face_emb, _ = self.main_window.models_processor.run_recognize_direct( - frame_tensor, kpss_5[i], "Auto", rec_model + frame_tensor, current_kps5, "Auto", rec_model ) - # Verify if the detected face matches any of our targets match, _, _ = find_best_target_match( face_emb, self.main_window.models_processor, @@ -220,92 +204,94 @@ def run( rec_model, ) - # Keep the index only if it's a target face if match is not None: + # Positive ID confirmed valid_indices.append(i) - elif ( - not used_fast_path - and isinstance(bboxes, numpy.ndarray) - and bboxes.shape[0] > 0 - ): - # If no specific targets are configured (e.g., pure FaceTracking mode), keep everyone - if not force_recognition: - valid_indices = list(range(len(bboxes))) - - # 2c. Relaxed-threshold ArcFace pass — runs only when the strict pass produced no - # matches. ArcFace embeddings degrade on side angles, motion blur, and partial - # occlusion: the strict pass would discard the actual target and produce flashing - # artefacts. We re-test with the per-target SimilarityThresholdSlider halved (and - # floored at 20). The FrameWorker performs its own per-face similarity check before - # swapping, so a relaxed match that turns out to be wrong is filtered downstream. - if ( - not used_fast_path - and len(valid_indices) == 0 - and isinstance(bboxes, numpy.ndarray) - and bboxes.shape[0] > 0 - and len(target_faces) > 0 - ): - relaxed_local_params: Dict[str, Any] = {} - if local_params_for_worker: - for _tgt_id, _per in local_params_for_worker.items(): - if isinstance(_per, Mapping): - _per_copy = dict(_per) - if "SimilarityThresholdSlider" in _per_copy: - try: - _orig = float(_per_copy["SimilarityThresholdSlider"]) - _per_copy["SimilarityThresholdSlider"] = max( - 20.0, _orig * 0.5 - ) - except (TypeError, ValueError): - pass - relaxed_local_params[_tgt_id] = _per_copy - else: - relaxed_local_params[_tgt_id] = _per - relaxed_default = dict(default_params) - if "SimilarityThresholdSlider" in relaxed_default: - try: - _orig = float(relaxed_default["SimilarityThresholdSlider"]) - relaxed_default["SimilarityThresholdSlider"] = max( - 20.0, _orig * 0.5 + temporal_memory_this_frame.append( + {"bbox": current_bbox, "emb": face_emb} ) - except (TypeError, ValueError): - pass + else: + # Failed UI threshold, queue for temporal rescue + unverified_current_indices.append(i) + unverified_embeddings[i] = face_emb + + # 2. Temporal Memory Tracking (Rescue Pass via IoU + Frame-to-Frame Continuity) + if ( + len(unverified_current_indices) > 0 + and hasattr(self, "_temporal_memory") + and len(self._temporal_memory) > 0 + ): + iou_matches = [] + for curr_idx in unverified_current_indices: + curr_bbox = bboxes[curr_idx] + for prev_idx, prev_data in enumerate(self._temporal_memory): + prev_bbox = prev_data["bbox"] + + xA = max(curr_bbox[0], prev_bbox[0]) + yA = max(curr_bbox[1], prev_bbox[1]) + xB = min(curr_bbox[2], prev_bbox[2]) + yB = min(curr_bbox[3], prev_bbox[3]) + + interArea = max(0.0, xB - xA) * max(0.0, yB - yA) + boxAArea = (curr_bbox[2] - curr_bbox[0]) * ( + curr_bbox[3] - curr_bbox[1] + ) + boxBArea = (prev_bbox[2] - prev_bbox[0]) * ( + prev_bbox[3] - prev_bbox[1] + ) - for i in range(len(kpss_5)): - face_emb, _ = self.main_window.models_processor.run_recognize_direct( - frame_tensor, kpss_5[i], "Auto", rec_model - ) - match, _, _ = find_best_target_match( - face_emb, - self.main_window.models_processor, - target_faces, - relaxed_local_params, - relaxed_default, - rec_model, - ) - if match is not None: - valid_indices.append(i) + denominator = float(boxAArea + boxBArea - interArea) + iou = interArea / denominator if denominator > 0 else 0.0 - # 2d. Last-resort single-face fallback: if both passes filtered everything out - # but only one face was detected, allow it through. This catches the rare case - # where ArcFace produces a near-zero similarity (severe motion blur, occlusion). - # The FrameWorker still performs identity verification before swapping. - if ( - not force_recognition # --- BATCH VIDEO FIX: Block fallback during anchoring --- - and len(valid_indices) == 0 - and isinstance(bboxes, numpy.ndarray) - and bboxes.shape[0] == 1 - and len(target_faces) > 0 - ): - valid_indices = [0] + if iou > 0.40: + iou_matches.append((iou, curr_idx, prev_idx)) - # --- BATCH VIDEO FIX: Release Identity Anchor --- - # If we successfully verified the target via true ArcFace recognition, - # disable the batch flag to restore Fast-path and Temporal Smoothing optimizations. - if force_recognition and len(valid_indices) > 0: - self.main_window.force_recognition_in_batch = False + iou_matches.sort(key=lambda x: x[0], reverse=True) + used_curr_indices = set() + used_prev_indices = set() - # Apply the filter to eliminate background extras + for iou, curr_idx, prev_idx in iou_matches: + if ( + curr_idx not in used_curr_indices + and prev_idx not in used_prev_indices + ): + # THE JUMP-CUT SHIELD: Frame-to-Frame Cosine Similarity + curr_emb = unverified_embeddings[curr_idx].flatten() + prev_emb = self._temporal_memory[prev_idx]["emb"].flatten() + + # Calculate mathematical distance between Face(t) and Face(t-1) + sim = numpy.dot(curr_emb, prev_emb) / ( + numpy.linalg.norm(curr_emb) * numpy.linalg.norm(prev_emb) + + 1e-8 + ) + + # A continuous physical movement maintains > 0.75 similarity. + # A jump cut to a different person drops heavily. + if sim > 0.75: + valid_indices.append(curr_idx) + temporal_memory_this_frame.append( + { + "bbox": bboxes[curr_idx], + "emb": unverified_embeddings[curr_idx], + } + ) + used_curr_indices.add(curr_idx) + used_prev_indices.add(prev_idx) + + # Update the temporal memory for the NEXT frame + self._temporal_memory = temporal_memory_this_frame + + elif isinstance(bboxes, numpy.ndarray) and bboxes.shape[0] > 0: + valid_indices = list(range(len(bboxes))) + + temporal_memory_this_frame = [] + for i in range(len(bboxes)): + # If no targets, we must generate dummy embeddings to keep the structure intact + dummy_emb = numpy.zeros((512,), dtype=numpy.float32) + temporal_memory_this_frame.append({"bbox": bboxes[i], "emb": dummy_emb}) + self._temporal_memory = temporal_memory_this_frame + + # Apply the filter to eliminate background extras and non-targets filtered_bboxes = ( bboxes[valid_indices] if valid_indices @@ -317,23 +303,42 @@ def run( else numpy.empty((0, 5, 2), dtype=numpy.float32) ) + # --- EARLY EXIT OPTIMIZATION --- + # If the tracking/filtering rejected everyone, skip all heavy computations immediately. + if len(filtered_bboxes) == 0: + if owns_frame_tensor and frame_tensor is not None: + del frame_tensor + + # Clear smoothing states since target is lost + self._smoothed_kps.clear() + self._smoothed_dense_kps.clear() + self._smoothed_dense_kps_203.clear() + + return ( + numpy.empty((0, 4), dtype=numpy.float32), + numpy.empty((0, 5, 2), dtype=numpy.float32), + numpy.empty((0, 68, 2), dtype=numpy.float32), + numpy.empty((0, 203, 2), dtype=numpy.float32), + ) + # --- STEP 3: HEAVY LANDMARK DETECTION (On target faces ONLY) --- + num_targets = len(filtered_bboxes) + + # DYNAMIC ALLOCATION: Using lists to handle ANY landmark model dimension (5, 68, 98, 203, 478...) + # and to prevent shape mismatch crashes if dense detection fails for a single face. filtered_kpss = [] filtered_kpss_203 = [] - for i in range(len(filtered_bboxes)): + for i in range(num_targets): current_bbox = filtered_bboxes[i] current_kps5 = filtered_kpss_5[i] - # CRITICAL FALLBACK: Instead of appending None on failure, we use valid arrays - # to prevent downstream ".copy()" calls from throwing AttributeError. + # Critical fallback: default to 5 points if heavy detection fails kps_standard = current_kps5.copy() - - # FW-LOGIC-FIX 1: Extract forced 203 landmarks FIRST if advanced editing features demand it. - # This ensures we always have a properly aligned 203 if required, independently of UI settings. kps_203_local = numpy.zeros((203, 2), dtype=numpy.float32) has_valid_203 = False + # FW-LOGIC-FIX 1: Extract forced 203 landmarks FIRST if required if requires_203: lm_203_5, lm_203, _ = ( self.main_window.models_processor.run_detect_landmark( @@ -349,7 +354,6 @@ def run( if len(lm_203) > 0: kps_203_local = lm_203 has_valid_203 = True - # If 203 was extracted for fallback, but the user ALSO selected 203 # as their primary UI model, update the Swapper's 5 points immediately. if landmark_mode == "203" and len(lm_203_5) > 0: @@ -357,7 +361,7 @@ def run( filtered_kpss_203.append(kps_203_local) - # FW-LOGIC-FIX 2: Extract standard dense landmarks (68, 203 or 478 depending on UI selection) + # FW-LOGIC-FIX 2: Extract standard dense landmarks (Dynamic Model) if use_landmark: # OPTIMIZATION: Reuse the 203 landmarks computed above ONLY IF # the user explicitly enabled 'from_points' in the UI. @@ -369,7 +373,6 @@ def run( and from_points ): kps_standard = kps_203_local.copy() - # (filtered_kpss_5[i] is already updated in the block above) else: lm_std_5, lm_kpss, _ = ( self.main_window.models_processor.run_detect_landmark( @@ -384,23 +387,30 @@ def run( ) if len(lm_kpss) > 0: kps_standard = lm_kpss - # Sync the 5-point array with the refined outputs if len(lm_std_5) > 0: filtered_kpss_5[i] = lm_std_5 filtered_kpss.append(kps_standard) - # Reformat output arrays to match the expected pipeline signature (CRITICAL TYPE CASTING) - bboxes = numpy.array(filtered_bboxes, dtype=numpy.float32) - kpss_5 = numpy.array(filtered_kpss_5, dtype=numpy.float32) + # Reassign outputs with dynamic casting + bboxes = filtered_bboxes + kpss_5 = filtered_kpss_5 + # Safely convert to numpy arrays. We use a try/except to fallback to dtype=object + # ONLY if the arrays have mixed dimensions (e.g., Face A = 478 points, Face B = 5 points). if len(filtered_kpss) > 0: - kpss = numpy.array(filtered_kpss, dtype=object) + try: + kpss = numpy.array(filtered_kpss, dtype=numpy.float32) + except Exception: + kpss = numpy.array(filtered_kpss, dtype=object) else: kpss = numpy.empty((0, 5, 2), dtype=numpy.float32) if requires_203 and len(filtered_kpss_203) > 0: - kpss_203 = numpy.array(filtered_kpss_203, dtype=object) + try: + kpss_203 = numpy.array(filtered_kpss_203, dtype=numpy.float32) + except Exception: + kpss_203 = numpy.array(filtered_kpss_203, dtype=object) else: kpss_203 = numpy.empty((0, 203, 2), dtype=numpy.float32) @@ -409,55 +419,19 @@ def run( del frame_tensor frame_tensor = None - # Safely copy arrays before returning to prevent memory corruption when the - # arrays are accessed by independent worker threads. - if isinstance(bboxes, numpy.ndarray): - bboxes = bboxes.copy() - if isinstance(kpss_5, numpy.ndarray): - kpss_5 = kpss_5.copy() - if isinstance(kpss, numpy.ndarray): - kpss = kpss.copy() - if isinstance(kpss_203, numpy.ndarray): - kpss_203 = kpss_203.copy() - # --- SANITIZATION SHIELD --- - # Ensures only perfectly formatted data passes through to the FrameWorker. - # Defends against dimension mismatches, NaNs, and infinite values. - if isinstance(bboxes, numpy.ndarray): - if bboxes.dtype == object: - try: - bboxes = bboxes.astype(numpy.float32) - except Exception: - bboxes = numpy.empty((0, 4), dtype=numpy.float32) - - if bboxes.size > 0 and bboxes.ndim == 2 and bboxes.shape[1] == 4: - valid_mask = numpy.isfinite(bboxes).all(axis=1) - if not valid_mask.all(): - bboxes = bboxes[valid_mask] - if isinstance(kpss_5, numpy.ndarray) and kpss_5.shape[0] == len( - valid_mask - ): - kpss_5 = kpss_5[valid_mask] - if isinstance(kpss, numpy.ndarray) and kpss.shape[0] == len( - valid_mask - ): - kpss = kpss[valid_mask] - if isinstance(kpss_203, numpy.ndarray) and kpss_203.shape[0] == len( - valid_mask - ): - kpss_203 = kpss_203[valid_mask] - else: - bboxes = numpy.empty((0, 4), dtype=numpy.float32) - else: - bboxes = numpy.empty((0, 4), dtype=numpy.float32) - - if bboxes.shape[0] == 0: - if isinstance(kpss_5, numpy.ndarray): - kpss_5 = numpy.empty((0, 5, 2), dtype=numpy.float32) - if isinstance(kpss, numpy.ndarray): - kpss = numpy.empty((0, 68, 2), dtype=numpy.float32) - if isinstance(kpss_203, numpy.ndarray): - kpss_203 = numpy.empty((0, 203, 2), dtype=numpy.float32) + # Protects against NaNs and Infs, works flawlessly with dynamic shapes + if bboxes.shape[0] > 0: + valid_mask = numpy.isfinite(bboxes).all(axis=1) + if not valid_mask.all(): + bboxes = bboxes[valid_mask] + kpss_5 = kpss_5[valid_mask] + if isinstance(kpss, numpy.ndarray) and kpss.shape[0] == len(valid_mask): + kpss = kpss[valid_mask] + if isinstance(kpss_203, numpy.ndarray) and kpss_203.shape[0] == len( + valid_mask + ): + kpss_203 = kpss_203[valid_mask] # Update global tracker state for UI bounding box rendering detected_for_state = [] diff --git a/app/processors/workers/frame_worker.py b/app/processors/workers/frame_worker.py index 76c909a2..f685919c 100644 --- a/app/processors/workers/frame_worker.py +++ b/app/processors/workers/frame_worker.py @@ -563,6 +563,7 @@ def _find_best_target_match( detected_embedding_np, control_global, target_faces_snapshot: dict | None = None, + is_sequentially_tracked: bool = False, ): """Finds the best matching source face for a detected target face. @@ -584,11 +585,21 @@ def _find_best_target_match( with self.lock: default_params_dict = dict(self.main_window.default_parameters.data) + # FW-ARCH-FIX: Trust the Sequential Detector + # If the face was already rescued temporally, we bypass the UI threshold + current_params = copy.deepcopy(self.parameters) + if is_sequentially_tracked: + for face_id, p in current_params.items(): + if isinstance(p, dict): + p["SimilarityThresholdSlider"] = 0.0 + if "SimilarityThresholdSlider" in default_params_dict: + default_params_dict["SimilarityThresholdSlider"] = 0.0 + return find_best_target_match( detected_embedding_np, self.models_processor, faces_to_iterate, - self.parameters, + current_params, cast(dict, default_params_dict), str(control_global["RecognitionModelSelection"]), ) @@ -2009,7 +2020,12 @@ def _process_frame_standard( # The workers are now "Stateless Render Engines". They no longer track time or state. # They consume perfectly sequenced and EMA-smoothed detections from the Feeder thread. - if self.precomputed_bboxes is not None and self.precomputed_kpss_5 is not None: + # FW-ARCH-FIX: Flag to know if faces were vetted by the Sequential Detector + is_sequentially_tracked = ( + self.precomputed_bboxes is not None and self.precomputed_kpss_5 is not None + ) + + if is_sequentially_tracked: # 1. Primary Path (Video/Webcam): Use the sequentially precomputed detections bboxes = self.precomputed_bboxes kpss_5 = self.precomputed_kpss_5 @@ -2176,12 +2192,18 @@ def _process_frame_standard( or isinstance(kpss_5, list) and len(kpss_5) > 0 ): + safe_bboxes = cast(Any, bboxes) + + _kpss_list = cast(list, kpss) if kpss is not None else [] + _kpss_203_list = cast(list, kpss_203) if kpss_203 is not None else [] + for i in range(len(kpss_5)): - _bbox_i = bboxes[i] + _bbox_i = safe_bboxes[i] if not is_detected_face_eligible_for_matching( kpss_5[i], _bbox_i, self._MIN_FACE_PIXELS ): continue # too small to produce meaningful swap + similarity_type = str("Auto") face_emb, _ = self.models_processor.run_recognize_direct( img, @@ -2190,11 +2212,9 @@ def _process_frame_standard( control["RecognitionModelSelection"], ) - kps_all_i = kpss[i] if kpss is not None and i < len(kpss) else None + kps_all_i = _kpss_list[i] if i < len(_kpss_list) else None # Extract the 203 points specifically - kps_203_i = ( - kpss_203[i] if kpss_203 is not None and i < len(kpss_203) else None - ) + kps_203_i = _kpss_203_list[i] if i < len(_kpss_203_list) else None det_faces_data_for_display.append( { @@ -2202,7 +2222,9 @@ def _process_frame_standard( "kps_all": kps_all_i.copy() if kps_all_i is not None else None, "kps_203": kps_203_i.copy() if kps_203_i is not None else None, "embedding": face_emb, - "bbox": bboxes[i].copy() if bboxes[i] is not None else None, + "bbox": safe_bboxes[i].copy() + if safe_bboxes[i] is not None + else None, "original_face": None, "swap_mask": None, "matched_target": None, # FW-BUG-09: cache slot @@ -2232,14 +2254,20 @@ def _process_frame_standard( for fface in det_faces_data_for_display: # FW-BUG-09: pass snapshot; cache result on fface for downstream reuse tgt, tgt_params, score = self._find_best_target_match( - fface["embedding"], control, target_faces_snapshot + fface["embedding"], + control, + target_faces_snapshot, + is_sequentially_tracked=is_sequentially_tracked, ) fface["matched_target"] = tgt if tgt and tgt.face_id == target_face.face_id: - if ( - score >= tgt_params["SimilarityThresholdSlider"] - and score > best_score - ): + # FW-ARCH-FIX: Bypass strict UI threshold locally if rescued temporally + effective_threshold = ( + tgt_params["SimilarityThresholdSlider"] + if not is_sequentially_tracked + else 0.0 + ) + if score >= effective_threshold and score > best_score: best_score = score best_fface = fface @@ -2369,7 +2397,10 @@ def _process_frame_standard( break # FW-BUG-09: pass the target_faces snapshot to avoid re-iterating live dict best_target, params, _ = self._find_best_target_match( - fface["embedding"], control, target_faces_snapshot + fface["embedding"], + control, + target_faces_snapshot, + is_sequentially_tracked=is_sequentially_tracked, ) # FW-BUG-09: cache matched target so downstream helpers can reuse it fface["matched_target"] = best_target diff --git a/app/ui/widgets/actions/job_manager_actions.py b/app/ui/widgets/actions/job_manager_actions.py index c96767bb..fe56a936 100644 --- a/app/ui/widgets/actions/job_manager_actions.py +++ b/app/ui/widgets/actions/job_manager_actions.py @@ -1603,12 +1603,6 @@ def load_job_settings(main_window: "MainWindow", job_data: dict): # 4. Load markers. _load_job_markers(main_window, job_data) - # --- Arm the recognition flag for the new job --- - # We MUST set this to True here in the Main Thread before processing starts. - # This forces the sequential_detector's feeder_thread to run ArcFace on the - # first frame, preventing identity bleed (Fast-Path bypass) from the previous job. - main_window.force_recognition_in_batch = True - # Calculate assigned_input_embedding here for KV injection for face_id, target_face_button in main_window.target_faces.items(): print( diff --git a/app/ui/widgets/actions/video_control_actions.py b/app/ui/widgets/actions/video_control_actions.py index 49c3b239..a8e2182e 100644 --- a/app/ui/widgets/actions/video_control_actions.py +++ b/app/ui/widgets/actions/video_control_actions.py @@ -2824,7 +2824,6 @@ def process_batch_images(main_window: "MainWindow", process_all_faces: bool): # This will use the markers, inputs, etc., currently set in the UI # and will block until the video is fully processed and saved. - # --- BATCH VIDEO FIX (Preventing Face Mix-ups) --- # 1. Purge the tracker and the Temporal EMA state for the new video if hasattr(main_window, "video_processor") and hasattr( main_window.video_processor, "sequential_detector" @@ -2834,13 +2833,10 @@ def process_batch_images(main_window: "MainWindow", process_all_faces: bool): "[INFO] Batch: Detector state and Temporal EMA cleared for the new video." ) - # 2. Temporarily force the Embedding-based recognition method - main_window.force_recognition_in_batch = True - - # 3. Trigger the recording. This will start the async process. + # 2. Trigger the recording. This will start the async process. record_video(main_window, True) - # 4. Wait for the processing to finish. + # 3. Wait for the processing to finish. # This loop now checks for cancellation while ( main_window.video_processor.processing @@ -2861,7 +2857,7 @@ def process_batch_images(main_window: "MainWindow", process_all_faces: bool): QtCore.QThread.msleep(1) # 1ms sleep - # 5. At this point, record_video has completed (or been aborted) + # 4. At this point, record_video has completed (or been aborted) # We must check *again* if the loop was exited due to cancellation # to avoid incorrectly incrementing the 'processed_count'. if not progress_dialog.confirmedCanceled(): @@ -2889,10 +2885,6 @@ def process_batch_images(main_window: "MainWindow", process_all_faces: bool): # 8. Close the progress dialog progress_dialog.close_without_confirmation() - # Clear the forced recognition flag to avoid impacting manual processing mode - if hasattr(main_window, "force_recognition_in_batch"): - main_window.force_recognition_in_batch = False - # 9. Show completion message if progress_dialog.confirmedCanceled(): result_msg = (