From f90c866193264b15bd42f56bf6a0407b82dbfc39 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 00:23:07 +0100 Subject: [PATCH] Improve CFAR UI, CFAR perf, classifier gaps --- internal/classifier/rules.go | 14 ++++++- internal/detector/detector.go | 72 ++++++++++++++++++++++++++++++----- web/app.js | 39 +++++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/internal/classifier/rules.go b/internal/classifier/rules.go index 4d2e3f4..d9c8960 100644 --- a/internal/classifier/rules.go +++ b/internal/classifier/rules.go @@ -13,10 +13,14 @@ func RuleClassify(feat Features) Classification { conf := 0.3 switch { - case bw > 100e3: + case bw >= 80e3: best = ClassWFM conf = 0.9 - case bw >= 6e3 && bw <= 16e3: + case bw >= 25e3 && bw < 80e3: + best = ClassWFM + second = ClassNFM + conf = 0.65 + case bw >= 6e3 && bw < 25e3: best = ClassNFM conf = 0.8 if flat > 0.7 { @@ -63,6 +67,12 @@ func RuleClassify(feat Features) Classification { if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { best = ClassWSPR conf = 0.55 + } else if feat.InstFreqStd > 0.9 { + best = ClassFSK + conf = 0.45 + } else if feat.InstFreqStd < 0.25 { + best = ClassPSK + conf = 0.45 } case bw < 150: best = ClassCW diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 57222a4..1f9f0a8 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -229,23 +229,75 @@ func (d *Detector) cfarThresholds(spectrum []float64) []float64 { } rankIdx := rank - 1 thresholds := make([]float64, n) - buf := make([]float64, totalTrain) + buf := make([]float64, 0, totalTrain) + firstValid := guard + train + lastValid := n - guard - train - 1 for i := 0; i < n; i++ { - leftStart := i - guard - train - rightEnd := i + guard + train - if leftStart < 0 || rightEnd >= n { + if i < firstValid || i > lastValid { thresholds[i] = math.NaN() - continue } - copy(buf[:train], spectrum[leftStart:leftStart+train]) - copy(buf[train:], spectrum[i+guard+1:i+guard+1+train]) - sort.Float64s(buf) - noise := buf[rankIdx] - thresholds[i] = noise + d.CFARScaleDb + } + if firstValid > lastValid { + return thresholds + } + + // Build initial sorted window for first valid bin. + leftStart := firstValid - guard - train + buf = append(buf, spectrum[leftStart:leftStart+train]...) + buf = append(buf, spectrum[firstValid+guard+1:firstValid+guard+1+train]...) + sort.Float64s(buf) + thresholds[firstValid] = buf[rankIdx] + d.CFARScaleDb + + // Slide window: remove outgoing bins and insert incoming bins (O(train) per step). + for i := firstValid + 1; i <= lastValid; i++ { + outLeft := spectrum[i-guard-train-1] + outRight := spectrum[i+guard] + inLeft := spectrum[i-guard-1] + inRight := spectrum[i+guard+train] + buf = removeValue(buf, outLeft) + buf = removeValue(buf, outRight) + buf = insertValue(buf, inLeft) + buf = insertValue(buf, inRight) + thresholds[i] = buf[rankIdx] + d.CFARScaleDb } return thresholds } +func removeValue(sorted []float64, v float64) []float64 { + if len(sorted) == 0 { + return sorted + } + idx := sort.SearchFloat64s(sorted, v) + if idx < len(sorted) && sorted[idx] == v { + return append(sorted[:idx], sorted[idx+1:]...) + } + for i := idx - 1; i >= 0; i-- { + if sorted[i] == v { + return append(sorted[:i], sorted[i+1:]...) + } + if sorted[i] < v { + break + } + } + for i := idx + 1; i < len(sorted); i++ { + if sorted[i] == v { + return append(sorted[:i], sorted[i+1:]...) + } + if sorted[i] > v { + break + } + } + return sorted +} + +func insertValue(sorted []float64, v float64) []float64 { + idx := sort.SearchFloat64s(sorted, v) + sorted = append(sorted, 0) + copy(sorted[idx+1:], sorted[idx:]) + sorted[idx] = v + return sorted +} + func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { if d.ema == nil || len(d.ema) != len(spectrum) { d.ema = make([]float64, len(spectrum)) diff --git a/web/app.js b/web/app.js index 9832faa..5f8a5d5 100644 --- a/web/app.js +++ b/web/app.js @@ -542,6 +542,44 @@ function drawSpectrumGrid(ctx, w, h, startHz, endHz) { } } +function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { + if (!latest || !currentConfig?.detector?.cfar_enabled) return; + const guard = currentConfig.detector.cfar_guard_cells ?? 0; + const train = currentConfig.detector.cfar_train_cells ?? 0; + const bins = guard + train; + if (bins <= 0) return; + const fftSize = latest.fft_size || latest.spectrum_db?.length; + if (!fftSize || fftSize <= 0) return; + const binHz = latest.sample_rate / fftSize; + const edgeHz = bins * binHz; + const bandStart = latest.center_hz - latest.sample_rate / 2; + const bandEnd = latest.center_hz + latest.sample_rate / 2; + const leftEdgeEnd = bandStart + edgeHz; + const rightEdgeStart = bandEnd - edgeHz; + + ctx.fillStyle = 'rgba(255, 204, 102, 0.08)'; + ctx.strokeStyle = 'rgba(255, 204, 102, 0.18)'; + ctx.lineWidth = 1; + + const leftStart = Math.max(startHz, bandStart); + const leftEnd = Math.min(endHz, leftEdgeEnd); + if (leftEnd > leftStart) { + const x1 = ((leftStart - startHz) / (endHz - startHz)) * w; + const x2 = ((leftEnd - startHz) / (endHz - startHz)) * w; + ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); + ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); + } + + const rightStart = Math.max(startHz, rightEdgeStart); + const rightEnd = Math.min(endHz, bandEnd); + if (rightEnd > rightStart) { + const x1 = ((rightStart - startHz) / (endHz - startHz)) * w; + const x2 = ((rightEnd - startHz) / (endHz - startHz)) * w; + ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); + ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); + } +} + function renderSpectrum() { if (!latest) return; const ctx = spectrumCanvas.getContext('2d'); @@ -558,6 +596,7 @@ function renderSpectrum() { spanInput.value = (span / 1e6).toFixed(3); drawSpectrumGrid(ctx, w, h, startHz, endHz); + drawCfarEdgeOverlay(ctx, w, h, startHz, endHz); const minDb = -120; const maxDb = 0;