Ver código fonte

feat: stabilize classification with temporal voting

master
Jan Svabenik 2 dias atrás
pai
commit
ba9b929f86
6 arquivos alterados com 154 adições e 9 exclusões
  1. +10
    -0
      internal/config/config.go
  2. +78
    -9
      internal/detector/detector.go
  3. +37
    -0
      internal/detector/detector_test.go
  4. +15
    -0
      internal/runtime/runtime.go
  5. +12
    -0
      web/app.js
  6. +2
    -0
      web/index.html

+ 10
- 0
internal/config/config.go Ver arquivo

@@ -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
}


+ 78
- 9
internal/detector/detector.go Ver arquivo

@@ -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)
}
}



+ 37
- 0
internal/detector/detector_test.go Ver arquivo

@@ -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 {


+ 15
- 0
internal/runtime/runtime.go Ver arquivo

@@ -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 {


+ 12
- 0
web/app.js Ver arquivo

@@ -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 }));


+ 2
- 0
web/index.html Ver arquivo

@@ -199,6 +199,8 @@
<label class="field"><span>Gap Tolerance (ms)</span><input id="gapToleranceInput" type="number" step="50" min="0" /></label>
<label class="field"><span>Edge Margin (dB)</span><input id="edgeMarginInput" type="number" step="0.5" min="0" /></label>
<label class="field"><span>Merge Gap (Hz)</span><input id="mergeGapInput" type="number" step="500" min="0" /></label>
<label class="field"><span>Class History</span><input id="classHistoryInput" type="number" step="1" min="1" max="30" /></label>
<label class="field"><span>Class Switch Ratio</span><input id="classSwitchInput" type="number" step="0.05" min="0.1" max="1.0" /></label>
<label class="field"><span>CFAR Mode</span>
<select id="cfarModeSelect">
<option value="OFF">Off</option>


Carregando…
Cancelar
Salvar