diff --git a/config/control_command.yaml b/config/control_command.yaml index 5cba715..69d7156 100644 --- a/config/control_command.yaml +++ b/config/control_command.yaml @@ -124,7 +124,7 @@ register_keys: target_reid_gap_threshold: 0.10 target_reid_min_crop_area: 2000.0 target_reid_max_crop_aspect_ratio: 0.90 - target_reid_feature_update_interval: 5 + target_reid_feature_update_interval: 3 target_reid_lost_timeout_frames: 150 target_reid_gallery_size: 8 target_reid_input_height: 256 diff --git a/src/target_observation_processing.cpp b/src/target_observation_processing.cpp index fb4908c..21354d1 100644 --- a/src/target_observation_processing.cpp +++ b/src/target_observation_processing.cpp @@ -748,6 +748,7 @@ std::vector TargetObservationProcessor::bind_and_update_tracks( // Only brand-new unmatched rows go through the consecutive-hit gate in // Phase 4 before receiving a fresh stable_id. bool reid_recovery_attempted = false; + std::vector suppress_new_sid(static_cast(row_count), false); for (int i = 0; i < row_count; ++i) { if (stable_ids[static_cast(i)] >= 0) { continue; @@ -855,6 +856,60 @@ std::vector TargetObservationProcessor::bind_and_update_tracks( } } + // Phase 3.75: duplicate suppression. If an unbound row strongly matches + // an already claimed stable_id, treat it as a duplicate ByteTrack split + // of an existing person instead of allocating a new stable_id. + // + // Typical failure mode: + // raw 1 -> sid0, raw 2 -> sid1, then ByteTrack temporarily emits raw 3 + // for the same person as sid0 while raw 1 is still alive. + // Without this guard, raw 3 would stay unbound, accumulate pending hits, + // and eventually get promoted to a brand-new sid2 even though its ReID + // is almost identical to sid0. Here we suppress that path. + for (int i = 0; i < row_count; ++i) { + if (stable_ids[static_cast(i)] >= 0) { + continue; + } + const ReIDFeature& feature = features_by_row[static_cast(i)]; + if (feature.empty()) { + continue; + } + + int best_sid = -1; + float top1 = -1.0f; + float top2 = -1.0f; + for (const auto& kv : tracks_) { + if (kv.second.gallery.empty()) { + continue; + } + const float sim = kv.second.gallery.best_similarity(feature); + if (sim > top1) { + top2 = top1; + top1 = sim; + best_sid = kv.first; + } else if (sim > top2) { + top2 = sim; + } + } + + if (best_sid < 0) { + continue; + } + if (!taken_sids.count(best_sid)) { + continue; // free sids are handled by Phase 3 ReID recovery + } + if (top1 < config_.reid_match_threshold) { + continue; + } + if (top2 >= 0.0f && (top1 - top2) < config_.reid_gap_threshold) { + continue; + } + + const int raw_id = static_cast(tracks(i, 4)); + pending_raw_ids_.erase(raw_id); + suppress_new_sid[static_cast(i)] = true; + } + // Phase 4: allocate a fresh stable_id for rows that still have no match. // Apply the confirmed-track gate: a ByteTrack raw_id must show up for // `reid_confirm_hits_before_allocate` consecutive frames before getting @@ -866,6 +921,9 @@ std::vector TargetObservationProcessor::bind_and_update_tracks( if (stable_ids[static_cast(i)] >= 0) { continue; } + if (suppress_new_sid[static_cast(i)]) { + continue; + } const int raw_id = static_cast(tracks(i, 4)); auto [pit, inserted] = pending_raw_ids_.try_emplace(raw_id); PendingRaw& pending = pit->second;