[refactor](trx-wxsat): unify image encoding to shared PNG module

Extract common image_enc module at crate root with encode_grayscale_png
and encode_rgb_png helpers. Both NOAA APT and Meteor-M LRPT now use PNG
as the output format through the shared encoder. Drop jpeg image feature
dependency.

https://claude.ai/code/session_01JA13DHuzuHUL4nSBBRU83f
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-28 09:10:07 +00:00
committed by Stan Grams
parent 1a3b815ed8
commit 4d40c29e49
9 changed files with 63 additions and 60 deletions
+1 -1
View File
@@ -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"] }
+38
View File
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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<u8>) -> Option<Vec<u8>> {
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<u8>) -> Option<Vec<u8>> {
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<Vec<u8>> {
let mut cursor = Cursor::new(Vec::new());
img.write_to(&mut cursor, image::ImageOutputFormat::Png)
.ok()?;
Some(cursor.into_inner())
}
+1
View File
@@ -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;
+5 -19
View File
@@ -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<u8> = Vec::with_capacity(npix * 3);
if ch_r.is_some() || ch_g.is_some() || ch_b.is_some() {
let mut rgb_pixels: Vec<u8> = 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<u8> = 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) {
+5 -17
View File
@@ -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<Vec<u8>> {
pub fn encode_png(lines: &[RawLine]) -> Option<Vec<u8>> {
if lines.is_empty() {
return None;
}
@@ -31,13 +27,5 @@ pub fn encode_jpeg(lines: &[RawLine], quality: u8) -> Option<Vec<u8>> {
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)
}
+4 -7
View File
@@ -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<u8>,
/// PNG-encoded image bytes.
pub png: Vec<u8>,
/// 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,