| @@ -17,6 +17,7 @@ import ( | |||||
| "github.com/gorilla/websocket" | "github.com/gorilla/websocket" | ||||
| "sdr-visual-suite/internal/classifier" | |||||
| "sdr-visual-suite/internal/config" | "sdr-visual-suite/internal/config" | ||||
| "sdr-visual-suite/internal/detector" | "sdr-visual-suite/internal/detector" | ||||
| "sdr-visual-suite/internal/dsp" | "sdr-visual-suite/internal/dsp" | ||||
| @@ -704,6 +705,13 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| now := time.Now() | now := time.Now() | ||||
| finished, signals := det.Process(now, spectrum, cfg.CenterHz) | finished, signals := det.Process(now, spectrum, cfg.CenterHz) | ||||
| // enrich classification with temporal IQ features | |||||
| if len(iq) > 0 { | |||||
| for i := range signals { | |||||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb}, spectrum, cfg.SampleRate, cfg.FFTSize, iq) | |||||
| signals[i].Class = cls | |||||
| } | |||||
| } | |||||
| eventMu.Lock() | eventMu.Lock() | ||||
| for _, ev := range finished { | for _, ev := range finished { | ||||
| _ = enc.Encode(ev) | _ = enc.Encode(ev) | ||||
| @@ -1,11 +1,18 @@ | |||||
| package classifier | package classifier | ||||
| // Classify builds features and applies the rule-based classifier. | // Classify builds features and applies the rule-based classifier. | ||||
| func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int) *Classification { | |||||
| func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int, iq []complex64) *Classification { | |||||
| if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 { | if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 { | ||||
| return nil | return nil | ||||
| } | } | ||||
| feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) | feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) | ||||
| if len(iq) > 0 { | |||||
| envVar, zc, instStd, crest := ExtractTemporalFeatures(iq) | |||||
| feat.EnvVariance = envVar | |||||
| feat.ZeroCross = zc | |||||
| feat.InstFreqStd = instStd | |||||
| feat.CrestFactor = crest | |||||
| } | |||||
| cls := RuleClassify(feat) | cls := RuleClassify(feat) | ||||
| return &cls | return &cls | ||||
| } | } | ||||
| @@ -14,7 +14,7 @@ func TestRuleClassifyWFM(t *testing.T) { | |||||
| for i := start; i <= end; i++ { | for i := start; i <= end; i++ { | ||||
| spectrum[i] = -10 | spectrum[i] = -10 | ||||
| } | } | ||||
| cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize) | |||||
| cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize, nil) | |||||
| if cls == nil || cls.ModType != ClassWFM { | if cls == nil || cls.ModType != ClassWFM { | ||||
| t.Fatalf("expected WFM, got %+v", cls) | t.Fatalf("expected WFM, got %+v", cls) | ||||
| } | } | ||||
| @@ -0,0 +1,69 @@ | |||||
| package classifier | |||||
| import ( | |||||
| "math" | |||||
| ) | |||||
| // ExtractTemporalFeatures computes simple time-domain features from IQ. | |||||
| func ExtractTemporalFeatures(iq []complex64) (envVar float64, zeroCross float64, instFreqStd float64, crest float64) { | |||||
| if len(iq) == 0 { | |||||
| return 0, 0, 0, 0 | |||||
| } | |||||
| env := make([]float64, len(iq)) | |||||
| var mean, rms float64 | |||||
| for i, v := range iq { | |||||
| a := math.Hypot(float64(real(v)), float64(imag(v))) | |||||
| env[i] = a | |||||
| mean += a | |||||
| rms += a * a | |||||
| } | |||||
| mean /= float64(len(iq)) | |||||
| rms = math.Sqrt(rms / float64(len(iq))) | |||||
| // env variance | |||||
| var sumVar float64 | |||||
| for _, v := range env { | |||||
| d := v - mean | |||||
| sumVar += d * d | |||||
| } | |||||
| envVar = sumVar / float64(len(iq)) | |||||
| if rms > 0 { | |||||
| crest = maxFloat(env) / rms | |||||
| } | |||||
| // zero-crossing on real part | |||||
| zc := 0 | |||||
| for i := 1; i < len(iq); i++ { | |||||
| p := real(iq[i-1]) | |||||
| c := real(iq[i]) | |||||
| if (p >= 0 && c < 0) || (p < 0 && c >= 0) { | |||||
| zc++ | |||||
| } | |||||
| } | |||||
| zeroCross = float64(zc) / float64(len(iq)) | |||||
| // instantaneous frequency std | |||||
| if len(iq) > 1 { | |||||
| var sum, sumSq float64 | |||||
| for i := 1; i < len(iq); i++ { | |||||
| p := iq[i-1] | |||||
| c := iq[i] | |||||
| num := float64(real(p))*float64(imag(c)) - float64(imag(p))*float64(real(c)) | |||||
| den := float64(real(p))*float64(real(c)) + float64(imag(p))*float64(imag(c)) | |||||
| v := math.Atan2(num, den) | |||||
| sum += v | |||||
| sumSq += v * v | |||||
| } | |||||
| n := float64(len(iq) - 1) | |||||
| mean := sum / n | |||||
| instFreqStd = math.Sqrt(sumSq/n - mean*mean) | |||||
| } | |||||
| return | |||||
| } | |||||
| func maxFloat(vals []float64) float64 { | |||||
| m := vals[0] | |||||
| for _, v := range vals { | |||||
| if v > m { | |||||
| m = v | |||||
| } | |||||
| } | |||||
| return m | |||||
| } | |||||
| @@ -24,6 +24,11 @@ type Features struct { | |||||
| Symmetry float64 `json:"symmetry"` | Symmetry float64 `json:"symmetry"` | ||||
| RolloffLeft float64 `json:"rolloff_left_db_khz"` | RolloffLeft float64 `json:"rolloff_left_db_khz"` | ||||
| RolloffRight float64 `json:"rolloff_right_db_khz"` | RolloffRight float64 `json:"rolloff_right_db_khz"` | ||||
| // Temporal | |||||
| EnvVariance float64 `json:"env_variance"` | |||||
| ZeroCross float64 `json:"zero_cross_rate"` | |||||
| InstFreqStd float64 `json:"inst_freq_std"` | |||||
| CrestFactor float64 `json:"crest_factor"` | |||||
| } | } | ||||
| // Classification is the classifier output attached to signals/events. | // Classification is the classifier output attached to signals/events. | ||||
| @@ -122,7 +122,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||||
| 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) | |||||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins, nil) | |||||
| return Signal{ | return Signal{ | ||||
| FirstBin: first, | FirstBin: first, | ||||
| LastBin: last, | LastBin: last, | ||||