[fix](trx-wefax): auto-finalize image when carrier is lost

WEFAX images were only saved to disk and recorded in history when an APT
stop tone was detected or the decoder was explicitly reset. If the
transmission broke (carrier dropout, tuning drift, noise masking the
stop tone), the decoder stayed in Receiving state forever and the
partial image was never flushed.

Add a line-to-line Pearson correlation watchdog modelled on fldigi's
wefax automatic stop: real imagery has highly correlated adjacent scan
lines, while noise does not. After 30 consecutive low-correlation lines
(~15s at 120 LPM, ~30s at 60 LPM) the decoder finalizes the image,
emits WefaxEvent::Complete, and returns to Idle — so partial
transmissions show up in the web UI history like completed ones.

Flat lines with near-zero variance are treated as "undefined" and
leave the counter unchanged, so solid black/white image bands don't
falsely reset or trip the watchdog.

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:07:19 +02:00
parent 2a1c97a8dd
commit 84e50789d2
2 changed files with 129 additions and 0 deletions
+57
View File
@@ -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<f32>,
/// 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<WefaxEvent> {
+72
View File
@@ -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<f32> {
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::<f32>() / n;
let mean_b = line.iter().map(|&v| v as f32).sum::<f32>() / 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<u8> = (0..256).map(|i| i as u8).collect();
asm.push_line(gradient.clone());
// Nearly identical line — correlation ≈ 1.
let near: Vec<u8> = (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<u8> = (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);