Просмотр исходного кода

Add classifier scaffold and attach to signals/events

master
Jan Svabenik 4 дней назад
Родитель
Сommit
b96434484a
6 измененных файлов: 363 добавлений и 31 удалений
  1. +11
    -0
      internal/classifier/classifier.go
  2. +21
    -0
      internal/classifier/classifier_test.go
  3. +167
    -0
      internal/classifier/features.go
  4. +74
    -0
      internal/classifier/rules.go
  5. +43
    -0
      internal/classifier/types.go
  6. +47
    -31
      internal/detector/detector.go

+ 11
- 0
internal/classifier/classifier.go Просмотреть файл

@@ -0,0 +1,11 @@
package classifier

// Classify builds features and applies the rule-based classifier.
func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int) *Classification {
if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 {
return nil
}
feat := ExtractFeatures(input, spectrum, sampleRate, fftSize)
cls := RuleClassify(feat)
return &cls
}

+ 21
- 0
internal/classifier/classifier_test.go Просмотреть файл

@@ -0,0 +1,21 @@
package classifier

import "testing"

func TestRuleClassifyWFM(t *testing.T) {
sampleRate := 1_000_000
fftSize := 1024
spectrum := make([]float64, fftSize)
for i := range spectrum {
spectrum[i] = -100
}
start := 100
end := 350 // ~244 bins -> ~238 kHz
for i := start; i <= end; i++ {
spectrum[i] = -10
}
cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize)
if cls == nil || cls.ModType != ClassWFM {
t.Fatalf("expected WFM, got %+v", cls)
}
}

+ 167
- 0
internal/classifier/features.go Просмотреть файл

@@ -0,0 +1,167 @@
package classifier

import (
"math"
)

// ExtractFeatures computes spectral features for a signal slice.
// spectrum is full-band power in dB (length fftSize).
func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize int) Features {
if fftSize <= 0 {
fftSize = len(spectrum)
}
if len(spectrum) == 0 || s.FirstBin < 0 || s.LastBin < s.FirstBin || s.FirstBin >= len(spectrum) {
return Features{}
}
if s.LastBin >= len(spectrum) {
s.LastBin = len(spectrum) - 1
}
binHz := float64(sampleRate) / float64(max(1, fftSize))
// slice
start := s.FirstBin
end := s.LastBin
peakDb := -1e9
peakIdx := start
sumLin := 0.0
geoSum := 0.0
count := 0
for i := start; i <= end; i++ {
db := spectrum[i]
if db > peakDb {
peakDb = db
peakIdx = i
}
p := math.Pow(10, db/10.0)
sumLin += p
if p > 0 {
geoSum += math.Log(p)
}
count++
}
avgLin := 0.0
if count > 0 {
avgLin = sumLin / float64(count)
}
// Peak-to-avg in dB
peakToAvg := 0.0
if avgLin > 0 {
peakToAvg = 10 * math.Log10(math.Pow(10, peakDb/10.0)/avgLin)
}
// Spectral flatness
flat := 0.0
if count > 0 && avgLin > 0 {
geoMean := math.Exp(geoSum / float64(count))
flat = geoMean / avgLin
}
// BW3dB
bw3 := bwAtThreshold(spectrum, start, end, peakDb-3.0) * binHz
// BW90 (90% energy)
bw90 := bwEnergy(spectrum, start, end, 0.90) * binHz
// Symmetry (power left/right of peak)
leftSum, rightSum := 0.0, 0.0
for i := start; i <= end; i++ {
p := math.Pow(10, spectrum[i]/10.0)
if i <= peakIdx {
leftSum += p
} else {
rightSum += p
}
}
sym := 0.0
if leftSum+rightSum > 0 {
sym = (rightSum - leftSum) / (rightSum + leftSum)
}
// Rolloff (dB/kHz) at edges
leftDb := spectrum[start]
rightDb := spectrum[end]
leftHz := math.Max(binHz, float64(peakIdx-start)*binHz)
rightHz := math.Max(binHz, float64(end-peakIdx)*binHz)
rollL := (peakDb - leftDb) / (leftHz / 1e3)
rollR := (peakDb - rightDb) / (rightHz / 1e3)

return Features{
BW3dB: bw3,
BW90: bw90,
SpectralFlat: clamp01(flat),
PeakToAvg: peakToAvg,
Symmetry: sym,
RolloffLeft: rollL,
RolloffRight: rollR,
}
}

