diff --git a/src/decoders/trx-wefax/src/decoder.rs b/src/decoders/trx-wefax/src/decoder.rs index 810f2bb..e4a92d6 100644 --- a/src/decoders/trx-wefax/src/decoder.rs +++ b/src/decoders/trx-wefax/src/decoder.rs @@ -33,6 +33,16 @@ const SIGNAL_DETECT_MIN_STDDEV: f32 = 0.08; /// At 0.5 s per window this is ~3 seconds. const SIGNAL_DETECT_WINDOWS: u32 = 6; +/// Pearson correlation below which a new scan line is considered uncorrelated +/// with its predecessor — i.e. the slicer is looking at noise, not imagery. +/// Real WEFAX content typically shows r > 0.5 between adjacent lines. +const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2; + +/// Number of consecutive uncorrelated scan lines that trigger auto-finalize +/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on +/// fldigi's line-to-line correlation check for automatic stop. +const LINE_CORR_NOISE_LINES: u32 = 30; + /// WEFAX decoder output event. #[derive(Debug)] pub enum WefaxEvent { @@ -80,6 +90,11 @@ pub struct WefaxDecoder { signal_detect_count: u32, /// Accumulator for computing luminance variance within the current window. signal_detect_buf: Vec, + /// Counts consecutive scan lines whose correlation with the previous + /// line falls below `LINE_CORR_NOISE_THRESHOLD`. When it reaches + /// `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, /// Current rig dial frequency in Hz (for image filenames). freq_hz: u64, /// Current rig mode name (for image filenames). @@ -106,6 +121,7 @@ impl WefaxDecoder { sent_idle_event: false, signal_detect_count: 0, signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), + low_corr_lines: 0, freq_hz: 0, mode: String::new(), } @@ -258,13 +274,45 @@ impl WefaxDecoder { } // Feed luminance to line slicer. + let mut carrier_lost = 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). + if let Some(r) = image.correlation_with_last(&line) { + if r < LINE_CORR_NOISE_THRESHOLD { + self.low_corr_lines += 1; + trace!( + r = format!("{:.3}", r), + count = self.low_corr_lines, + "WEFAX low line-correlation" + ); + } else { + self.low_corr_lines = 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. + image.push_line(line); let count = image.line_count(); + if self.low_corr_lines >= LINE_CORR_NOISE_LINES { + debug!( + lines = count, + "WEFAX: line correlation lost — auto-finalizing image" + ); + carrier_lost = true; + break; + } + // Emit progress event. if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 { let line_data = @@ -287,6 +335,12 @@ impl WefaxDecoder { } } } + + if carrier_lost { + events.extend(self.finalize_image(ioc, lpm)); + self.transition_to_idle(); + return events; + } } State::Stopping { .. } => { @@ -319,6 +373,7 @@ impl WefaxDecoder { self.sent_idle_event = false; self.signal_detect_count = 0; self.signal_detect_buf.clear(); + self.low_corr_lines = 0; events } @@ -378,6 +433,7 @@ impl WefaxDecoder { 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.state = State::Receiving { ioc, lpm }; self.state_event("Receiving", ioc, lpm) } @@ -390,6 +446,7 @@ impl WefaxDecoder { self.tone_detector.reset(); self.signal_detect_count = 0; self.signal_detect_buf.clear(); + self.low_corr_lines = 0; } fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec { diff --git a/src/decoders/trx-wefax/src/image.rs b/src/decoders/trx-wefax/src/image.rs index c48d6a8..edff8e2 100644 --- a/src/decoders/trx-wefax/src/image.rs +++ b/src/decoders/trx-wefax/src/image.rs @@ -37,6 +37,47 @@ impl ImageAssembler { self.lines.last().map(|l| l.as_slice()) } + /// Pearson correlation between `line` and the most recently pushed line. + /// + /// Returns `None` if there is no previous line, the lengths don't match, + /// or either line has near-zero variance (constant pixels — correlation + /// is undefined, and flat regions shouldn't be scored as "noise"). + /// + /// For real WEFAX image content adjacent lines are typically highly + /// correlated (r > 0.5). When the signal is lost and the slicer feeds + /// on noise, r collapses toward 0. This mirrors fldigi's line-to-line + /// correlation check for automatic stop. + pub fn correlation_with_last(&self, line: &[u8]) -> Option { + let prev = self.lines.last()?; + if prev.len() != line.len() || line.is_empty() { + return None; + } + + let n = line.len() as f32; + let mean_a = prev.iter().map(|&v| v as f32).sum::() / n; + let mean_b = line.iter().map(|&v| v as f32).sum::() / n; + + let mut cov = 0.0f32; + let mut var_a = 0.0f32; + let mut var_b = 0.0f32; + for (&a, &b) in prev.iter().zip(line.iter()) { + let da = a as f32 - mean_a; + let db = b as f32 - mean_b; + cov += da * db; + var_a += da * da; + var_b += db * db; + } + + // Require some variance in both lines — flat regions are common in + // real imagery (solid black/white) and shouldn't be penalised. + const MIN_VAR: f32 = 32.0; // ~ stddev of 4 counts on 0..255 scale + if var_a < MIN_VAR || var_b < MIN_VAR { + return None; + } + + Some(cov / (var_a.sqrt() * var_b.sqrt())) + } + /// Encode the accumulated image to an 8-bit greyscale PNG file. /// /// Returns the full path to the saved file. @@ -155,6 +196,37 @@ fn is_leap(y: u32) -> bool { mod tests { use super::*; + #[test] + fn correlation_identifies_noise_vs_image() { + let mut asm = ImageAssembler::new(256); + + // No previous line. + assert!(asm.correlation_with_last(&[0u8; 256]).is_none()); + + // Flat line, then a gradient: first call has no reference. + let gradient: Vec = (0..256).map(|i| i as u8).collect(); + asm.push_line(gradient.clone()); + + // Nearly identical line — correlation ≈ 1. + let near: Vec = (0..256).map(|i| i as u8).collect(); + let r = asm.correlation_with_last(&near).expect("r"); + assert!(r > 0.99, "identical lines should correlate: r={}", r); + + // Pseudo-random noise vs gradient — correlation should be low. + let noise: Vec = (0..256) + .map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8) + .collect(); + let r = asm.correlation_with_last(&noise).expect("r"); + assert!( + r.abs() < 0.3, + "noise vs gradient should not correlate: r={}", + r + ); + + // Flat line returns None (no variance). + assert!(asm.correlation_with_last(&[128u8; 256]).is_none()); + } + #[test] fn image_assembler_line_count() { let mut asm = ImageAssembler::new(1809);