ba48de2d30
Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
383 lines
13 KiB
Rust
383 lines
13 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
//! Image buffer and PNG encoding for WEFAX decoded images.
|
|
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use tracing::{debug, warn};
|
|
|
|
/// Image assembler: accumulates greyscale lines and encodes to PNG.
|
|
pub struct ImageAssembler {
|
|
pixels_per_line: usize,
|
|
lines: Vec<Vec<u8>>,
|
|
}
|
|
|
|
impl ImageAssembler {
|
|
pub fn new(pixels_per_line: usize) -> Self {
|
|
Self {
|
|
pixels_per_line,
|
|
lines: Vec::with_capacity(800),
|
|
}
|
|
}
|
|
|
|
/// Append a completed greyscale line.
|
|
pub fn push_line(&mut self, line: Vec<u8>) {
|
|
debug_assert_eq!(line.len(), self.pixels_per_line);
|
|
self.lines.push(line);
|
|
}
|
|
|
|
/// Number of lines accumulated so far.
|
|
pub fn line_count(&self) -> u32 {
|
|
self.lines.len() as u32
|
|
}
|
|
|
|
/// Get the most recently added line (for progress events).
|
|
pub fn last_line(&self) -> Option<&[u8]> {
|
|
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.
|
|
pub fn save_png(&self, output_dir: &Path, freq_hz: u64, mode: &str) -> Result<PathBuf, String> {
|
|
if self.lines.is_empty() {
|
|
return Err("no image lines to save".into());
|
|
}
|
|
|
|
// Detect row-length drift before handing bytes to the encoder.
|
|
// png::Writer only validates the total byte count, so if some
|
|
// rows were pushed at the wrong width the total could still
|
|
// match and the decoded image would be silently skewed.
|
|
let expected = self.pixels_per_line;
|
|
let mut bad_rows: usize = 0;
|
|
for (i, line) in self.lines.iter().enumerate() {
|
|
if line.len() != expected {
|
|
bad_rows += 1;
|
|
if bad_rows <= 3 {
|
|
warn!(
|
|
row = i,
|
|
got = line.len(),
|
|
expected,
|
|
"WEFAX: scan line has wrong width"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if bad_rows > 0 {
|
|
return Err(format!(
|
|
"{} scan line(s) have wrong width (expected {} px)",
|
|
bad_rows, expected
|
|
));
|
|
}
|
|
|
|
std::fs::create_dir_all(output_dir).map_err(|e| format!("create output dir: {}", e))?;
|
|
|
|
let filename = generate_filename(freq_hz, mode);
|
|
let path = output_dir.join(&filename);
|
|
|
|
// We already buffer the image rows into `img_data` below and
|
|
// write them in a single call, so a BufWriter adds no value.
|
|
// Using the bare `File` also lets us fsync explicitly below.
|
|
let file = std::fs::File::create(&path)
|
|
.map_err(|e| format!("create PNG file '{}': {}", path.display(), e))?;
|
|
|
|
let width = self.pixels_per_line as u32;
|
|
let height = self.lines.len() as u32;
|
|
|
|
let mut encoder = png::Encoder::new(&file, width, height);
|
|
encoder.set_color(png::ColorType::Grayscale);
|
|
encoder.set_depth(png::BitDepth::Eight);
|
|
|
|
let mut writer = encoder
|
|
.write_header()
|
|
.map_err(|e| format!("write PNG header: {}", e))?;
|
|
|
|
// Write all rows.
|
|
let expected_bytes = (width as usize) * (height as usize);
|
|
let mut img_data = Vec::with_capacity(expected_bytes);
|
|
for line in &self.lines {
|
|
img_data.extend_from_slice(line);
|
|
}
|
|
debug_assert_eq!(img_data.len(), expected_bytes);
|
|
|
|
writer.write_image_data(&img_data).map_err(|e| {
|
|
format!(
|
|
"write PNG data ({} bytes, {}x{}): {}",
|
|
img_data.len(),
|
|
width,
|
|
height,
|
|
e
|
|
)
|
|
})?;
|
|
|
|
// Explicitly finish the writer (writes IEND). Relying on Drop
|
|
// alone swallows any I/O error and can yield a truncated file.
|
|
writer
|
|
.finish()
|
|
.map_err(|e| format!("finalize PNG: {}", e))?;
|
|
// Flush the underlying file so the data is durably on disk by
|
|
// the time we emit the WefaxEvent::Complete.
|
|
(&file)
|
|
.flush()
|
|
.map_err(|e| format!("flush PNG file: {}", e))?;
|
|
file.sync_all()
|
|
.map_err(|e| format!("sync PNG file: {}", e))?;
|
|
|
|
let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
|
debug!(
|
|
path = %path.display(),
|
|
width,
|
|
height,
|
|
bytes = file_size,
|
|
"WEFAX: saved PNG"
|
|
);
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.lines.clear();
|
|
}
|
|
}
|
|
|
|
fn generate_filename(freq_hz: u64, mode: &str) -> String {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
let secs = now.as_secs();
|
|
|
|
// Convert to UTC datetime components manually (avoid chrono dependency).
|
|
let (year, month, day, hour, min, sec) = unix_to_utc(secs);
|
|
let freq_khz = freq_hz / 1000;
|
|
|
|
format!(
|
|
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}-{}_kHz_{}.png",
|
|
year, month, day, hour, min, sec, freq_khz, mode
|
|
)
|
|
}
|
|
|
|
/// Convert Unix timestamp to (year, month, day, hour, minute, second) in UTC.
|
|
fn unix_to_utc(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
|
|
let s = secs;
|
|
let sec = (s % 60) as u32;
|
|
let min = ((s / 60) % 60) as u32;
|
|
let hour = ((s / 3600) % 24) as u32;
|
|
|
|
let mut days = (s / 86400) as i64;
|
|
// Days since 1970-01-01.
|
|
let mut year = 1970u32;
|
|
loop {
|
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
|
if days < days_in_year {
|
|
break;
|
|
}
|
|
days -= days_in_year;
|
|
year += 1;
|
|
}
|
|
|
|
let leap = is_leap(year);
|
|
let month_days = [
|
|
31,
|
|
if leap { 29 } else { 28 },
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
];
|
|
|
|
let mut month = 0u32;
|
|
for (i, &md) in month_days.iter().enumerate() {
|
|
if days < md as i64 {
|
|
month = i as u32 + 1;
|
|
break;
|
|
}
|
|
days -= md as i64;
|
|
}
|
|
let day = days as u32 + 1;
|
|
|
|
(year, month, day, hour, min, sec)
|
|
}
|
|
|
|
fn is_leap(y: u32) -> bool {
|
|
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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);
|
|
assert_eq!(asm.line_count(), 0);
|
|
asm.push_line(vec![128; 1809]);
|
|
assert_eq!(asm.line_count(), 1);
|
|
asm.push_line(vec![255; 1809]);
|
|
assert_eq!(asm.line_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn save_png_to_temp_dir() {
|
|
let mut asm = ImageAssembler::new(100);
|
|
for i in 0..50 {
|
|
let val = (i * 255 / 49) as u8;
|
|
asm.push_line(vec![val; 100]);
|
|
}
|
|
|
|
let dir = std::env::temp_dir().join("trx-wefax-test");
|
|
let result = asm.save_png(&dir, 7880000, "USB");
|
|
assert!(result.is_ok(), "save_png failed: {:?}", result.err());
|
|
let path = result.unwrap();
|
|
assert!(path.exists());
|
|
|
|
// Read the file back and verify it decodes as a valid 8-bit
|
|
// greyscale PNG of the expected size. This catches truncation
|
|
// or IHDR-vs-IDAT mismatches that file-existence alone misses.
|
|
let decoder = png::Decoder::new(std::fs::File::open(&path).unwrap());
|
|
let mut reader = decoder.read_info().expect("PNG header invalid");
|
|
let info = reader.info();
|
|
assert_eq!(info.width, 100);
|
|
assert_eq!(info.height, 50);
|
|
assert_eq!(info.color_type, png::ColorType::Grayscale);
|
|
assert_eq!(info.bit_depth, png::BitDepth::Eight);
|
|
let mut buf = vec![0; reader.output_buffer_size()];
|
|
reader.next_frame(&mut buf).expect("PNG data truncated");
|
|
assert_eq!(buf.len(), 100 * 50);
|
|
|
|
// Clean up.
|
|
let _ = std::fs::remove_file(&path);
|
|
let _ = std::fs::remove_dir(&dir);
|
|
}
|
|
|
|
/// Verify save_png survives realistic WEFAX dimensions (IOC 576 →
|
|
/// 1809 px wide, 800+ lines tall) and that every byte round-trips.
|
|
#[test]
|
|
fn save_png_realistic_dimensions() {
|
|
let ppl = crate::config::WefaxConfig::pixels_per_line(576) as usize;
|
|
let mut asm = ImageAssembler::new(ppl);
|
|
for y in 0..820u32 {
|
|
let row: Vec<u8> = (0..ppl)
|
|
.map(|x| ((x as u32 ^ y).wrapping_mul(17) & 0xff) as u8)
|
|
.collect();
|
|
asm.push_line(row);
|
|
}
|
|
let dir = std::env::temp_dir().join("trx-wefax-test-realistic");
|
|
let path = asm.save_png(&dir, 7880000, "USB").expect("save_png");
|
|
let bytes = std::fs::read(&path).expect("read back");
|
|
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "missing PNG magic");
|
|
// IEND chunk should be the last 12 bytes.
|
|
assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND");
|
|
|
|
let decoder = png::Decoder::new(&bytes[..]);
|
|
let mut reader = decoder.read_info().expect("decode header");
|
|
let info = reader.info();
|
|
assert_eq!(info.width, ppl as u32);
|
|
assert_eq!(info.height, 820);
|
|
let mut buf = vec![0; reader.output_buffer_size()];
|
|
reader.next_frame(&mut buf).expect("decode data");
|
|
assert_eq!(buf.len(), ppl * 820);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
let _ = std::fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn unix_to_utc_epoch() {
|
|
let (y, m, d, h, mi, s) = unix_to_utc(0);
|
|
assert_eq!((y, m, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn unix_to_utc_known_date() {
|
|
// 2026-03-28T14:30:00 UTC = 1774718600 (approximately)
|
|
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
|
|
assert_eq!(y, 2026);
|
|
// Just verify reasonable values without asserting exact date.
|
|
assert!(m >= 1 && m <= 12);
|
|
assert!(d >= 1 && d <= 31);
|
|
assert!(h < 24);
|
|
assert!(mi < 60);
|
|
}
|
|
}
|