[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:
Generated
-7
@@ -1261,7 +1261,6 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"color_quant",
|
"color_quant",
|
||||||
"jpeg-decoder",
|
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
]
|
]
|
||||||
@@ -1341,12 +1340,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jpeg-decoder"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ edition = "2021"
|
|||||||
trx-core = { path = "../../trx-core" }
|
trx-core = { path = "../../trx-core" }
|
||||||
rustfft = "6"
|
rustfft = "6"
|
||||||
num-complex = "0.4"
|
num-complex = "0.4"
|
||||||
image = { version = "0.24", default-features = false, features = ["jpeg", "png"] }
|
image = { version = "0.24", default-features = false, features = ["png"] }
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
//! - **Meteor-M LRPT** ([`lrpt`]): Low Rate Picture Transmission from
|
//! - **Meteor-M LRPT** ([`lrpt`]): Low Rate Picture Transmission from
|
||||||
//! Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps with CCSDS framing.
|
//! Meteor-M N2-3/N2-4 using QPSK modulation at 72 kbps with CCSDS framing.
|
||||||
|
|
||||||
|
pub mod image_enc;
|
||||||
pub mod lrpt;
|
pub mod lrpt;
|
||||||
pub mod noaa;
|
pub mod noaa;
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,6 @@
|
|||||||
//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination.
|
//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
use image::{DynamicImage, RgbImage};
|
|
||||||
|
|
||||||
use super::cadu::Cadu;
|
use super::cadu::Cadu;
|
||||||
use super::MeteorSatellite;
|
use super::MeteorSatellite;
|
||||||
@@ -168,9 +165,8 @@ impl ChannelAssembler {
|
|||||||
let ch_g = self.channels.get(&65);
|
let ch_g = self.channels.get(&65);
|
||||||
let ch_b = self.channels.get(&66);
|
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() {
|
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 {
|
for i in 0..npix {
|
||||||
let r = ch_r.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0);
|
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);
|
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(g);
|
||||||
rgb_pixels.push(b);
|
rgb_pixels.push(b);
|
||||||
}
|
}
|
||||||
|
crate::image_enc::encode_rgb_png(width, height, rgb_pixels)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: grayscale from the first available channel
|
// Fallback: grayscale from the first available channel
|
||||||
let first_ch = self.channels.values().next()?;
|
let first_ch = self.channels.values().next()?;
|
||||||
|
let mut gray_pixels: Vec<u8> = Vec::with_capacity(npix);
|
||||||
for i in 0..npix {
|
for i in 0..npix {
|
||||||
let v = first_ch.pixels.get(i).copied().unwrap_or(0);
|
gray_pixels.push(first_ch.pixels.get(i).copied().unwrap_or(0));
|
||||||
rgb_pixels.push(v);
|
|
||||||
rgb_pixels.push(v);
|
|
||||||
rgb_pixels.push(v);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
pub fn reset(&mut self) {
|
||||||
|
|||||||
@@ -2,22 +2,18 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// 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
|
//! 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.
|
//! 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};
|
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.
|
/// 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() {
|
if lines.is_empty() {
|
||||||
return None;
|
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());
|
pixels.extend_from_slice(line.pixels_b.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let gray = GrayImage::from_raw(width, height, pixels)?;
|
crate::image_enc::encode_grayscale_png(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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,10 @@ pub mod telemetry;
|
|||||||
use apt::{AptDemod, SyncTracker};
|
use apt::{AptDemod, SyncTracker};
|
||||||
use telemetry::{Satellite, SensorChannel};
|
use telemetry::{Satellite, SensorChannel};
|
||||||
|
|
||||||
/// JPEG encoding quality (0-100).
|
|
||||||
const JPEG_QUALITY: u8 = 85;
|
|
||||||
|
|
||||||
/// Completed APT image returned by [`AptDecoder::finalize`].
|
/// Completed APT image returned by [`AptDecoder::finalize`].
|
||||||
pub struct AptImage {
|
pub struct AptImage {
|
||||||
/// JPEG-encoded image bytes.
|
/// PNG-encoded image bytes.
|
||||||
pub jpeg: Vec<u8>,
|
pub png: Vec<u8>,
|
||||||
/// Number of decoded image lines.
|
/// Number of decoded image lines.
|
||||||
pub line_count: u32,
|
pub line_count: u32,
|
||||||
/// Millisecond timestamp when the first line was decoded.
|
/// 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]);
|
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 {
|
Some(AptImage {
|
||||||
jpeg,
|
png,
|
||||||
line_count: lines.len() as u32,
|
line_count: lines.len() as u32,
|
||||||
first_line_ms: self.first_line_ms.unwrap_or_else(crate::now_ms),
|
first_line_ms: self.first_line_ms.unwrap_or_else(crate::now_ms),
|
||||||
satellite,
|
satellite,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ pub struct Ft8Message {
|
|||||||
pub message: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WxsatImage {
|
pub struct WxsatImage {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -222,7 +222,7 @@ pub struct WxsatImage {
|
|||||||
pub pass_end_ms: i64,
|
pub pass_end_ms: i64,
|
||||||
/// Number of decoded image lines.
|
/// Number of decoded image lines.
|
||||||
pub line_count: u32,
|
pub line_count: u32,
|
||||||
/// Absolute filesystem path to the saved JPEG file.
|
/// Absolute filesystem path to the saved PNG file.
|
||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ts_ms: Option<i64>,
|
pub ts_ms: Option<i64>,
|
||||||
|
|||||||
@@ -2453,8 +2453,8 @@ pub async fn run_wspr_decoder(
|
|||||||
///
|
///
|
||||||
/// The task is idle until `state.wxsat_decode_enabled` becomes `true`.
|
/// 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
|
/// 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
|
/// new decoded lines), the accumulated image is encoded as PNG and saved to
|
||||||
/// `output_dir/<YYYY-MM-DD_HH-MM-SS>.jpg`.
|
/// `output_dir/<YYYY-MM-DD_HH-MM-SS>.png`.
|
||||||
pub async fn run_wxsat_decoder(
|
pub async fn run_wxsat_decoder(
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u16,
|
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.
|
/// `DecodedMessage::WxsatImage` event. No-ops if fewer than 2 lines decoded.
|
||||||
async fn finalize_wxsat_pass(
|
async fn finalize_wxsat_pass(
|
||||||
decoder: &mut AptDecoder,
|
decoder: &mut AptDecoder,
|
||||||
@@ -2601,9 +2601,9 @@ async fn finalize_wxsat_pass(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build output path: <output_dir>/<YYYY-MM-DD_HH-MM-SS>.jpg
|
// Build output path: <output_dir>/<YYYY-MM-DD_HH-MM-SS>.png
|
||||||
let dt = chrono::Local::now();
|
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);
|
let path = output_dir.join(&filename);
|
||||||
|
|
||||||
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
||||||
@@ -2615,7 +2615,7 @@ async fn finalize_wxsat_pass(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match std::fs::write(&path, &apt_image.jpeg) {
|
match std::fs::write(&path, &apt_image.png) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let sat_str = format!("{}", apt_image.satellite);
|
let sat_str = format!("{}", apt_image.satellite);
|
||||||
let ch_a_str = format!("{}", apt_image.sensor_a);
|
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 {:?}",
|
"wxsat: saved {} ({} lines, {} bytes, {}, A={}, B={}) to {:?}",
|
||||||
filename,
|
filename,
|
||||||
apt_image.line_count,
|
apt_image.line_count,
|
||||||
apt_image.jpeg.len(),
|
apt_image.png.len(),
|
||||||
sat_str,
|
sat_str,
|
||||||
ch_a_str,
|
ch_a_str,
|
||||||
ch_b_str,
|
ch_b_str,
|
||||||
|
|||||||
Reference in New Issue
Block a user