func bwAtThreshold(spectrum []float64, start, end int, threshDb float64) float64 {
left := start
right := end
for i := start; i <= end; i++ {
if spectrum[i] >= threshDb {
left = i
break
}
}
for i := end; i >= start; i-- {
if spectrum[i] >= threshDb {
right = i
break
}
}
if right < left {
return float64(end - start + 1)
}
return float64(right - left + 1)
}

func bwEnergy(spectrum []float64, start, end int, frac float64) float64 {
if frac <= 0 {
return 0
}
if frac > 1 {
frac = 1
}
powers := make([]float64, 0, end-start+1)
sum := 0.0
for i := start; i <= end; i++ {
p := math.Pow(10, spectrum[i]/10.0)
sum += p
powers = append(powers, p)
}
if sum == 0 {
return float64(end - start + 1)
}
// accumulate from center outward
center := (start + end) / 2
l := center
r := center
acc := powers[center-start]
for acc/sum < frac && (l > start || r < end) {
if l > start {
l--
acc += powers[l-start]
}
if acc/sum >= frac {
break
}
if r < end {
r++
acc += powers[r-start]
}
}
return float64(r - l + 1)
}

func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}

func max(a, b int) int {
if a > b {
return a
}
return b
}

+ 74
- 0
internal/classifier/rules.go Просмотреть файл

@@ -0,0 +1,74 @@
package classifier

import "math"

func RuleClassify(feat Features) Classification {
bw := feat.BW3dB
flat := feat.SpectralFlat
sym := feat.Symmetry
p2a := feat.PeakToAvg

best := ClassUnknown
second := ClassUnknown
conf := 0.3

switch {
case bw > 100e3:
best = ClassWFM
conf = 0.9
case bw >= 6e3 && bw <= 16e3:
best = ClassNFM
conf = 0.8
if flat > 0.7 {
second = ClassNoise
}
case bw >= 500 && bw < 3e3:
if sym > 0.2 {
best = ClassSSBUSB
conf = 0.7
} else if sym < -0.2 {
best = ClassSSBLSB
conf = 0.7
} else if p2a > 3 && flat < 0.4 {
best = ClassAM
conf = 0.6
}
case bw < 500:
best = ClassCW
conf = 0.7
}

// noise hint
if best == ClassUnknown && flat > 0.85 && bw > 2e3 {
best = ClassNoise
conf = 0.6
}

// edge-case: if symmetry is strong, second best opposite side
if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown {
if best == ClassSSBUSB {
second = ClassSSBLSB
} else {
second = ClassSSBUSB
}
}

// slightly scale confidence by feature strength
if best == ClassNFM || best == ClassWFM {
conf = conf * (0.8 + 0.2*clamp01(1-flat))
}
if best == ClassAM {
conf = conf * (0.7 + 0.3*clamp01(p2a/6.0))
}
if math.IsNaN(conf) || conf <= 0 {
conf = 0.3
}

return Classification{
ModType: best,
Confidence: conf,
BW3dB: bw,
Features: feat,
SecondBest: second,
}
}

+ 43
- 0
internal/classifier/types.go Просмотреть файл

@@ -0,0 +1,43 @@
package classifier

// SignalClass is a coarse modulation label.
type SignalClass string

const (
ClassAM SignalClass = "AM"
ClassNFM SignalClass = "NFM"
ClassWFM SignalClass = "WFM"
ClassSSBUSB SignalClass = "USB"
ClassSSBLSB SignalClass = "LSB"
ClassCW SignalClass = "CW"
ClassNoise SignalClass = "NOISE"
ClassUnknown SignalClass = "UNKNOWN"
)

// Features are lightweight spectral features derived from a signal slice.
type Features struct {
// Spectral
BW3dB float64 `json:"bw_3db_hz"`
BW90 float64 `json:"bw_90_hz"`
SpectralFlat float64 `json:"spectral_flat"`
PeakToAvg float64 `json:"peak_to_avg_db"`
Symmetry float64 `json:"symmetry"`
RolloffLeft float64 `json:"rolloff_left_db_khz"`
RolloffRight float64 `json:"rolloff_right_db_khz"`
}

