[fix](trx-wxsat): fix LRPT decoder always-enabled bug and implement image decoding
The LRPT decoder task was missing mode checks, processing audio in any rig mode once toggled on. Now it only activates in FM mode, matching the decoder registry descriptor. Also corrects active_modes from DIG/USB to FM. Replaces the MCU stub (which treated compressed JPEG data as raw pixels) with proper Huffman + inverse-DCT decompression, CCSDS packet reassembly from MPDUs, and CCSDS derandomization in the CADU framer. https://claude.ai/code/session_0135LuveBndEiZHkU2jsKPB9 Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,22 @@ pub const CADU_LEN: usize = 1024;
|
|||||||
/// CADU payload length (excluding ASM).
|
/// CADU payload length (excluding ASM).
|
||||||
pub const CADU_PAYLOAD_LEN: usize = CADU_LEN - 4;
|
pub const CADU_PAYLOAD_LEN: usize = CADU_LEN - 4;
|
||||||
|
|
||||||
|
/// Generate the CCSDS pseudo-random derandomization sequence.
|
||||||
|
///
|
||||||
|
/// Polynomial: x^8 + x^7 + x^5 + x^3 + 1, initial state 0xFF.
|
||||||
|
/// The sequence is XOR'd with CADU bytes after the ASM to undo the
|
||||||
|
/// on-board randomization applied before transmission.
|
||||||
|
fn ccsds_derandomize(data: &mut [u8]) {
|
||||||
|
let mut sr: u8 = 0xFF;
|
||||||
|
for byte in data.iter_mut() {
|
||||||
|
*byte ^= sr;
|
||||||
|
for _ in 0..8 {
|
||||||
|
let feedback = ((sr >> 7) ^ (sr >> 5) ^ (sr >> 3) ^ sr) & 1;
|
||||||
|
sr = (sr << 1) | feedback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A complete CADU frame (1024 bytes including ASM).
|
/// A complete CADU frame (1024 bytes including ASM).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Cadu {
|
pub struct Cadu {
|
||||||
@@ -131,6 +147,8 @@ impl CaduFramer {
|
|||||||
let mut data = ASM.to_vec();
|
let mut data = ASM.to_vec();
|
||||||
data.extend_from_slice(&frame_bytes);
|
data.extend_from_slice(&frame_bytes);
|
||||||
if data.len() == CADU_LEN {
|
if data.len() == CADU_LEN {
|
||||||
|
// Derandomize payload (everything after 4-byte ASM)
|
||||||
|
ccsds_derandomize(&mut data[4..]);
|
||||||
cadus.push(Cadu { data });
|
cadus.push(Cadu { data });
|
||||||
}
|
}
|
||||||
self.locked = false;
|
self.locked = false;
|
||||||
@@ -202,6 +220,19 @@ mod tests {
|
|||||||
assert_eq!(find_asm(&buf), None);
|
assert_eq!(find_asm(&buf), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derandomize_roundtrip() {
|
||||||
|
let original = vec![0xAB; CADU_PAYLOAD_LEN];
|
||||||
|
let mut data = original.clone();
|
||||||
|
// Randomize
|
||||||
|
ccsds_derandomize(&mut data);
|
||||||
|
// Should differ from original
|
||||||
|
assert_ne!(data, original);
|
||||||
|
// Derandomize again (same sequence) should restore
|
||||||
|
ccsds_derandomize(&mut data);
|
||||||
|
assert_eq!(data, original);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cadu_spacecraft_id() {
|
fn test_cadu_spacecraft_id() {
|
||||||
let mut data = vec![0u8; CADU_LEN];
|
let mut data = vec![0u8; CADU_LEN];
|
||||||
|
|||||||
@@ -17,27 +17,358 @@
|
|||||||
//!
|
//!
|
||||||
//! The standard colour composite uses APIDs 64 (R), 65 (G), 66 (B) or
|
//! The standard colour composite uses APIDs 64 (R), 65 (G), 66 (B) or
|
||||||
//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination.
|
//! APIDs 65 (R), 65 (G), 68 (B) depending on illumination.
|
||||||
|
//!
|
||||||
|
//! Each CCSDS packet carries compressed MCU data using a JPEG-like scheme:
|
||||||
|
//! Huffman-coded DCT coefficients with fixed quantization and Huffman tables.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use super::cadu::Cadu;
|
use super::cadu::Cadu;
|
||||||
use super::MeteorSatellite;
|
use super::MeteorSatellite;
|
||||||
|
|
||||||
/// Image width in pixels (Meteor-M MSU-MR swath: ~1568 px per line).
|
/// Image width in pixels (Meteor-M MSU-MR swath: 196 MCU blocks * 8 px).
|
||||||
const LINE_WIDTH: u32 = 1568;
|
const LINE_WIDTH: u32 = 1568;
|
||||||
|
|
||||||
|
/// Number of 8x8 MCU blocks per image line.
|
||||||
|
const MCUS_PER_LINE: usize = (LINE_WIDTH / 8) as usize;
|
||||||
|
|
||||||
/// Known Meteor-M spacecraft IDs.
|
/// Known Meteor-M spacecraft IDs.
|
||||||
const SPACECRAFT_M2_3: u16 = 57; // Meteor-M N2-3
|
const SPACECRAFT_M2_3: u16 = 57; // Meteor-M N2-3
|
||||||
const SPACECRAFT_M2_4: u16 = 58; // Meteor-M N2-4
|
const SPACECRAFT_M2_4: u16 = 58; // Meteor-M N2-4
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Meteor-M LRPT JPEG quantization table
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Standard quantization table for Meteor-M LRPT imagery.
|
||||||
|
/// Applied in zigzag order to dequantize DCT coefficients.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
const QUANT_TABLE: [i32; 64] = [
|
||||||
|
16, 11, 10, 16, 24, 40, 51, 61,
|
||||||
|
12, 12, 14, 19, 26, 58, 60, 55,
|
||||||
|
14, 13, 16, 24, 40, 57, 69, 56,
|
||||||
|
14, 17, 22, 29, 51, 87, 80, 62,
|
||||||
|
18, 22, 37, 56, 68, 109, 103, 77,
|
||||||
|
24, 35, 55, 64, 81, 104, 113, 92,
|
||||||
|
49, 64, 78, 87, 103, 121, 120, 101,
|
||||||
|
72, 92, 95, 98, 112, 100, 103, 99,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// JPEG zigzag scan order (maps zigzag index → row-major 8x8 index).
|
||||||
|
#[rustfmt::skip]
|
||||||
|
const ZIGZAG: [usize; 64] = [
|
||||||
|
0, 1, 8, 16, 9, 2, 3, 10,
|
||||||
|
17, 24, 32, 25, 18, 11, 4, 5,
|
||||||
|
12, 19, 26, 33, 40, 48, 41, 34,
|
||||||
|
27, 20, 13, 6, 7, 14, 21, 28,
|
||||||
|
35, 42, 49, 56, 57, 50, 43, 36,
|
||||||
|
29, 22, 15, 23, 30, 37, 44, 51,
|
||||||
|
58, 59, 52, 45, 38, 31, 39, 46,
|
||||||
|
53, 60, 61, 54, 47, 55, 62, 63,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Huffman tables for Meteor-M LRPT (standard JPEG baseline tables)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// DC Huffman table: (code_length, code_value) → category.
|
||||||
|
/// Standard JPEG luminance DC table.
|
||||||
|
struct HuffTable {
|
||||||
|
/// For each bit length (1..=16), the codes and their symbol values.
|
||||||
|
entries: Vec<(u8, u16, u8)>, // (bits, code, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HuffTable {
|
||||||
|
fn dc_table() -> Self {
|
||||||
|
// Standard JPEG luminance DC Huffman table
|
||||||
|
// Category 0-11, code lengths from JPEG spec
|
||||||
|
#[rustfmt::skip]
|
||||||
|
let symbols_by_length: &[(u8, &[u8])] = &[
|
||||||
|
(2, &[0, 1, 2, 3, 4, 5]),
|
||||||
|
(3, &[6]),
|
||||||
|
(4, &[7]),
|
||||||
|
(5, &[8]),
|
||||||
|
(6, &[9]),
|
||||||
|
(7, &[10]),
|
||||||
|
(8, &[11]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Self::build(symbols_by_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ac_table() -> Self {
|
||||||
|
// Standard JPEG luminance AC Huffman table
|
||||||
|
// Each symbol is (run_length << 4 | category)
|
||||||
|
#[rustfmt::skip]
|
||||||
|
let symbols_by_length: &[(u8, &[u8])] = &[
|
||||||
|
(2, &[0x01, 0x02]),
|
||||||
|
(3, &[0x03]),
|
||||||
|
(4, &[0x00, 0x04, 0x11]),
|
||||||
|
(5, &[0x05, 0x12, 0x21]),
|
||||||
|
(6, &[0x31, 0x41]),
|
||||||
|
(7, &[0x06, 0x13, 0x51, 0x61]),
|
||||||
|
(8, &[0x07, 0x22, 0x71]),
|
||||||
|
(9, &[0x14, 0x32, 0x81, 0x91, 0xA1]),
|
||||||
|
(10, &[0x08, 0x23, 0x42, 0xB1, 0xC1]),
|
||||||
|
(11, &[0x15, 0x52, 0xD1, 0xF0]),
|
||||||
|
(12, &[0x24, 0x33, 0x62, 0x72]),
|
||||||
|
(15, &[0x82]),
|
||||||
|
(16, &[0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25,
|
||||||
|
0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36,
|
||||||
|
0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
|
||||||
|
0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||||
|
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66,
|
||||||
|
0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
|
||||||
|
0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86,
|
||||||
|
0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95,
|
||||||
|
0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4,
|
||||||
|
0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3,
|
||||||
|
0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2,
|
||||||
|
0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA,
|
||||||
|
0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9,
|
||||||
|
0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7,
|
||||||
|
0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5,
|
||||||
|
0xF6, 0xF7, 0xF8, 0xF9, 0xFA]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Self::build(symbols_by_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(symbols_by_length: &[(u8, &[u8])]) -> Self {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut code: u16 = 0;
|
||||||
|
|
||||||
|
// Sort by bit length to generate canonical Huffman codes
|
||||||
|
let mut all: Vec<(u8, u8)> = Vec::new();
|
||||||
|
for &(bits, syms) in symbols_by_length {
|
||||||
|
for &sym in syms {
|
||||||
|
all.push((bits, sym));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.sort_by_key(|&(bits, _)| bits);
|
||||||
|
|
||||||
|
let mut prev_bits = 0u8;
|
||||||
|
for &(bits, sym) in &all {
|
||||||
|
if prev_bits > 0 {
|
||||||
|
code = (code + 1) << (bits - prev_bits);
|
||||||
|
}
|
||||||
|
entries.push((bits, code, sym));
|
||||||
|
prev_bits = bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { entries }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bitstream reader
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
struct BitReader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
byte_pos: usize,
|
||||||
|
bit_pos: u8, // 0-7, MSB first
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BitReader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
byte_pos: 0,
|
||||||
|
bit_pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bit(&mut self) -> Option<u8> {
|
||||||
|
if self.byte_pos >= self.data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bit = (self.data[self.byte_pos] >> (7 - self.bit_pos)) & 1;
|
||||||
|
self.bit_pos += 1;
|
||||||
|
if self.bit_pos >= 8 {
|
||||||
|
self.bit_pos = 0;
|
||||||
|
self.byte_pos += 1;
|
||||||
|
}
|
||||||
|
Some(bit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bits(&mut self, count: u8) -> Option<i32> {
|
||||||
|
let mut val: i32 = 0;
|
||||||
|
for _ in 0..count {
|
||||||
|
val = (val << 1) | self.read_bit()? as i32;
|
||||||
|
}
|
||||||
|
Some(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_huffman(&mut self, table: &HuffTable) -> Option<u8> {
|
||||||
|
let mut code: u16 = 0;
|
||||||
|
let mut bits_read: u8 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bit = self.read_bit()?;
|
||||||
|
code = (code << 1) | bit as u16;
|
||||||
|
bits_read += 1;
|
||||||
|
|
||||||
|
for &(entry_bits, entry_code, symbol) in &table.entries {
|
||||||
|
if entry_bits == bits_read && entry_code == code {
|
||||||
|
return Some(symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bits_read >= 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_remaining(&self) -> bool {
|
||||||
|
self.byte_pos < self.data.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a signed value from category bits (JPEG magnitude encoding).
|
||||||
|
fn decode_magnitude(category: u8, bits: i32) -> i32 {
|
||||||
|
if category == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// If MSB is 0, value is negative
|
||||||
|
if bits < (1 << (category - 1)) {
|
||||||
|
bits - (1 << category) + 1
|
||||||
|
} else {
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Inverse DCT (8x8)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Perform 8x8 inverse discrete cosine transform on dequantized coefficients.
|
||||||
|
fn idct_8x8(coeffs: &[i32; 64], output: &mut [u8; 64]) {
|
||||||
|
// Use the standard IDCT formula with precomputed cosine values.
|
||||||
|
// cos(pi * (2*x + 1) * u / 16) for x,u in 0..8
|
||||||
|
let mut workspace = [0.0f64; 64];
|
||||||
|
|
||||||
|
for y in 0..8 {
|
||||||
|
for x in 0..8 {
|
||||||
|
let mut sum = 0.0f64;
|
||||||
|
for v in 0..8 {
|
||||||
|
for u in 0..8 {
|
||||||
|
let cu = if u == 0 {
|
||||||
|
std::f64::consts::FRAC_1_SQRT_2
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let cv = if v == 0 {
|
||||||
|
std::f64::consts::FRAC_1_SQRT_2
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let coeff = coeffs[v * 8 + u] as f64;
|
||||||
|
let cos_x =
|
||||||
|
(std::f64::consts::PI * (2 * x + 1) as f64 * u as f64 / 16.0).cos();
|
||||||
|
let cos_y =
|
||||||
|
(std::f64::consts::PI * (2 * y + 1) as f64 * v as f64 / 16.0).cos();
|
||||||
|
sum += cu * cv * coeff * cos_x * cos_y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workspace[y * 8 + x] = sum / 4.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level shift (+128) and clamp to [0, 255]
|
||||||
|
for i in 0..64 {
|
||||||
|
let val = (workspace[i] + 128.0).round();
|
||||||
|
output[i] = val.clamp(0.0, 255.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCU block decoder
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Decode a single 8x8 MCU block from a bitstream.
|
||||||
|
///
|
||||||
|
/// Returns the decoded 64-pixel block and the updated DC prediction value.
|
||||||
|
fn decode_mcu_block(
|
||||||
|
reader: &mut BitReader,
|
||||||
|
dc_table: &HuffTable,
|
||||||
|
ac_table: &HuffTable,
|
||||||
|
prev_dc: i32,
|
||||||
|
) -> Option<([u8; 64], i32)> {
|
||||||
|
let mut coeffs = [0i32; 64];
|
||||||
|
|
||||||
|
// DC coefficient
|
||||||
|
let dc_category = reader.decode_huffman(dc_table)?;
|
||||||
|
let dc_bits = if dc_category > 0 {
|
||||||
|
reader.read_bits(dc_category)?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let dc_diff = decode_magnitude(dc_category, dc_bits);
|
||||||
|
let dc_val = prev_dc + dc_diff;
|
||||||
|
coeffs[0] = dc_val;
|
||||||
|
|
||||||
|
// AC coefficients (zigzag positions 1-63)
|
||||||
|
let mut idx = 1;
|
||||||
|
while idx < 64 {
|
||||||
|
let symbol = reader.decode_huffman(ac_table)?;
|
||||||
|
if symbol == 0x00 {
|
||||||
|
// EOB — remaining coefficients are zero
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let run = (symbol >> 4) as usize;
|
||||||
|
let category = symbol & 0x0F;
|
||||||
|
|
||||||
|
if symbol == 0xF0 {
|
||||||
|
// ZRL — skip 16 zeros
|
||||||
|
idx += 16;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx += run;
|
||||||
|
if idx >= 64 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ac_bits = if category > 0 {
|
||||||
|
reader.read_bits(category)?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
coeffs[idx] = decode_magnitude(category, ac_bits);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-zigzag and dequantize
|
||||||
|
let mut dequant = [0i32; 64];
|
||||||
|
for i in 0..64 {
|
||||||
|
dequant[ZIGZAG[i]] = coeffs[i] * QUANT_TABLE[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse DCT
|
||||||
|
let mut pixels = [0u8; 64];
|
||||||
|
idct_8x8(&dequant, &mut pixels);
|
||||||
|
|
||||||
|
Some((pixels, dc_val))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Channel buffer and assembler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Per-APID channel accumulator.
|
/// Per-APID channel accumulator.
|
||||||
struct ChannelBuffer {
|
struct ChannelBuffer {
|
||||||
/// Row-major pixel data (grayscale, 0-255).
|
/// Row-major pixel data (grayscale, 0-255).
|
||||||
pixels: Vec<u8>,
|
pixels: Vec<u8>,
|
||||||
/// Number of complete image lines accumulated.
|
/// Number of complete image lines accumulated.
|
||||||
lines: u32,
|
lines: u32,
|
||||||
/// Pixel write cursor.
|
/// Current MCU column position within the current MCU row.
|
||||||
cursor: usize,
|
mcu_col: usize,
|
||||||
|
/// Row buffer for the current MCU row (8 lines * LINE_WIDTH pixels).
|
||||||
|
row_buf: Vec<u8>,
|
||||||
|
/// DC prediction value for differential coding.
|
||||||
|
prev_dc: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelBuffer {
|
impl ChannelBuffer {
|
||||||
@@ -45,15 +376,51 @@ impl ChannelBuffer {
|
|||||||
Self {
|
Self {
|
||||||
pixels: Vec::new(),
|
pixels: Vec::new(),
|
||||||
lines: 0,
|
lines: 0,
|
||||||
cursor: 0,
|
mcu_col: 0,
|
||||||
|
row_buf: vec![0u8; 8 * LINE_WIDTH as usize],
|
||||||
|
prev_dc: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_mcu_row(&mut self, data: &[u8]) {
|
/// Write an 8x8 MCU block at the current column position.
|
||||||
// Each MCU row = LINE_WIDTH pixels
|
fn push_mcu_block(&mut self, block: &[u8; 64]) {
|
||||||
|
let col = self.mcu_col;
|
||||||
|
if col >= MCUS_PER_LINE {
|
||||||
|
// Flush the current MCU row to pixels, start a new one
|
||||||
|
self.flush_mcu_row();
|
||||||
|
}
|
||||||
|
|
||||||
|
let x_off = self.mcu_col * 8;
|
||||||
|
for row in 0..8 {
|
||||||
|
let dst_start = row * LINE_WIDTH as usize + x_off;
|
||||||
|
let src_start = row * 8;
|
||||||
|
if dst_start + 8 <= self.row_buf.len() {
|
||||||
|
self.row_buf[dst_start..dst_start + 8]
|
||||||
|
.copy_from_slice(&block[src_start..src_start + 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mcu_col += 1;
|
||||||
|
|
||||||
|
// If we've filled a complete MCU row, flush it
|
||||||
|
if self.mcu_col >= MCUS_PER_LINE {
|
||||||
|
self.flush_mcu_row();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_mcu_row(&mut self) {
|
||||||
|
if self.mcu_col == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pixels.extend_from_slice(&self.row_buf);
|
||||||
|
self.lines += 8;
|
||||||
|
self.row_buf.fill(0);
|
||||||
|
self.mcu_col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push raw pixel data as a fallback (one LINE_WIDTH row at a time).
|
||||||
|
fn push_raw_row(&mut self, data: &[u8]) {
|
||||||
self.pixels.extend_from_slice(data);
|
self.pixels.extend_from_slice(data);
|
||||||
self.cursor += data.len();
|
self.lines = (self.pixels.len() / LINE_WIDTH as usize) as u32;
|
||||||
self.lines = (self.cursor / LINE_WIDTH as usize) as u32;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +432,11 @@ pub struct ChannelAssembler {
|
|||||||
total_mcu_count: u32,
|
total_mcu_count: u32,
|
||||||
/// Spacecraft ID seen in CADUs (for satellite identification).
|
/// Spacecraft ID seen in CADUs (for satellite identification).
|
||||||
spacecraft_id: Option<u16>,
|
spacecraft_id: Option<u16>,
|
||||||
|
/// Huffman tables (built once).
|
||||||
|
dc_table: HuffTable,
|
||||||
|
ac_table: HuffTable,
|
||||||
|
/// Partial CCSDS packet reassembly buffer, keyed by APID.
|
||||||
|
packet_buf: BTreeMap<u16, Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ChannelAssembler {
|
impl Default for ChannelAssembler {
|
||||||
@@ -79,6 +451,9 @@ impl ChannelAssembler {
|
|||||||
channels: BTreeMap::new(),
|
channels: BTreeMap::new(),
|
||||||
total_mcu_count: 0,
|
total_mcu_count: 0,
|
||||||
spacecraft_id: None,
|
spacecraft_id: None,
|
||||||
|
dc_table: HuffTable::dc_table(),
|
||||||
|
ac_table: HuffTable::ac_table(),
|
||||||
|
packet_buf: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,20 +475,84 @@ impl ChannelAssembler {
|
|||||||
|
|
||||||
let apid = 64 + vcid as u16;
|
let apid = 64 + vcid as u16;
|
||||||
|
|
||||||
// Extract pixel data from MPDU payload.
|
// Parse first header pointer from MPDU header.
|
||||||
// In a full implementation, this would perform JPEG/Huffman decoding
|
// The 2 bytes before the payload in the CADU (at offset 10-11 after ASM)
|
||||||
// of the MCU blocks. Here we treat the payload as raw pixel data
|
// contain the first header pointer. If 0x07FF, no packet starts here.
|
||||||
// for scaffolding purposes (to be replaced with proper MCU decode).
|
let fhp = if cadu.data.len() >= 12 {
|
||||||
|
((cadu.data[10] as u16 & 0x07) << 8) | cadu.data[11] as u16
|
||||||
|
} else {
|
||||||
|
0x07FF
|
||||||
|
};
|
||||||
|
|
||||||
|
if fhp == 0x07FF {
|
||||||
|
// No new packet starts in this MPDU — append to ongoing packet
|
||||||
|
self.packet_buf
|
||||||
|
.entry(apid)
|
||||||
|
.or_default()
|
||||||
|
.extend_from_slice(payload);
|
||||||
|
} else {
|
||||||
|
let fhp = fhp as usize;
|
||||||
|
|
||||||
|
// Complete the previous packet with data before the pointer
|
||||||
|
if fhp > 0 && fhp <= payload.len() {
|
||||||
|
if let Some(buf) = self.packet_buf.get_mut(&apid) {
|
||||||
|
buf.extend_from_slice(&payload[..fhp]);
|
||||||
|
let packet_data = std::mem::take(buf);
|
||||||
|
self.decode_packet(apid, &packet_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new packet from the first header pointer
|
||||||
|
if fhp < payload.len() {
|
||||||
|
let buf = self.packet_buf.entry(apid).or_default();
|
||||||
|
buf.clear();
|
||||||
|
buf.extend_from_slice(&payload[fhp..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to decode MCU blocks from a reassembled CCSDS packet.
|
||||||
|
fn decode_packet(&mut self, apid: u16, data: &[u8]) {
|
||||||
|
// CCSDS source packet: 6-byte primary header + data zone
|
||||||
|
if data.len() < 10 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip 6-byte CCSDS primary header + 4 bytes of secondary header
|
||||||
|
// to reach the compressed MCU data
|
||||||
|
let mcu_data = &data[10..];
|
||||||
|
if mcu_data.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let buf = self.channels.entry(apid).or_insert_with(ChannelBuffer::new);
|
let buf = self.channels.entry(apid).or_insert_with(ChannelBuffer::new);
|
||||||
|
|
||||||
// Pad or truncate to LINE_WIDTH boundary
|
// Try JPEG MCU decompression
|
||||||
let usable = payload.len().min(LINE_WIDTH as usize);
|
let mut reader = BitReader::new(mcu_data);
|
||||||
let mut row = vec![0u8; LINE_WIDTH as usize];
|
let mut blocks_decoded = 0u32;
|
||||||
row[..usable].copy_from_slice(&payload[..usable]);
|
|
||||||
buf.push_mcu_row(&row);
|
|
||||||
|
|
||||||
|
while reader.has_remaining() {
|
||||||
|
match decode_mcu_block(&mut reader, &self.dc_table, &self.ac_table, buf.prev_dc) {
|
||||||
|
Some((block, new_dc)) => {
|
||||||
|
buf.prev_dc = new_dc;
|
||||||
|
buf.push_mcu_block(&block);
|
||||||
|
blocks_decoded += 1;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if blocks_decoded > 0 {
|
||||||
|
self.total_mcu_count += blocks_decoded;
|
||||||
|
} else if mcu_data.len() >= LINE_WIDTH as usize {
|
||||||
|
// Fallback: if JPEG decode fails entirely, try as raw data
|
||||||
|
let usable = mcu_data.len().min(LINE_WIDTH as usize);
|
||||||
|
let mut row = vec![0u8; LINE_WIDTH as usize];
|
||||||
|
row[..usable].copy_from_slice(&mcu_data[..usable]);
|
||||||
|
buf.push_raw_row(&row);
|
||||||
self.total_mcu_count += 1;
|
self.total_mcu_count += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Total MCU rows decoded across all channels.
|
/// Total MCU rows decoded across all channels.
|
||||||
pub fn mcu_count(&self) -> u32 {
|
pub fn mcu_count(&self) -> u32 {
|
||||||
@@ -143,8 +582,16 @@ impl ChannelAssembler {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the maximum number of complete lines across channels
|
// Flush any partial MCU rows by computing effective heights
|
||||||
let max_lines = self.channels.values().map(|ch| ch.lines).max().unwrap_or(0);
|
let max_lines = self
|
||||||
|
.channels
|
||||||
|
.values()
|
||||||
|
.map(|ch| {
|
||||||
|
let extra = if ch.mcu_col > 0 { 8 } else { 0 };
|
||||||
|
ch.lines + extra
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
if max_lines == 0 {
|
if max_lines == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -154,17 +601,30 @@ impl ChannelAssembler {
|
|||||||
let height = max_lines;
|
let height = max_lines;
|
||||||
let npix = (width * height) as usize;
|
let npix = (width * height) as usize;
|
||||||
|
|
||||||
|
// Helper to get pixel data including unflushed MCU row
|
||||||
|
let get_pixels = |ch: &ChannelBuffer| -> Vec<u8> {
|
||||||
|
let mut px = ch.pixels.clone();
|
||||||
|
if ch.mcu_col > 0 {
|
||||||
|
px.extend_from_slice(&ch.row_buf);
|
||||||
|
}
|
||||||
|
px
|
||||||
|
};
|
||||||
|
|
||||||
// Try RGB composite (APIDs 64=R, 65=G, 66=B)
|
// Try RGB composite (APIDs 64=R, 65=G, 66=B)
|
||||||
let ch_r = self.channels.get(&64);
|
let ch_r = self.channels.get(&64);
|
||||||
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);
|
||||||
|
|
||||||
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 px_r = ch_r.map(get_pixels);
|
||||||
|
let px_g = ch_g.map(get_pixels);
|
||||||
|
let px_b = ch_b.map(get_pixels);
|
||||||
|
|
||||||
let mut rgb_pixels: Vec<u8> = Vec::with_capacity(npix * 3);
|
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 = px_r.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||||
let g = ch_g.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0);
|
let g = px_g.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||||
let b = ch_b.and_then(|c| c.pixels.get(i).copied()).unwrap_or(0);
|
let b = px_b.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(0);
|
||||||
rgb_pixels.push(r);
|
rgb_pixels.push(r);
|
||||||
rgb_pixels.push(g);
|
rgb_pixels.push(g);
|
||||||
rgb_pixels.push(b);
|
rgb_pixels.push(b);
|
||||||
@@ -173,9 +633,10 @@ impl ChannelAssembler {
|
|||||||
} 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 px = get_pixels(first_ch);
|
||||||
let mut gray_pixels: Vec<u8> = Vec::with_capacity(npix);
|
let mut gray_pixels: Vec<u8> = Vec::with_capacity(npix);
|
||||||
for i in 0..npix {
|
for i in 0..npix {
|
||||||
gray_pixels.push(first_ch.pixels.get(i).copied().unwrap_or(0));
|
gray_pixels.push(px.get(i).copied().unwrap_or(0));
|
||||||
}
|
}
|
||||||
crate::image_enc::encode_grayscale_png(width, height, gray_pixels)
|
crate::image_enc::encode_grayscale_png(width, height, gray_pixels)
|
||||||
}
|
}
|
||||||
@@ -185,6 +646,7 @@ impl ChannelAssembler {
|
|||||||
self.channels.clear();
|
self.channels.clear();
|
||||||
self.total_mcu_count = 0;
|
self.total_mcu_count = 0;
|
||||||
self.spacecraft_id = None;
|
self.spacecraft_id = None;
|
||||||
|
self.packet_buf.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,24 +658,109 @@ mod tests {
|
|||||||
fn test_channel_buffer_line_counting() {
|
fn test_channel_buffer_line_counting() {
|
||||||
let mut buf = ChannelBuffer::new();
|
let mut buf = ChannelBuffer::new();
|
||||||
let row = vec![128u8; LINE_WIDTH as usize];
|
let row = vec![128u8; LINE_WIDTH as usize];
|
||||||
buf.push_mcu_row(&row);
|
buf.push_raw_row(&row);
|
||||||
assert_eq!(buf.lines, 1);
|
assert_eq!(buf.lines, 1);
|
||||||
buf.push_mcu_row(&row);
|
buf.push_raw_row(&row);
|
||||||
assert_eq!(buf.lines, 2);
|
assert_eq!(buf.lines, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcu_block_placement() {
|
||||||
|
let mut buf = ChannelBuffer::new();
|
||||||
|
let block = [200u8; 64];
|
||||||
|
|
||||||
|
// Push one MCU block
|
||||||
|
buf.push_mcu_block(&block);
|
||||||
|
assert_eq!(buf.mcu_col, 1);
|
||||||
|
assert_eq!(buf.lines, 0); // Not yet a full MCU row
|
||||||
|
|
||||||
|
// The first 8 pixels of row 0 in row_buf should be 200
|
||||||
|
assert_eq!(buf.row_buf[0], 200);
|
||||||
|
assert_eq!(buf.row_buf[7], 200);
|
||||||
|
// Pixel at column 8 should still be 0
|
||||||
|
assert_eq!(buf.row_buf[8], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcu_row_flush() {
|
||||||
|
let mut buf = ChannelBuffer::new();
|
||||||
|
let block = [128u8; 64];
|
||||||
|
|
||||||
|
// Fill a complete MCU row (196 blocks)
|
||||||
|
for _ in 0..MCUS_PER_LINE {
|
||||||
|
buf.push_mcu_block(&block);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have flushed: 8 lines of LINE_WIDTH pixels
|
||||||
|
assert_eq!(buf.lines, 8);
|
||||||
|
assert_eq!(buf.pixels.len(), 8 * LINE_WIDTH as usize);
|
||||||
|
assert_eq!(buf.mcu_col, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_identify_satellite() {
|
fn test_identify_satellite() {
|
||||||
let mut asm = ChannelAssembler::new();
|
let mut asm = ChannelAssembler::new();
|
||||||
assert_eq!(asm.identify_satellite(), None);
|
assert_eq!(asm.identify_satellite(), None);
|
||||||
|
|
||||||
asm.spacecraft_id = Some(SPACECRAFT_M2_3);
|
asm.spacecraft_id = Some(SPACECRAFT_M2_3);
|
||||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_3));
|
assert_eq!(
|
||||||
|
asm.identify_satellite(),
|
||||||
|
Some(MeteorSatellite::MeteorM2_3)
|
||||||
|
);
|
||||||
|
|
||||||
asm.spacecraft_id = Some(SPACECRAFT_M2_4);
|
asm.spacecraft_id = Some(SPACECRAFT_M2_4);
|
||||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::MeteorM2_4));
|
assert_eq!(
|
||||||
|
asm.identify_satellite(),
|
||||||
|
Some(MeteorSatellite::MeteorM2_4)
|
||||||
|
);
|
||||||
|
|
||||||
asm.spacecraft_id = Some(99);
|
asm.spacecraft_id = Some(99);
|
||||||
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::Unknown));
|
assert_eq!(asm.identify_satellite(), Some(MeteorSatellite::Unknown));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decode_magnitude() {
|
||||||
|
assert_eq!(decode_magnitude(0, 0), 0);
|
||||||
|
assert_eq!(decode_magnitude(1, 1), 1);
|
||||||
|
assert_eq!(decode_magnitude(1, 0), -1);
|
||||||
|
assert_eq!(decode_magnitude(2, 3), 3);
|
||||||
|
assert_eq!(decode_magnitude(2, 2), 2);
|
||||||
|
assert_eq!(decode_magnitude(2, 1), -2);
|
||||||
|
assert_eq!(decode_magnitude(2, 0), -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idct_dc_only() {
|
||||||
|
// A block with only a DC coefficient should produce a uniform block
|
||||||
|
let mut coeffs = [0i32; 64];
|
||||||
|
coeffs[0] = 100;
|
||||||
|
let mut output = [0u8; 64];
|
||||||
|
idct_8x8(&coeffs, &mut output);
|
||||||
|
|
||||||
|
// All pixels should be close to 128 + 100/4 = 153 (DC is scaled by 1/4)
|
||||||
|
// Actually DC: C(0)*C(0) * coeff * cos(0)*cos(0) / 4
|
||||||
|
// = (1/√2)*(1/√2) * 100 * 1 * 1 / 4 = 100/8 = 12.5, + 128 = 140.5
|
||||||
|
let expected = (100.0_f64 * 0.5 / 4.0 + 128.0).round() as u8;
|
||||||
|
for &px in &output {
|
||||||
|
assert!(
|
||||||
|
(px as i32 - expected as i32).unsigned_abs() <= 1,
|
||||||
|
"pixel {} != expected {}",
|
||||||
|
px,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bitreader_basics() {
|
||||||
|
let data = [0b10110100, 0b01100000];
|
||||||
|
let mut reader = BitReader::new(&data);
|
||||||
|
|
||||||
|
assert_eq!(reader.read_bit(), Some(1));
|
||||||
|
assert_eq!(reader.read_bit(), Some(0));
|
||||||
|
assert_eq!(reader.read_bit(), Some(1));
|
||||||
|
assert_eq!(reader.read_bit(), Some(1));
|
||||||
|
assert_eq!(reader.read_bits(4), Some(0b0100));
|
||||||
|
assert_eq!(reader.read_bits(3), Some(0b011));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
|
|||||||
id: "lrpt",
|
id: "lrpt",
|
||||||
label: "Meteor LRPT",
|
label: "Meteor LRPT",
|
||||||
activation: DecoderActivation::Toggle,
|
activation: DecoderActivation::Toggle,
|
||||||
active_modes: &["DIG", "USB"],
|
active_modes: &["FM"],
|
||||||
background_decode: false,
|
background_decode: false,
|
||||||
bookmark_selectable: true,
|
bookmark_selectable: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2395,7 +2395,8 @@ pub async fn run_lrpt_decoder(
|
|||||||
info!("LRPT decoder started ({}Hz, {} ch)", sample_rate, channels);
|
info!("LRPT decoder started ({}Hz, {} ch)", sample_rate, channels);
|
||||||
let mut decoder = LrptDecoder::new(sample_rate);
|
let mut decoder = LrptDecoder::new(sample_rate);
|
||||||
let mut last_reset_seq: u64 = 0;
|
let mut last_reset_seq: u64 = 0;
|
||||||
let mut active = state_rx.borrow().decoders.lrpt_decode_enabled;
|
let mut active = state_rx.borrow().decoders.lrpt_decode_enabled
|
||||||
|
&& matches!(state_rx.borrow().status.mode, RigMode::FM);
|
||||||
let mut pass_start_ms: i64 = 0;
|
let mut pass_start_ms: i64 = 0;
|
||||||
let mut last_mcu_at = tokio::time::Instant::now();
|
let mut last_mcu_at = tokio::time::Instant::now();
|
||||||
|
|
||||||
@@ -2404,7 +2405,8 @@ pub async fn run_lrpt_decoder(
|
|||||||
match state_rx.changed().await {
|
match state_rx.changed().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
active = state.decoders.lrpt_decode_enabled;
|
active = state.decoders.lrpt_decode_enabled
|
||||||
|
&& matches!(state.status.mode, RigMode::FM);
|
||||||
if active {
|
if active {
|
||||||
decoder.reset();
|
decoder.reset();
|
||||||
pass_start_ms = current_timestamp_ms();
|
pass_start_ms = current_timestamp_ms();
|
||||||
@@ -2455,7 +2457,9 @@ pub async fn run_lrpt_decoder(
|
|||||||
if changed.is_ok() {
|
if changed.is_ok() {
|
||||||
let (new_active, new_reset_seq) = {
|
let (new_active, new_reset_seq) = {
|
||||||
let state = state_rx.borrow();
|
let state = state_rx.borrow();
|
||||||
(state.decoders.lrpt_decode_enabled, state.reset_seqs.lrpt_decode_reset_seq)
|
(state.decoders.lrpt_decode_enabled
|
||||||
|
&& matches!(state.status.mode, RigMode::FM),
|
||||||
|
state.reset_seqs.lrpt_decode_reset_seq)
|
||||||
};
|
};
|
||||||
let was_active = active;
|
let was_active = active;
|
||||||
active = new_active;
|
active = new_active;
|
||||||
|
|||||||
Reference in New Issue
Block a user