From 76f995369551692cd6c4f663bc149a10f7204dd3 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 4 Apr 2026 19:49:22 +0200 Subject: [PATCH] [refactor](trx-wefax): verify unverified auto-starts via line correlation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Stan Grams --- src/decoders/trx-wefax/src/decoder.rs | 126 +++++++++++++++++++------- 1 file changed, 94 insertions(+), 32 deletions(-) diff --git a/src/decoders/trx-wefax/src/decoder.rs b/src/decoders/trx-wefax/src/decoder.rs index a23f546..06c8bc2 100644 --- a/src/decoders/trx-wefax/src/decoder.rs +++ b/src/decoders/trx-wefax/src/decoder.rs @@ -43,11 +43,20 @@ const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2; /// fldigi's line-to-line correlation check for automatic stop. const LINE_CORR_NOISE_LINES: u32 = 30; -/// Minimum image height (lines) to save. Anything shorter is assumed to be a -/// false-positive auto-start (variance detector tripping on tones, noise -/// bursts, or phasing leakage) and discarded silently. A real WEFAX chart is -/// at least several hundred lines long. -const MIN_IMAGE_LINES: u32 = 100; +/// Pearson correlation above which adjacent lines are considered good +/// evidence of real image content. Used to verify unverified auto-starts. +const LINE_CORR_IMAGE_THRESHOLD: f32 = 0.5; + +/// 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. #[derive(Debug)] @@ -101,6 +110,14 @@ pub struct WefaxDecoder { /// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress /// image (carrier dropped / tx ended without an APT stop tone). 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). freq_hz: u64, /// Current rig mode name (for image filenames). @@ -128,6 +145,8 @@ impl WefaxDecoder { signal_detect_count: 0, signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), low_corr_lines: 0, + verified: false, + high_corr_streak: 0, freq_hz: 0, mode: String::new(), } @@ -235,7 +254,7 @@ impl WefaxDecoder { .as_millis() as i64, ); 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; } @@ -265,7 +284,7 @@ impl WefaxDecoder { if let Some(ref mut phasing) = self.phasing { 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. let mut carrier_lost = false; + let mut verify_failed = false; if let Some(ref mut slicer) = self.slicer { let new_lines = slicer.process(&luminance); for line in new_lines { if let Some(ref mut image) = self.image { - // Line-to-line correlation watchdog: real imagery - // has highly correlated adjacent lines; pure noise - // does not. If correlation stays low for - // `LINE_CORR_NOISE_LINES` consecutive lines, the - // carrier is gone and we finalize (fldigi-style). + // Line-to-line Pearson correlation classifies the + // new line as image-like, noise-like, or flat. + // fldigi-style: real imagery has highly correlated + // adjacent lines; pure noise does not. 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.high_corr_streak = 0; trace!( r = format!("{:.3}", r), count = self.low_corr_lines, "WEFAX low line-correlation" ); } 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 - // the counter but also don't reset it — an image - // with a solid band surrounded by noise still - // trips the watchdog once the noise resumes. + // either counter — solid bands in real imagery + // shouldn't be scored as noise OR as evidence. image.push_line(line); 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!( lines = count, "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 { events.extend(self.finalize_image(ioc, lpm)); self.transition_to_idle(); @@ -380,6 +438,8 @@ impl WefaxDecoder { self.signal_detect_count = 0; self.signal_detect_buf.clear(); self.low_corr_lines = 0; + self.verified = false; + self.high_corr_streak = 0; events } @@ -433,13 +493,24 @@ impl WefaxDecoder { self.state_event("Phasing", ioc, lpm) } - fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent { - debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving"); + fn transition_to_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; self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset)); self.image = Some(ImageAssembler::new(ppl)); self.tone_detector.reset(); self.low_corr_lines = 0; + self.verified = verified; + self.high_corr_streak = 0; self.state = State::Receiving { ioc, lpm }; self.state_event("Receiving", ioc, lpm) } @@ -453,24 +524,15 @@ impl WefaxDecoder { self.signal_detect_count = 0; self.signal_detect_buf.clear(); self.low_corr_lines = 0; + self.verified = false; + self.high_corr_streak = 0; } fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec { let mut events = Vec::new(); if let Some(ref image) = self.image { - let lines = image.line_count(); - 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; + if image.line_count() == 0 { return events; }