diff --git a/Cargo.lock b/Cargo.lock index 489d1e9..bcea77f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1261,7 +1261,6 @@ dependencies = [ "bytemuck", "byteorder", "color_quant", - "jpeg-decoder", "num-traits", "png", ] @@ -1341,12 +1340,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - [[package]] name = "js-sys" version = "0.3.85" diff --git a/src/decoders/trx-wxsat/Cargo.toml b/src/decoders/trx-wxsat/Cargo.toml index 496dd02..22ab2b9 100644 --- a/src/decoders/trx-wxsat/Cargo.toml +++ b/src/decoders/trx-wxsat/Cargo.toml @@ -11,4 +11,4 @@ edition = "2021" trx-core = { path = "../../trx-core" } rustfft = "6" num-complex = "0.4" -image = { version = "0.24", default-features = false, features = ["jpeg", "png"] } +image = { version = "0.24", default-features = false, features = ["png"] } diff --git a/src/decoders/trx-wxsat/src/image_enc.rs b/src/decoders/trx-wxsat/src/image_enc.rs new file mode 100644 index 0000000..d1a93cb --- /dev/null +++ b/src/decoders/trx-wxsat/src/image_enc.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Shared PNG image encoding for weather satellite decoders. +//! +//! Both NOAA APT and Meteor-M LRPT decoders produce PNG output through +//! this common module. + +use std::io::Cursor; + +use image::DynamicImage; + +/// Encode a grayscale pixel buffer as PNG. +/// +/// Returns `None` if the buffer is empty or encoding fails. +pub fn encode_grayscale_png(width: u32, height: u32, pixels: Vec) -> Option> { + let gray = image::GrayImage::from_raw(width, height, pixels)?; + let dynamic = DynamicImage::ImageLuma8(gray); + encode_dynamic_png(&dynamic) +} + +/// Encode an RGB pixel buffer as PNG. +/// +/// `pixels` must contain `width * height * 3` bytes in R, G, B order. +/// Returns `None` if the buffer is empty or encoding fails. +pub fn encode_rgb_png(width: u32, height: u32, pixels: Vec) -> Option> { + let rgb = image::RgbImage::from_raw(width, height, pixels)?; + let dynamic = DynamicImage::ImageRgb8(rgb); + encode_dynamic_png(&dynamic) +} + +fn encode_dynamic_png(img: &DynamicImage) -> Option> { + let mut cursor = Cursor::new(Vec::new()); + img.write_to(&mut cursor, image::ImageOutputFormat::Png) + .ok()?; + Some(cursor.into_inner()) +} diff --git a/src/decoders/trx-wxsat/src/lib.rs b/src/decoders/trx-wxsat/src/lib.rs index d7ac22d..f2ceca6 100644 --- a/src/decoders/trx-wxsat/src/lib.rs +++ b/src/decoders/trx-wxsat/src/lib.rs @@ -12,6 +12,7 @@ //! - **Meteor-M LRPT** ([`lrpt`]): Low Rate Picture Transmission from //! Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps with CCSDS framing. +pub mod image_enc; pub mod lrpt; pub mod noaa; diff --git a/src/decoders/trx-wxsat/src/lrpt/mcu.rs b/src/decoders/trx-wxsat/src/lrpt/mcu.rs index 706851b..70ba19a 100644 --- a/src/decoders/trx-wxsat/src/lrpt/mcu.rs +++ b/src/decoders/trx-wxsat/src/lrpt/mcu.rs @@ -19,9 +19,6 @@ //! APIDs 65 (R), 65 (G), 68 (B) depending on illumination. use std::collections::BTreeMap; -use std::io::Cursor; - -use image::{DynamicImage, RgbImage}; use super::cadu::Cadu; use super::MeteorSatellite; @@ -168,9 +165,8 @@ impl ChannelAssembler { let ch_g = self.channels.get(&65); let ch_b = self.channels.get(&66); - let mut rgb_pixels: Vec = Vec::with_capacity(npix * 3); - if ch_r.is_some() || ch_g.is_some() || ch_b.is_some() { + let mut rgb_pixels: Vec = Vec::with_capacity(npix * 3); for i in 0..npix { let r = ch_r.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0); let g = ch_g.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0); @@ -179,26 +175,16 @@ impl ChannelAssembler { rgb_pixels.push(g); rgb_pixels.push(b); } + crate::image_enc::encode_rgb_png(width, height, rgb_pixels) } else { // Fallback: grayscale from the first available channel let first_ch = self.channels.values().next()?; + let mut gray_pixels: Vec = Vec::with_capacity(npix); for i in 0..npix { - let v = first_ch.pixels.get(i).copied().unwrap_or(0); - rgb_pixels.push(v); - rgb_pixels.push(v); - rgb_pixels.push(v); + gray_pixels.push(first_ch.pixels.get(i).copied().unwrap_or(0)); } + crate::image_enc::encode_grayscale_png(width, height, gray_pixels) } - - let img = RgbImage::from_raw(width, height, rgb_pixels)?; - let dynamic = DynamicImage::ImageRgb8(img); - - let mut cursor = Cursor::new(Vec::new()); - dynamic - .write_to(&mut cursor, image::ImageOutputFormat::Png) - .ok()?; - - Some(cursor.into_inner()) } pub fn reset(&mut self) { diff --git a/src/decoders/trx-wxsat/src/noaa/image_enc.rs b/src/decoders/trx-wxsat/src/noaa/image_enc.rs index 86cd1ee..6deb901 100644 --- a/src/decoders/trx-wxsat/src/noaa/image_enc.rs +++ b/src/decoders/trx-wxsat/src/noaa/image_enc.rs @@ -2,22 +2,18 @@ // // SPDX-License-Identifier: BSD-2-Clause -//! APT image assembly and JPEG encoding. +//! APT image assembly. //! //! Standard output layout: channel A (visible / IR-A) on the left half and //! channel B (IR-B / IR) on the right half, stacked vertically by line number. -use std::io::Cursor; - -use image::{DynamicImage, GrayImage}; - use super::apt::{RawLine, IMAGE_A_LEN, IMAGE_B_LEN}; -/// Assemble decoded lines into a JPEG image. +/// Assemble decoded APT lines into a PNG image. /// -/// Returns the JPEG bytes, or `None` if `lines` is empty or encoding fails. +/// Returns the PNG bytes, or `None` if `lines` is empty or encoding fails. /// Width = `IMAGE_A_LEN + IMAGE_B_LEN` (1818 px), height = number of lines. -pub fn encode_jpeg(lines: &[RawLine], quality: u8) -> Option> { +pub fn encode_png(lines: &[RawLine]) -> Option> { if lines.is_empty() { return None; } @@ -31,13 +27,5 @@ pub fn encode_jpeg(lines: &[RawLine], quality: u8) -> Option> { pixels.extend_from_slice(line.pixels_b.as_ref()); } - let gray = GrayImage::from_raw(width, height, pixels)?; - let dynamic = DynamicImage::ImageLuma8(gray); - - let mut cursor = Cursor::new(Vec::new()); - dynamic - .write_to(&mut cursor, image::ImageOutputFormat::Jpeg(quality)) - .ok()?; - - Some(cursor.into_inner()) + crate::image_enc::encode_grayscale_png(width, height, pixels) } diff --git a/src/decoders/trx-wxsat/src/noaa/mod.rs b/src/decoders/trx-wxsat/src/noaa/mod.rs index 0c72b87..7663091 100644 --- a/src/decoders/trx-wxsat/src/noaa/mod.rs +++ b/src/decoders/trx-wxsat/src/noaa/mod.rs @@ -26,13 +26,10 @@ pub mod telemetry; use apt::{AptDemod, SyncTracker}; use telemetry::{Satellite, SensorChannel}; -/// JPEG encoding quality (0-100). -const JPEG_QUALITY: u8 = 85; - /// Completed APT image returned by [`AptDecoder::finalize`]. pub struct AptImage { - /// JPEG-encoded image bytes. - pub jpeg: Vec, + /// PNG-encoded image bytes. + pub png: Vec, /// Number of decoded image lines. pub line_count: u32, /// Millisecond timestamp when the first line was decoded. @@ -137,9 +134,9 @@ impl AptDecoder { line.pixels_b.copy_from_slice(&all_b[i * width_b..(i + 1) * width_b]); } - let jpeg = image_enc::encode_jpeg(&lines, JPEG_QUALITY)?; + let png = image_enc::encode_png(&lines)?; Some(AptImage { - jpeg, + png, line_count: lines.len() as u32, first_line_ms: self.first_line_ms.unwrap_or_else(crate::now_ms), satellite, diff --git a/src/trx-core/src/decode.rs b/src/trx-core/src/decode.rs index 69243ae..29390ff 100644 --- a/src/trx-core/src/decode.rs +++ b/src/trx-core/src/decode.rs @@ -211,7 +211,7 @@ pub struct Ft8Message { pub message: String, } -/// A completed weather satellite APT image, saved to disk as a JPEG. +/// A completed weather satellite APT image, saved to disk as a PNG. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WxsatImage { #[serde(skip_serializing_if = "Option::is_none")] @@ -222,7 +222,7 @@ pub struct WxsatImage { pub pass_end_ms: i64, /// Number of decoded image lines. pub line_count: u32, - /// Absolute filesystem path to the saved JPEG file. + /// Absolute filesystem path to the saved PNG file. pub path: String, #[serde(skip_serializing_if = "Option::is_none")] pub ts_ms: Option, diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index bbefd5f..036ae01 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -2453,8 +2453,8 @@ pub async fn run_wspr_decoder( /// /// The task is idle until `state.wxsat_decode_enabled` becomes `true`. /// When the user disables the decoder (or 30 s of silence elapses with no -/// new decoded lines), the accumulated image is encoded as JPEG and saved to -/// `output_dir/.jpg`. +/// new decoded lines), the accumulated image is encoded as PNG and saved to +/// `output_dir/.png`. pub async fn run_wxsat_decoder( sample_rate: u32, channels: u16, @@ -2581,7 +2581,7 @@ pub async fn run_wxsat_decoder( } } -/// Encode all accumulated lines as JPEG, write to disk, and broadcast the +/// Encode all accumulated lines as PNG, write to disk, and broadcast the /// `DecodedMessage::WxsatImage` event. No-ops if fewer than 2 lines decoded. async fn finalize_wxsat_pass( decoder: &mut AptDecoder, @@ -2601,9 +2601,9 @@ async fn finalize_wxsat_pass( return; }; - // Build output path: /.jpg + // Build output path: /.png let dt = chrono::Local::now(); - let filename = dt.format("%Y-%m-%d_%H-%M-%S.jpg").to_string(); + let filename = dt.format("%Y-%m-%d_%H-%M-%S.png").to_string(); let path = output_dir.join(&filename); if let Err(e) = std::fs::create_dir_all(output_dir) { @@ -2615,7 +2615,7 @@ async fn finalize_wxsat_pass( return; } - match std::fs::write(&path, &apt_image.jpeg) { + match std::fs::write(&path, &apt_image.png) { Ok(()) => { let sat_str = format!("{}", apt_image.satellite); let ch_a_str = format!("{}", apt_image.sensor_a); @@ -2624,7 +2624,7 @@ async fn finalize_wxsat_pass( "wxsat: saved {} ({} lines, {} bytes, {}, A={}, B={}) to {:?}", filename, apt_image.line_count, - apt_image.jpeg.len(), + apt_image.png.len(), sat_str, ch_a_str, ch_b_str,