use std::path::Path;
use crate::inlined_compress::cpu_compress_frame;
use crate::inlined_types::{CompressedFrame, Frame};
use serde_json::Value;
#[derive(Debug)]
pub enum BridgeError {
Io(std::io::Error),
InvalidBmp(String),
}
impl std::fmt::Display for BridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BridgeError::Io(e) => write!(f, "IO error: {}", e),
BridgeError::InvalidBmp(msg) => write!(f, "Invalid BMP: {}", msg),
}
}
}
impl std::error::Error for BridgeError {}
impl From<std::io::Error> for BridgeError {
fn from(e: std::io::Error) -> Self {
BridgeError::Io(e)
}
}
pub struct VisionBridge {
prev_hashes: Option<Vec<u64>>,
tile_size: u32,
}
impl VisionBridge {
pub fn new(tile_size: u32) -> Self {
Self {
prev_hashes: None,
tile_size,
}
}
pub fn decode_bmp(path: &Path) -> Result<Frame, BridgeError> {
let data = std::fs::read(path)?;
if data.len() < 54 {
return Err(BridgeError::InvalidBmp("file too small for BMP header".into()));
}
if data[0] != b'B' || data[1] != b'M' {
return Err(BridgeError::InvalidBmp("missing BM signature".into()));
}
let pixel_offset = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize;
let info_size = u32::from_le_bytes([data[14], data[15], data[16], data[17]]);
if info_size < 40 {
return Err(BridgeError::InvalidBmp(
format!("unsupported info header size: {}", info_size),
));
}
let width = i32::from_le_bytes([data[18], data[19], data[20], data[21]]);
let height = i32::from_le_bytes([data[22], data[23], data[24], data[25]]);
if width <= 0 {
return Err(BridgeError::InvalidBmp(
format!("invalid width: {}", width),
));
}
let bottom_up = height > 0;
let abs_height = height.unsigned_abs();
let w = width as u32;
let h = abs_height;
let bits_per_pixel = u16::from_le_bytes([data[28], data[29]]);
if bits_per_pixel != 24 {
return Err(BridgeError::InvalidBmp(
format!("only 24-bit BMP supported, got {}-bit", bits_per_pixel),
));
}
let compression = u32::from_le_bytes([data[30], data[31], data[32], data[33]]);
if compression != 0 {
return Err(BridgeError::InvalidBmp(
format!("only uncompressed BMP supported, compression={}", compression),
));
}
let row_bytes = (w * 3 + 3) & !3;
let expected_pixel_data = (row_bytes as usize) * (h as usize);
if data.len() < pixel_offset + expected_pixel_data {
return Err(BridgeError::InvalidBmp(
format!(
"file too small: need {} bytes of pixel data, have {}",
expected_pixel_data,
data.len() - pixel_offset
),
));
}
let pixel_count = (w as usize) * (h as usize);
let mut bgra = Vec::with_capacity(pixel_count * 4);
for row in 0..h as usize {
let src_row = if bottom_up { (h as usize) - 1 - row } else { row };
let row_start = pixel_offset + src_row * (row_bytes as usize);
for col in 0..w as usize {
let px = row_start + col * 3;
let b = data[px];
let g = data[px + 1];
let r = data[px + 2];
bgra.push(b);
bgra.push(g);
bgra.push(r);
bgra.push(255); }
}
debug_assert_eq!(bgra.len(), pixel_count * 4);
Ok(Frame::new(w, h, bgra))
}
pub fn compress(&mut self, frame: &Frame) -> CompressedFrame {
cpu_compress_frame(frame, &mut self.prev_hashes, self.tile_size)
}
pub fn compress_bmp(&mut self, path: &Path) -> Result<CompressedFrame, BridgeError> {
let frame = Self::decode_bmp(path)?;
Ok(self.compress(&frame))
}
pub fn to_json(compressed: &CompressedFrame) -> Value {
let total = compressed.total_tiles;
let changed = compressed.changed_tiles;
let change_ratio = if total > 0 {
changed as f64 / total as f64
} else {
0.0
};
let tiles: Vec<Value> = compressed
.tiles
.iter()
.map(|t| {
serde_json::json!({
"col": t.col,
"row": t.row,
"mean_color": t.mean_color,
"edge_density": t.edge_density,
"likely_text": t.likely_text,
})
})
.collect();
serde_json::json!({
"total_tiles": total,
"changed_tiles": changed,
"change_ratio": change_ratio,
"tiles": tiles,
})
}
pub fn reset(&mut self) {
self.prev_hashes = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use tempfile::NamedTempFile;
fn write_bmp_24bit(path: &Path, width: u32, height: u32, bgra: &[u8]) {
assert_eq!(bgra.len(), (width as usize) * (height as usize) * 4);
let row_bytes = (width * 3 + 3) & !3;
let pixel_data_size = row_bytes * height;
let file_size = 54 + pixel_data_size;
let mut out = Vec::with_capacity(file_size as usize);
out.extend_from_slice(b"BM");
out.extend_from_slice(&file_size.to_le_bytes());
out.extend_from_slice(&[0u8; 4]); out.extend_from_slice(&54u32.to_le_bytes());
out.extend_from_slice(&40u32.to_le_bytes()); out.extend_from_slice(&(width as i32).to_le_bytes());
out.extend_from_slice(&(height as i32).to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&24u16.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&pixel_data_size.to_le_bytes()); out.extend_from_slice(&[0u8; 16]);
let w = width as usize;
let pad = (row_bytes - width * 3) as usize;
for row in (0..height as usize).rev() {
for col in 0..w {
let px = (row * w + col) * 4;
let b = bgra[px];
let g = bgra[px + 1];
let r = bgra[px + 2];
out.push(b);
out.push(g);
out.push(r);
}
for _ in 0..pad {
out.push(0);
}
}
std::fs::write(path, &out).expect("failed to write BMP");
}
proptest! {
#[test]
fn prop_bmp_decode_round_trip(
width in 1u32..=64,
height in 1u32..=64,
seed in any::<u8>(),
) {
let pixel_count = (width as usize) * (height as usize);
let mut bgra = Vec::with_capacity(pixel_count * 4);
for i in 0..pixel_count {
let v = ((i as u8).wrapping_add(seed)) as u8;
bgra.push(v); bgra.push(v.wrapping_add(1)); bgra.push(v.wrapping_add(2)); bgra.push(255); }
let tmp = NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
write_bmp_24bit(&path, width, height, &bgra);
let frame = VisionBridge::decode_bmp(&path).unwrap();
prop_assert_eq!(frame.width, width);
prop_assert_eq!(frame.height, height);
prop_assert_eq!(frame.data.len(), pixel_count * 4);
for i in 0..pixel_count {
let orig_b = bgra[i * 4];
let orig_g = bgra[i * 4 + 1];
let orig_r = bgra[i * 4 + 2];
let dec_b = frame.data[i * 4];
let dec_g = frame.data[i * 4 + 1];
let dec_r = frame.data[i * 4 + 2];
let dec_a = frame.data[i * 4 + 3];
prop_assert_eq!(dec_b, orig_b, "B mismatch at pixel {}", i);
prop_assert_eq!(dec_g, orig_g, "G mismatch at pixel {}", i);
prop_assert_eq!(dec_r, orig_r, "R mismatch at pixel {}", i);
prop_assert_eq!(dec_a, 255, "A should be 255 at pixel {}", i);
}
}
}
proptest! {
#[test]
fn prop_compressed_frame_json_completeness(
width in 1u32..=128,
height in 1u32..=128,
tile_size_exp in 2u32..=5, seed in any::<u8>(),
) {
let tile_size = 1 << tile_size_exp;
let pixel_count = (width as usize) * (height as usize);
let data: Vec<u8> = (0..pixel_count * 4)
.map(|i| ((i as u8).wrapping_add(seed)))
.collect();
let frame = Frame::new(width, height, data);
let mut bridge = VisionBridge::new(tile_size);
let compressed = bridge.compress(&frame);
let json = VisionBridge::to_json(&compressed);
prop_assert!(json.get("total_tiles").is_some(), "missing total_tiles");
prop_assert!(json.get("changed_tiles").is_some(), "missing changed_tiles");
prop_assert!(json.get("change_ratio").is_some(), "missing change_ratio");
prop_assert!(json.get("tiles").is_some(), "missing tiles");
let tiles = json["tiles"].as_array().unwrap();
prop_assert_eq!(tiles.len(), compressed.tiles.len());
for tile in tiles {
prop_assert!(tile.get("col").is_some(), "tile missing col");
prop_assert!(tile.get("row").is_some(), "tile missing row");
prop_assert!(tile.get("mean_color").is_some(), "tile missing mean_color");
prop_assert!(tile.get("edge_density").is_some(), "tile missing edge_density");
prop_assert!(tile.get("likely_text").is_some(), "tile missing likely_text");
}
let json_total = json["total_tiles"].as_u64().unwrap() as u32;
let json_changed = json["changed_tiles"].as_u64().unwrap() as u32;
prop_assert_eq!(json_total, compressed.total_tiles);
prop_assert_eq!(json_changed, compressed.changed_tiles);
}
}
}