// Classification is the classifier output attached to signals/events.
type Classification struct {
ModType SignalClass `json:"mod_type"`
Confidence float64 `json:"confidence"`
BW3dB float64 `json:"bw_3db_hz"`
Features Features `json:"features,omitempty"`
SecondBest SignalClass `json:"second_best,omitempty"`
}

// SignalInput is the minimal input needed for classification.
type SignalInput struct {
FirstBin int
LastBin int
SNRDb float64
}

+ 47
- 31
internal/detector/detector.go Просмотреть файл

@@ -4,18 +4,21 @@ import (
"math" "math"
"sort" "sort"
"time" "time"

"sdr-visual-suite/internal/classifier"
) )


type Event struct { type Event struct {
ID int64 `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
CenterHz float64 `json:"center_hz"`
Bandwidth float64 `json:"bandwidth_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
ID int64 `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
CenterHz float64 `json:"center_hz"`
Bandwidth float64 `json:"bandwidth_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
Class *classifier.Classification `json:"class,omitempty"`
} }


type Detector struct { type Detector struct {
@@ -23,32 +26,35 @@ type Detector struct {
MinDuration time.Duration MinDuration time.Duration
Hold time.Duration Hold time.Duration


binWidth float64
nbins int
binWidth float64
nbins int
sampleRate int


active map[int64]*activeEvent
nextID int64
active map[int64]*activeEvent
nextID int64
} }


type activeEvent struct { type activeEvent struct {
id int64
start time.Time
lastSeen time.Time
centerHz float64
bwHz float64
peakDb float64
snrDb float64
firstBin int
lastBin int
id int64
start time.Time
lastSeen time.Time
centerHz float64
bwHz float64
peakDb float64
snrDb float64
firstBin int
lastBin int
class *classifier.Classification
} }


type Signal struct { type Signal struct {
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
CenterHz float64 `json:"center_hz"`
BWHz float64 `json:"bw_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
CenterHz float64 `json:"center_hz"`
BWHz float64 `json:"bw_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
Class *classifier.Classification `json:"class,omitempty"`
} }


func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector { func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector {
@@ -64,6 +70,7 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur
Hold: hold, Hold: hold,
binWidth: float64(sampleRate) / float64(fftSize), binWidth: float64(sampleRate) / float64(fftSize),
nbins: fftSize, nbins: fftSize,
sampleRate: sampleRate,
active: map[int64]*activeEvent{}, active: map[int64]*activeEvent{},
nextID: 1, nextID: 1,
} }
@@ -100,21 +107,22 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal
peakBin = i peakBin = i
} }
} else if in { } else if in {
signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz))
signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, spectrum))
in = false in = false
} }
} }
if in { if in {
signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz))
signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, spectrum))
} }
return signals return signals
} }


func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64) Signal {
func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal {
centerBin := float64(first+last) / 2.0 centerBin := float64(first+last) / 2.0
centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth
bw := float64(last-first+1) * d.binWidth bw := float64(last-first+1) * d.binWidth
snr := peak - noise snr := peak - noise
cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins)
return Signal{ return Signal{
FirstBin: first, FirstBin: first,
LastBin: last, LastBin: last,
@@ -122,6 +130,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise
BWHz: bw, BWHz: bw,
PeakDb: peak, PeakDb: peak,
SNRDb: snr, SNRDb: snr,
Class: cls,
} }
} }


@@ -158,6 +167,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
snrDb: s.SNRDb, snrDb: s.SNRDb,
firstBin: s.FirstBin, firstBin: s.FirstBin,
lastBin: s.LastBin, lastBin: s.LastBin,
class: s.Class,
} }
continue continue
} }
@@ -179,6 +189,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
if s.LastBin > best.lastBin { if s.LastBin > best.lastBin {
best.lastBin = s.LastBin best.lastBin = s.LastBin
} }
if s.Class != nil {
if best.class == nil || s.Class.Confidence >= best.class.Confidence {
best.class = s.Class
}
}
} }


var finished []Event var finished []Event
@@ -204,6 +219,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
SNRDb: ev.snrDb, SNRDb: ev.snrDb,
FirstBin: ev.firstBin, FirstBin: ev.firstBin,
LastBin: ev.lastBin, LastBin: ev.lastBin,
Class: ev.class,
}) })
delete(d.active, id) delete(d.active, id)
} }


Загрузка…
Отмена
Сохранить