[feat](trx-wefax): add continuous slant correction via line cross-correlation
Track sample-clock drift between transmitter and receiver by cross-correlating each new scan line against the previous one at shifts of ±6 samples. The best-matching shift nudges the slicer's extraction cursor, keeping adjacent lines aligned and removing the diagonal skew that would otherwise accumulate over an 800-line image. A small correlation-peak deadband prefers d=0 on quiet lines, and a minimum-variance guard skips flat reference lines where drift estimation is meaningless. Enabled by default via WefaxConfig::slant_correction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ pub struct WefaxConfig {
|
|||||||
pub output_dir: Option<String>,
|
pub output_dir: Option<String>,
|
||||||
/// Whether to emit line-by-line progress events.
|
/// Whether to emit line-by-line progress events.
|
||||||
pub emit_progress: bool,
|
pub emit_progress: bool,
|
||||||
|
/// Whether to continuously track and correct sample-clock drift
|
||||||
|
/// (line-to-line cross-correlation) to remove image slant.
|
||||||
|
pub slant_correction: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WefaxConfig {
|
impl Default for WefaxConfig {
|
||||||
@@ -30,6 +33,7 @@ impl Default for WefaxConfig {
|
|||||||
deviation_hz: 400.0,
|
deviation_hz: 400.0,
|
||||||
output_dir: None,
|
output_dir: None,
|
||||||
emit_progress: true,
|
emit_progress: true,
|
||||||
|
slant_correction: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -534,7 +534,13 @@ impl WefaxDecoder {
|
|||||||
lpm, phase_offset, verified, "WEFAX: entering receiving"
|
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::with_slant(
|
||||||
|
lpm,
|
||||||
|
ioc,
|
||||||
|
INTERNAL_RATE,
|
||||||
|
phase_offset,
|
||||||
|
self.config.slant_correction,
|
||||||
|
));
|
||||||
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;
|
||||||
|
|||||||
@@ -7,9 +7,21 @@
|
|||||||
//! Once the phasing detector has established a line-start phase offset,
|
//! Once the phasing detector has established a line-start phase offset,
|
||||||
//! the line slicer accumulates demodulated luminance samples and extracts
|
//! the line slicer accumulates demodulated luminance samples and extracts
|
||||||
//! complete image lines at the configured LPM rate.
|
//! complete image lines at the configured LPM rate.
|
||||||
|
//!
|
||||||
|
//! When `slant_correction` is enabled, the slicer tracks line-to-line
|
||||||
|
//! drift via cross-correlation with the previous line and nudges the
|
||||||
|
//! extraction cursor by ±`MAX_DRIFT_SAMPLES` per line. This compensates
|
||||||
|
//! for the small mismatch between the transmitter's and receiver's
|
||||||
|
//! sample clocks that would otherwise skew the assembled image.
|
||||||
|
|
||||||
use crate::config::WefaxConfig;
|
use crate::config::WefaxConfig;
|
||||||
|
|
||||||
|
/// Maximum per-line drift (in samples at the internal rate) searched for
|
||||||
|
/// when slant correction is enabled. At 120 LPM / 11025 Hz there are
|
||||||
|
/// ~5512 samples per line, so ±6 samples is ~0.1% drift per line — more
|
||||||
|
/// than enough for any real-world sample-clock mismatch.
|
||||||
|
const MAX_DRIFT_SAMPLES: usize = 6;
|
||||||
|
|
||||||
/// Line slicer for WEFAX image assembly.
|
/// Line slicer for WEFAX image assembly.
|
||||||
pub struct LineSlicer {
|
pub struct LineSlicer {
|
||||||
/// Samples per line at the internal sample rate.
|
/// Samples per line at the internal sample rate.
|
||||||
@@ -18,16 +30,34 @@ pub struct LineSlicer {
|
|||||||
pixels_per_line: usize,
|
pixels_per_line: usize,
|
||||||
/// Phase offset in samples from the phasing detector.
|
/// Phase offset in samples from the phasing detector.
|
||||||
phase_offset: usize,
|
phase_offset: usize,
|
||||||
/// Accumulated luminance samples.
|
/// Accumulated luminance samples. While `slant_correction` is on,
|
||||||
|
/// the buffer anchor is the *start of the previous line* (so the
|
||||||
|
/// first `samples_per_line` samples are the reference for drift
|
||||||
|
/// tracking). Without slant correction the anchor is simply the
|
||||||
|
/// start of the next line to extract.
|
||||||
buffer: Vec<f32>,
|
buffer: Vec<f32>,
|
||||||
/// Number of samples consumed since the last phase alignment point.
|
|
||||||
consumed: usize,
|
|
||||||
/// Whether we have aligned to the phase offset yet.
|
/// Whether we have aligned to the phase offset yet.
|
||||||
aligned: bool,
|
aligned: bool,
|
||||||
|
/// Whether a reference (previous) line is held at the buffer anchor.
|
||||||
|
has_reference: bool,
|
||||||
|
/// Enable line-to-line drift tracking.
|
||||||
|
slant_correction: bool,
|
||||||
|
/// Cumulative drift applied so far (samples). Diagnostic.
|
||||||
|
pub(crate) total_drift: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LineSlicer {
|
impl LineSlicer {
|
||||||
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
||||||
|
Self::with_slant(lpm, ioc, sample_rate, phase_offset, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_slant(
|
||||||
|
lpm: u16,
|
||||||
|
ioc: u16,
|
||||||
|
sample_rate: u32,
|
||||||
|
phase_offset: usize,
|
||||||
|
slant_correction: bool,
|
||||||
|
) -> Self {
|
||||||
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
||||||
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
|
|
||||||
@@ -35,9 +65,11 @@ impl LineSlicer {
|
|||||||
samples_per_line,
|
samples_per_line,
|
||||||
pixels_per_line,
|
pixels_per_line,
|
||||||
phase_offset,
|
phase_offset,
|
||||||
buffer: Vec::with_capacity(samples_per_line * 2),
|
buffer: Vec::with_capacity(samples_per_line * 3),
|
||||||
consumed: 0,
|
|
||||||
aligned: false,
|
aligned: false,
|
||||||
|
has_reference: false,
|
||||||
|
slant_correction,
|
||||||
|
total_drift: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +90,58 @@ impl LineSlicer {
|
|||||||
self.aligned = true;
|
self.aligned = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract complete lines (single drain at the end to avoid O(n²)).
|
let spl = self.samples_per_line;
|
||||||
|
|
||||||
|
if !self.slant_correction {
|
||||||
|
// Simple fixed-period extraction.
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while offset + self.samples_per_line <= self.buffer.len() {
|
while offset + spl <= self.buffer.len() {
|
||||||
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
|
let line_samples = &self.buffer[offset..offset + spl];
|
||||||
let pixels = self.resample_line(line_samples);
|
let pixels = self.resample_line(line_samples);
|
||||||
lines.push(pixels);
|
lines.push(pixels);
|
||||||
offset += self.samples_per_line;
|
offset += spl;
|
||||||
self.consumed += self.samples_per_line;
|
|
||||||
}
|
}
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
self.buffer.drain(..offset);
|
self.buffer.drain(..offset);
|
||||||
}
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slant-corrected extraction.
|
||||||
|
let max_shift = MAX_DRIFT_SAMPLES;
|
||||||
|
|
||||||
|
// Bootstrap: the very first line has no previous reference.
|
||||||
|
// Extract it naively and keep it in the buffer as the reference.
|
||||||
|
if !self.has_reference {
|
||||||
|
if self.buffer.len() < spl {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
let first = self.buffer[0..spl].to_vec();
|
||||||
|
let pixels = self.resample_line(&first);
|
||||||
|
lines.push(pixels);
|
||||||
|
self.has_reference = true;
|
||||||
|
// Do NOT drain: the first `spl` samples remain as the
|
||||||
|
// reference for the next line's drift search.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent lines: for each iteration, buffer[0..spl] is the
|
||||||
|
// reference line, and we search for the best starting position
|
||||||
|
// of the NEXT line in the range [spl - max_shift, spl + max_shift].
|
||||||
|
while self.buffer.len() >= 2 * spl + max_shift {
|
||||||
|
let prev = &self.buffer[0..spl];
|
||||||
|
let (best_d, _best_r) =
|
||||||
|
search_best_shift(prev, &self.buffer, spl, max_shift);
|
||||||
|
|
||||||
|
let start = (spl as i32 + best_d) as usize;
|
||||||
|
let next_line = self.buffer[start..start + spl].to_vec();
|
||||||
|
let pixels = self.resample_line(&next_line);
|
||||||
|
lines.push(pixels);
|
||||||
|
|
||||||
|
// Advance the anchor to the start of the line we just
|
||||||
|
// emitted — it becomes the reference for the next iteration.
|
||||||
|
self.buffer.drain(..start);
|
||||||
|
self.total_drift += best_d as i64;
|
||||||
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
@@ -78,10 +150,16 @@ impl LineSlicer {
|
|||||||
self.pixels_per_line
|
self.pixels_per_line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Samples per line at the internal rate (for diagnostics).
|
||||||
|
pub fn samples_per_line(&self) -> usize {
|
||||||
|
self.samples_per_line
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.consumed = 0;
|
|
||||||
self.aligned = false;
|
self.aligned = false;
|
||||||
|
self.has_reference = false;
|
||||||
|
self.total_drift = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resample a line's worth of luminance samples to the target pixel count
|
/// Resample a line's worth of luminance samples to the target pixel count
|
||||||
@@ -112,6 +190,82 @@ impl LineSlicer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search for the drift `d ∈ [-max_shift, +max_shift]` that maximises
|
||||||
|
/// the Pearson correlation between `reference` and
|
||||||
|
/// `buffer[spl+d .. spl+d+spl]`.
|
||||||
|
///
|
||||||
|
/// Returns `(best_d, best_r)`. A correlation-peak deadband prefers
|
||||||
|
/// `d = 0` when the peak is only marginally better than at zero, which
|
||||||
|
/// keeps tracking stable on quiet lines.
|
||||||
|
fn search_best_shift(
|
||||||
|
reference: &[f32],
|
||||||
|
buffer: &[f32],
|
||||||
|
spl: usize,
|
||||||
|
max_shift: usize,
|
||||||
|
) -> (i32, f32) {
|
||||||
|
debug_assert!(buffer.len() >= 2 * spl + max_shift);
|
||||||
|
debug_assert_eq!(reference.len(), spl);
|
||||||
|
|
||||||
|
// Pre-compute reference mean + variance.
|
||||||
|
let n = spl as f32;
|
||||||
|
let mean_r = reference.iter().sum::<f32>() / n;
|
||||||
|
let mut var_r = 0.0f32;
|
||||||
|
for &v in reference {
|
||||||
|
let d = v - mean_r;
|
||||||
|
var_r += d * d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against a flat reference line — drift tracking is useless.
|
||||||
|
const MIN_VAR: f32 = 32.0;
|
||||||
|
if var_r < MIN_VAR {
|
||||||
|
return (0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ms = max_shift as i32;
|
||||||
|
let mut best_d = 0i32;
|
||||||
|
let mut best_r = f32::NEG_INFINITY;
|
||||||
|
let mut r_at_zero = 0.0f32;
|
||||||
|
|
||||||
|
for d in -ms..=ms {
|
||||||
|
let start = (spl as i32 + d) as usize;
|
||||||
|
let candidate = &buffer[start..start + spl];
|
||||||
|
|
||||||
|
let mean_c = candidate.iter().sum::<f32>() / n;
|
||||||
|
let mut var_c = 0.0f32;
|
||||||
|
let mut cov = 0.0f32;
|
||||||
|
for (i, &v) in candidate.iter().enumerate() {
|
||||||
|
let dr = reference[i] - mean_r;
|
||||||
|
let dc = v - mean_c;
|
||||||
|
cov += dr * dc;
|
||||||
|
var_c += dc * dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
let r = if var_c < MIN_VAR {
|
||||||
|
// Skip flat candidate slices.
|
||||||
|
f32::NEG_INFINITY
|
||||||
|
} else {
|
||||||
|
cov / (var_r.sqrt() * var_c.sqrt())
|
||||||
|
};
|
||||||
|
|
||||||
|
if d == 0 {
|
||||||
|
r_at_zero = r;
|
||||||
|
}
|
||||||
|
if r > best_r {
|
||||||
|
best_r = r;
|
||||||
|
best_d = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadband: if the peak is only marginally better than `d = 0`,
|
||||||
|
// stick with zero. This avoids per-line jitter when drift is small.
|
||||||
|
const DEADBAND: f32 = 0.01;
|
||||||
|
if r_at_zero.is_finite() && best_r - r_at_zero < DEADBAND {
|
||||||
|
return (0, r_at_zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
(best_d, best_r)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -124,7 +278,8 @@ mod tests {
|
|||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
|
|
||||||
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
// Slant correction off for deterministic line count.
|
||||||
|
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false);
|
||||||
// Feed exactly 3 lines worth of white.
|
// Feed exactly 3 lines worth of white.
|
||||||
let samples = vec![1.0f32; spl * 3];
|
let samples = vec![1.0f32; spl * 3];
|
||||||
let lines = slicer.process(&samples);
|
let lines = slicer.process(&samples);
|
||||||
@@ -141,7 +296,7 @@ mod tests {
|
|||||||
let sr = 11025;
|
let sr = 11025;
|
||||||
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
|
|
||||||
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
|
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false);
|
||||||
// Feed a linear ramp from 0.0 to 1.0.
|
// Feed a linear ramp from 0.0 to 1.0.
|
||||||
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
|
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
|
||||||
let lines = slicer.process(&samples);
|
let lines = slicer.process(&samples);
|
||||||
@@ -150,4 +305,92 @@ mod tests {
|
|||||||
assert!(lines[0][0] < 5);
|
assert!(lines[0][0] < 5);
|
||||||
assert!(lines[0].last().copied().unwrap_or(0) > 250);
|
assert!(lines[0].last().copied().unwrap_or(0) > 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synthesise a noisy-ish gradient line that repeats with a small
|
||||||
|
/// per-line offset, simulating a sample-clock mismatch. The slant
|
||||||
|
/// tracker should follow the drift.
|
||||||
|
#[test]
|
||||||
|
fn slant_tracker_follows_drift() {
|
||||||
|
let lpm = 120;
|
||||||
|
let ioc = 576;
|
||||||
|
let sr = 11025;
|
||||||
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
|
|
||||||
|
// Build a signal where each real line is `spl + 3` samples long
|
||||||
|
// (i.e. transmitter clock is slower than expected → positive drift
|
||||||
|
// of +3 samples per line). The content needs high-frequency
|
||||||
|
// structure for a few-sample shift to be detectable against the
|
||||||
|
// deadband.
|
||||||
|
let true_line_len = spl + 3;
|
||||||
|
let mut signal: Vec<f32> = Vec::new();
|
||||||
|
let base: Vec<f32> = (0..true_line_len)
|
||||||
|
.map(|i| {
|
||||||
|
// Pseudo-random-but-repeatable content with a narrow
|
||||||
|
// bright stripe — sharp features make sub-line shifts
|
||||||
|
// easy to localise.
|
||||||
|
let x = ((i as u32).wrapping_mul(2654435761)) >> 16;
|
||||||
|
let noise = (x & 0xff) as f32 / 255.0;
|
||||||
|
let stripe = if i == true_line_len / 3 { 1.0 } else { 0.0 };
|
||||||
|
0.3 + 0.4 * noise + stripe
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// 20 lines, each identical.
|
||||||
|
for _ in 0..20 {
|
||||||
|
signal.extend_from_slice(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true);
|
||||||
|
let lines = slicer.process(&signal);
|
||||||
|
|
||||||
|
// Expect ~ (20*true_line_len - spl) / (spl+drift) lines with
|
||||||
|
// drift absorbing the extra 2 samples per line.
|
||||||
|
assert!(
|
||||||
|
lines.len() >= 15,
|
||||||
|
"slant-corrected slicer produced only {} lines",
|
||||||
|
lines.len()
|
||||||
|
);
|
||||||
|
// Should have tracked positive drift.
|
||||||
|
assert!(
|
||||||
|
slicer.total_drift > 0,
|
||||||
|
"expected positive drift, got {}",
|
||||||
|
slicer.total_drift
|
||||||
|
);
|
||||||
|
// Roughly +3 per line (after the first bootstrap line); allow wide tolerance.
|
||||||
|
let per_line = slicer.total_drift as f32 / (lines.len() - 1) as f32;
|
||||||
|
assert!(
|
||||||
|
per_line > 1.5 && per_line < 4.0,
|
||||||
|
"per-line drift {:.2} out of range (total {}, lines {})",
|
||||||
|
per_line,
|
||||||
|
slicer.total_drift,
|
||||||
|
lines.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slant_tracker_deadband_on_no_drift() {
|
||||||
|
let lpm = 120;
|
||||||
|
let ioc = 576;
|
||||||
|
let sr = 11025;
|
||||||
|
let spl = WefaxConfig::samples_per_line(lpm, sr);
|
||||||
|
|
||||||
|
// Perfectly aligned lines → drift should stay at zero.
|
||||||
|
let line: Vec<f32> = (0..spl)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / spl as f32;
|
||||||
|
0.5 + 0.4 * (t * 9.0 * std::f32::consts::PI).sin()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut signal = Vec::new();
|
||||||
|
for _ in 0..10 {
|
||||||
|
signal.extend_from_slice(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true);
|
||||||
|
let _ = slicer.process(&signal);
|
||||||
|
// Deadband should keep drift at 0.
|
||||||
|
assert_eq!(
|
||||||
|
slicer.total_drift, 0,
|
||||||
|
"no drift expected for identical lines"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user