[refactor](trx-wefax): verify unverified auto-starts via line correlation

Previously, the variance-based auto-start entered State::Receiving
directly and committed to saving whatever came out, relying on a
100-line minimum as a crude filter. This let any sustained tone or
noise burst allocate an image buffer and emit state events.

Replace that filter with real verification. Each entry into Receiving
is now tagged verified (phasing-driven) or unverified (variance
auto-start). Unverified receptions must produce 5 consecutive lines
of r >= 0.5 correlation within the first 40 lines to commit. Otherwise
the buffered content is dropped silently and the decoder returns to
Idle — no image saved, no history entry, no carrier-lost event.

The carrier-loss watchdog is now gated on verified==true so it can
only ever finalize genuine captures. Phasing-driven receptions (APT
start tone + phasing pulses) enter verified and don't wait on the
correlation streak.

The 100-line minimum in finalize_image is removed — verification is
a cleaner semantic gate. A very short but genuinely phasing-validated
capture will now save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-04-04 19:49:22 +02:00
parent b12112d035
commit 76f9953695
+94 -32
View File
@@ -43,11 +43,20 @@ const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// fldigi's line-to-line correlation check for automatic stop. /// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30; const LINE_CORR_NOISE_LINES: u32 = 30;
/// Minimum image height (lines) to save. Anything shorter is assumed to be a /// Pearson correlation above which adjacent lines are considered good
/// false-positive auto-start (variance detector tripping on tones, noise /// evidence of real image content. Used to verify unverified auto-starts.
/// bursts, or phasing leakage) and discarded silently. A real WEFAX chart is const LINE_CORR_IMAGE_THRESHOLD: f32 = 0.5;
/// at least several hundred lines long.
const MIN_IMAGE_LINES: u32 = 100; /// Number of consecutive well-correlated lines that verify an unverified
/// reception (i.e. an auto-start from variance detection). Low enough to
/// engage quickly on real imagery.
const VERIFY_HIGH_CORR_STREAK: u32 = 5;
/// Maximum number of scan lines the verifier waits for before giving up on
/// an unverified reception. Roughly 20 s at 120 LPM. If no high-correlation
/// streak appears by then, the buffered content is dropped and we return
/// to Idle without saving anything.
const VERIFY_TIMEOUT_LINES: u32 = 40;
/// WEFAX decoder output event. /// WEFAX decoder output event.
#[derive(Debug)] #[derive(Debug)]
@@ -101,6 +110,14 @@ pub struct WefaxDecoder {
/// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress /// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress
/// image (carrier dropped / tx ended without an APT stop tone). /// image (carrier dropped / tx ended without an APT stop tone).
low_corr_lines: u32, low_corr_lines: u32,
/// `true` once the current reception has been confirmed to contain real
/// image content. Set immediately for phasing-driven entries (the APT
/// start tone + phasing pulses already proved the signal); set later
/// by the correlation verifier for variance-driven auto-starts.
verified: bool,
/// Rolling count of consecutive well-correlated lines, used to confirm
/// an unverified reception.
high_corr_streak: u32,
/// Current rig dial frequency in Hz (for image filenames). /// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64, freq_hz: u64,
/// Current rig mode name (for image filenames). /// Current rig mode name (for image filenames).
@@ -128,6 +145,8 @@ impl WefaxDecoder {
signal_detect_count: 0, signal_detect_count: 0,
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0, low_corr_lines: 0,
verified: false,
high_corr_streak: 0,
freq_hz: 0, freq_hz: 0,
mode: String::new(), mode: String::new(),
} }
@@ -235,7 +254,7 @@ impl WefaxDecoder {
.as_millis() as i64, .as_millis() as i64,
); );
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
events.push(self.transition_to_receiving(ioc, lpm, 0)); events.push(self.transition_to_receiving(ioc, lpm, 0, false));
break; break;
} }
@@ -265,7 +284,7 @@ impl WefaxDecoder {
if let Some(ref mut phasing) = self.phasing { if let Some(ref mut phasing) = self.phasing {
if let Some(offset) = phasing.process(&luminance) { if let Some(offset) = phasing.process(&luminance) {
events.push(self.transition_to_receiving(ioc, lpm, offset)); events.push(self.transition_to_receiving(ioc, lpm, offset, true));
} }
} }
} }
@@ -281,36 +300,66 @@ impl WefaxDecoder {
// Feed luminance to line slicer. // Feed luminance to line slicer.
let mut carrier_lost = false; let mut carrier_lost = false;
let mut verify_failed = false;
if let Some(ref mut slicer) = self.slicer { if let Some(ref mut slicer) = self.slicer {
let new_lines = slicer.process(&luminance); let new_lines = slicer.process(&luminance);
for line in new_lines { for line in new_lines {
if let Some(ref mut image) = self.image { if let Some(ref mut image) = self.image {
// Line-to-line correlation watchdog: real imagery // Line-to-line Pearson correlation classifies the
// has highly correlated adjacent lines; pure noise // new line as image-like, noise-like, or flat.
// does not. If correlation stays low for // fldigi-style: real imagery has highly correlated
// `LINE_CORR_NOISE_LINES` consecutive lines, the // adjacent lines; pure noise does not.
// carrier is gone and we finalize (fldigi-style).
if let Some(r) = image.correlation_with_last(&line) { if let Some(r) = image.correlation_with_last(&line) {
if r < LINE_CORR_NOISE_THRESHOLD { if r >= LINE_CORR_IMAGE_THRESHOLD {
self.high_corr_streak += 1;
self.low_corr_lines = 0;
if !self.verified
&& self.high_corr_streak >= VERIFY_HIGH_CORR_STREAK
{
self.verified = true;
debug!(
lines = image.line_count(),
"WEFAX: reception verified from line correlation"
);
}
} else if r < LINE_CORR_NOISE_THRESHOLD {
self.low_corr_lines += 1; self.low_corr_lines += 1;
self.high_corr_streak = 0;
trace!( trace!(
r = format!("{:.3}", r), r = format!("{:.3}", r),
count = self.low_corr_lines, count = self.low_corr_lines,
"WEFAX low line-correlation" "WEFAX low line-correlation"
); );
} else { } else {
self.low_corr_lines = 0; // Middle zone — reset high streak, hold
// low-corr counter.
self.high_corr_streak = 0;
} }
} }
// Flat lines (correlation == None) don't advance // Flat lines (correlation == None) don't advance
// the counter but also don't reset it — an image // either counter — solid bands in real imagery
// with a solid band surrounded by noise still // shouldn't be scored as noise OR as evidence.
// trips the watchdog once the noise resumes.
image.push_line(line); image.push_line(line);
let count = image.line_count(); let count = image.line_count();
if self.low_corr_lines >= LINE_CORR_NOISE_LINES { // Unverified timeout: if we got here from a
// variance auto-start and line correlation never
// took hold, the "signal" wasn't real WEFAX.
// Abandon without saving.
if !self.verified && count >= VERIFY_TIMEOUT_LINES {
debug!(
lines = count,
"WEFAX: failed to verify image content — abandoning"
);
verify_failed = true;
break;
}
// Carrier-loss watchdog — only active once the
// reception has been verified (otherwise it
// double-counts with the verify timeout).
if self.verified && self.low_corr_lines >= LINE_CORR_NOISE_LINES {
debug!( debug!(
lines = count, lines = count,
"WEFAX: line correlation lost — auto-finalizing image" "WEFAX: line correlation lost — auto-finalizing image"
@@ -342,6 +391,15 @@ impl WefaxDecoder {
} }
} }
if verify_failed {
// Drop buffered content without saving — this was a
// false auto-start (tone, noise burst, etc.).
self.image = None;
self.reception_start_ms = None;
self.transition_to_idle();
return events;
}
if carrier_lost { if carrier_lost {
events.extend(self.finalize_image(ioc, lpm)); events.extend(self.finalize_image(ioc, lpm));
self.transition_to_idle(); self.transition_to_idle();
@@ -380,6 +438,8 @@ impl WefaxDecoder {
self.signal_detect_count = 0; self.signal_detect_count = 0;
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = false;
self.high_corr_streak = 0;
events events
} }
@@ -433,13 +493,24 @@ impl WefaxDecoder {
self.state_event("Phasing", ioc, lpm) self.state_event("Phasing", ioc, lpm)
} }
fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent { fn transition_to_receiving(
debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving"); &mut self,
ioc: u16,
lpm: u16,
phase_offset: usize,
verified: bool,
) -> WefaxEvent {
debug!(
ioc,
lpm, phase_offset, verified, "WEFAX: entering receiving"
);
let ppl = WefaxConfig::pixels_per_line(ioc) as usize; let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset)); self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
self.image = Some(ImageAssembler::new(ppl)); self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset(); self.tone_detector.reset();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = verified;
self.high_corr_streak = 0;
self.state = State::Receiving { ioc, lpm }; self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm) self.state_event("Receiving", ioc, lpm)
} }
@@ -453,24 +524,15 @@ impl WefaxDecoder {
self.signal_detect_count = 0; self.signal_detect_count = 0;
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = false;
self.high_corr_streak = 0;
} }
fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> { fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
if let Some(ref image) = self.image { if let Some(ref image) = self.image {
let lines = image.line_count(); if image.line_count() == 0 {
if lines == 0 {
return events;
}
if lines < MIN_IMAGE_LINES {
debug!(
lines,
min = MIN_IMAGE_LINES,
"WEFAX: discarding short image (likely false auto-start)"
);
self.image = None;
self.reception_start_ms = None;
return events; return events;
} }