From ba9b929f86cbe964178430591747d768e04ab20e Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 21:17:15 +0100 Subject: [PATCH] feat: stabilize classification with temporal voting --- internal/config/config.go | 10 ++++ internal/detector/detector.go | 87 ++++++++++++++++++++++++++---- internal/detector/detector_test.go | 37 +++++++++++++ internal/runtime/runtime.go | 15 ++++++ web/app.js | 12 +++++ web/index.html | 2 + 6 files changed, 154 insertions(+), 9 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1971698..5701395 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,8 @@ type DetectorConfig struct { EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` MergeGapHz float64 `yaml:"merge_gap_hz" json:"merge_gap_hz"` + ClassHistorySize int `yaml:"class_history_size" json:"class_history_size"` + ClassSwitchRatio float64 `yaml:"class_switch_ratio" json:"class_switch_ratio"` // Deprecated (backward compatibility) CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` @@ -117,6 +119,8 @@ func Default() Config { EdgeMarginDb: 3.0, MaxSignalBwHz: 150000, MergeGapHz: 5000, + ClassHistorySize: 10, + ClassSwitchRatio: 0.6, }, Recorder: RecorderConfig{ Enabled: false, @@ -218,6 +222,12 @@ func applyDefaults(cfg Config) Config { if cfg.Detector.MergeGapHz <= 0 { cfg.Detector.MergeGapHz = 5000 } + if cfg.Detector.ClassHistorySize <= 0 { + cfg.Detector.ClassHistorySize = 10 + } + if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 { + cfg.Detector.ClassSwitchRatio = 0.6 + } if cfg.FrameRate <= 0 { cfg.FrameRate = 15 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 6d4f354..7e8863c 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -35,6 +35,8 @@ type Detector struct { EdgeMarginDb float64 MaxSignalBwHz float64 MergeGapHz float64 + classHistorySize int + classSwitchRatio float64 binWidth float64 nbins int sampleRate int @@ -57,8 +59,10 @@ type activeEvent struct { snrDb float64 firstBin int lastBin int - class *classifier.Classification - stableHits int + class *classifier.Classification + stableHits int + classHistory []classifier.SignalClass + classIdx int } type Signal struct { @@ -96,6 +100,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { edgeMarginDb := detCfg.EdgeMarginDb maxSignalBwHz := detCfg.MaxSignalBwHz mergeGapHz := detCfg.MergeGapHz + classHistorySize := detCfg.ClassHistorySize + classSwitchRatio := detCfg.ClassSwitchRatio if minDur <= 0 { minDur = 250 * time.Millisecond @@ -127,6 +133,12 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { if mergeGapHz <= 0 { mergeGapHz = 5000 } + if classHistorySize <= 0 { + classHistorySize = 10 + } + if classSwitchRatio <= 0 || classSwitchRatio > 1 { + classSwitchRatio = 0.6 + } if cfarRank <= 0 || cfarRank > 2*cfarTrain { cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) if cfarRank <= 0 { @@ -156,6 +168,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { EdgeMarginDb: edgeMarginDb, MaxSignalBwHz: maxSignalBwHz, MergeGapHz: mergeGapHz, + classHistorySize: classHistorySize, + classSwitchRatio: classSwitchRatio, binWidth: float64(sampleRate) / float64(fftSize), nbins: fftSize, sampleRate: sampleRate, @@ -183,14 +197,68 @@ func (d *Detector) LastNoiseFloor() float64 { return d.lastNoiseFloor } +func (ev *activeEvent) updateClass(newCls *classifier.Classification, historySize int, switchRatio float64) { + if newCls == nil { + return + } + if historySize <= 0 { + historySize = 10 + } + if switchRatio <= 0 || switchRatio > 1 { + switchRatio = 0.6 + } + if len(ev.classHistory) != historySize { + ev.classHistory = make([]classifier.SignalClass, historySize) + ev.classIdx = 0 + } + ev.classHistory[ev.classIdx%len(ev.classHistory)] = newCls.ModType + ev.classIdx++ + if ev.class == nil { + clone := *newCls + ev.class = &clone + return + } + counts := map[classifier.SignalClass]int{} + filled := ev.classIdx + if filled > len(ev.classHistory) { + filled = len(ev.classHistory) + } + for i := 0; i < filled; i++ { + c := ev.classHistory[i] + if c != "" { + counts[c]++ + } + } + var majority classifier.SignalClass + majorityCount := 0 + for c, n := range counts { + if n > majorityCount { + majority = c + majorityCount = n + } + } + threshold := int(math.Ceil(float64(filled) * switchRatio)) + if threshold < 1 { + threshold = 1 + } + if majorityCount >= threshold && majority != ev.class.ModType { + clone := *newCls + clone.ModType = majority + ev.class = &clone + } else if majority == ev.class.ModType && newCls.Confidence > ev.class.Confidence { + ev.class.Confidence = newCls.Confidence + ev.class.Features = newCls.Features + ev.class.SecondBest = newCls.SecondBest + ev.class.Scores = newCls.Scores + } +} + func (d *Detector) UpdateClasses(signals []Signal) { for _, s := range signals { for _, ev := range d.active { if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 { if s.Class != nil { - if ev.class == nil || s.Class.Confidence >= ev.class.Confidence { - ev.class = s.Class - } + ev.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) } } } @@ -498,8 +566,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { best.lastSeen = now best.stableHits++ best.centerHz = (best.centerHz + s.CenterHz) / 2.0 - if s.BWHz > best.bwHz { + if best.bwHz <= 0 { best.bwHz = s.BWHz + } else { + const alpha = 0.15 + best.bwHz = alpha*s.BWHz + (1-alpha)*best.bwHz } if s.PeakDb > best.peakDb { best.peakDb = s.PeakDb @@ -514,9 +585,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { best.lastBin = s.LastBin } if s.Class != nil { - if best.class == nil || s.Class.Confidence >= best.class.Confidence { - best.class = s.Class - } + best.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) } } diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index c6373c7..27e983f 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "sdr-visual-suite/internal/classifier" "sdr-visual-suite/internal/config" ) @@ -174,6 +175,42 @@ func TestGuardTrainHzScaling(t *testing.T) { } } +func TestClassStabilization(t *testing.T) { + ev := &activeEvent{} + histSize := 5 + switchRatio := 0.6 + + wfm := &classifier.Classification{ModType: classifier.ClassWFM, Confidence: 0.8} + nfm := &classifier.Classification{ModType: classifier.ClassNFM, Confidence: 0.7} + + ev.updateClass(wfm, histSize, switchRatio) + if ev.class.ModType != classifier.ClassWFM { + t.Fatalf("first class should be WFM, got %s", ev.class.ModType) + } + + ev.updateClass(nfm, histSize, switchRatio) + if ev.class.ModType != classifier.ClassWFM { + t.Fatalf("should stay WFM after 1 NFM, got %s", ev.class.ModType) + } + + ev.updateClass(nfm, histSize, switchRatio) + if ev.class.ModType != classifier.ClassNFM { + t.Fatalf("should switch to NFM after 2/3 majority, got %s", ev.class.ModType) + } + + for i := 0; i < 5; i++ { + ev.updateClass(wfm, histSize, switchRatio) + } + if ev.class.ModType != classifier.ClassWFM { + t.Fatalf("should be WFM after 5 consecutive, got %s", ev.class.ModType) + } + + ev.updateClass(nfm, histSize, switchRatio) + if ev.class.ModType != classifier.ClassWFM { + t.Fatalf("single outlier should not flip class, got %s", ev.class.ModType) + } +} + func makeSignalSpectrum(fftSize int, sampleRate int, offsetHz float64, bwHz float64, signalDb float64, noiseDb float64) []float64 { spectrum := make([]float64, fftSize) for i := range spectrum { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 3f79848..fe8129c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -38,6 +38,8 @@ type DetectorUpdate struct { CFARWrapAround *bool `json:"cfar_wrap_around"` EdgeMarginDb *float64 `json:"edge_margin_db"` MergeGapHz *float64 `json:"merge_gap_hz"` + ClassHistorySize *int `json:"class_history_size"` + ClassSwitchRatio *float64 `json:"class_switch_ratio"` } type SettingsUpdate struct { @@ -225,6 +227,19 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { } next.Detector.MergeGapHz = v } + if update.Detector.ClassHistorySize != nil { + if *update.Detector.ClassHistorySize < 1 { + return m.cfg, errors.New("class_history_size must be >= 1") + } + next.Detector.ClassHistorySize = *update.Detector.ClassHistorySize + } + if update.Detector.ClassSwitchRatio != nil { + v := *update.Detector.ClassSwitchRatio + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0.1 || v > 1.0 { + return m.cfg, errors.New("class_switch_ratio must be between 0.1 and 1.0") + } + next.Detector.ClassSwitchRatio = v + } } if update.Recorder != nil { if update.Recorder.Enabled != nil { diff --git a/web/app.js b/web/app.js index fcf5345..bb57003 100644 --- a/web/app.js +++ b/web/app.js @@ -44,6 +44,8 @@ const stableFramesInput = qs('stableFramesInput'); const gapToleranceInput = qs('gapToleranceInput'); const edgeMarginInput = qs('edgeMarginInput'); const mergeGapInput = qs('mergeGapInput'); +const classHistoryInput = qs('classHistoryInput'); +const classSwitchInput = qs('classSwitchInput'); const agcToggle = qs('agcToggle'); const dcToggle = qs('dcToggle'); const iqToggle = qs('iqToggle'); @@ -422,6 +424,8 @@ function applyConfigToUI(cfg) { if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; + if (classHistoryInput) classHistoryInput.value = cfg.detector.class_history_size ?? 10; + if (classSwitchInput) classSwitchInput.value = cfg.detector.class_switch_ratio ?? 0.6; agcToggle.checked = !!cfg.agc; dcToggle.checked = !!cfg.dc_block; iqToggle.checked = !!cfg.iq_balance; @@ -1429,6 +1433,14 @@ if (mergeGapInput) mergeGapInput.addEventListener('change', () => { const v = parseFloat(mergeGapInput.value); if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); }); +if (classHistoryInput) classHistoryInput.addEventListener('change', () => { + const v = parseInt(classHistoryInput.value, 10); + if (Number.isFinite(v) && v >= 1) queueConfigUpdate({ detector: { class_history_size: v } }); +}); +if (classSwitchInput) classSwitchInput.addEventListener('change', () => { + const v = parseFloat(classSwitchInput.value); + if (Number.isFinite(v) && v >= 0.1 && v <= 1.0) queueConfigUpdate({ detector: { class_switch_ratio: v } }); +}); agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); diff --git a/web/index.html b/web/index.html index 5fcd55e..d4a59e7 100644 --- a/web/index.html +++ b/web/index.html @@ -199,6 +199,8 @@ + +