[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:
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user