use crate::error::CvError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StitchError {
NoFrames,
DimensionMismatch,
OffsetOutOfBounds,
}
impl std::fmt::Display for StitchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoFrames => write!(f, "no frames supplied for stitching"),
Self::DimensionMismatch => write!(f, "frames have incompatible pixel formats"),
Self::OffsetOutOfBounds => write!(f, "frame offset is out of representable range"),
}
}
}
impl std::error::Error for StitchError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PanoFormat {
#[default]
Flat,
Equirectangular,
}
#[derive(Debug, Clone)]
pub struct StitchFrame {
pub image: Vec<u8>,
pub width: u32,
pub height: u32,
pub offset_x: i64,
pub offset_y: i64,
}
impl StitchFrame {
#[must_use]
pub fn bytes_per_pixel(&self) -> Option<usize> {
let n = (self.width as usize).checked_mul(self.height as usize)?;
if n == 0 || self.image.len() % n != 0 {
return None;
}
Some(self.image.len() / n)
}
}
#[derive(Debug, Clone)]
pub struct StitchedPanorama {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub channels: usize,
pub format: PanoFormat,
}
#[derive(Debug, Clone)]
pub struct PanoramaConfig {
pub blend_width_px: u32,
pub output_format: PanoFormat,
}
impl Default for PanoramaConfig {
fn default() -> Self {
Self {
blend_width_px: 16,
output_format: PanoFormat::Flat,
}
}
}
#[derive(Debug, Clone)]
pub struct PanoramaStitcher {
config: PanoramaConfig,
}
impl PanoramaStitcher {
#[must_use]
pub fn new(config: PanoramaConfig) -> Self {
Self { config }
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(PanoramaConfig::default())
}
#[allow(clippy::cast_precision_loss)]
pub fn stitch(&self, frames: &[StitchFrame]) -> Result<StitchedPanorama, StitchError> {
if frames.is_empty() {
return Err(StitchError::NoFrames);
}
let bpp = frames[0]
.bytes_per_pixel()
.ok_or(StitchError::DimensionMismatch)?;
for f in frames.iter().skip(1) {
if f.bytes_per_pixel() != Some(bpp) {
return Err(StitchError::DimensionMismatch);
}
}
let mut canvas_x0 = i64::MAX;
let mut canvas_y0 = i64::MAX;
let mut canvas_x1 = i64::MIN;
let mut canvas_y1 = i64::MIN;
for f in frames {
let fx1 = f
.offset_x
.checked_add(f.width as i64)
.ok_or(StitchError::OffsetOutOfBounds)?;
let fy1 = f
.offset_y
.checked_add(f.height as i64)
.ok_or(StitchError::OffsetOutOfBounds)?;
canvas_x0 = canvas_x0.min(f.offset_x);
canvas_y0 = canvas_y0.min(f.offset_y);
canvas_x1 = canvas_x1.max(fx1);
canvas_y1 = canvas_y1.max(fy1);
}
let canvas_w = (canvas_x1 - canvas_x0) as u32;
let canvas_h = (canvas_y1 - canvas_y0) as u32;
if canvas_w == 0 || canvas_h == 0 {
return Err(StitchError::OffsetOutOfBounds);
}
let n_px = (canvas_w as usize) * (canvas_h as usize);
let mut accum: Vec<f32> = vec![0.0; n_px * bpp];
let mut weight: Vec<f32> = vec![0.0; n_px];
let blend_w = self.config.blend_width_px as usize;
for frame in frames {
let fw = frame.width as usize;
let fh = frame.height as usize;
let cx0 = (frame.offset_x - canvas_x0) as usize;
let cy0 = (frame.offset_y - canvas_y0) as usize;
for fy in 0..fh {
for fx in 0..fw {
let frame_px_idx = fy * fw + fx;
let canvas_x = cx0 + fx;
let canvas_y = cy0 + fy;
let canvas_px_idx = canvas_y * (canvas_w as usize) + canvas_x;
let dist_left = fx;
let dist_right = fw.saturating_sub(1).saturating_sub(fx);
let dist_min = dist_left.min(dist_right);
let alpha = if blend_w == 0 {
1.0f32
} else {
(dist_min as f32 / blend_w as f32).clamp(0.0, 1.0)
};
let frame_base = frame_px_idx * bpp;
let canvas_base = canvas_px_idx * bpp;
for c in 0..bpp {
let val = f32::from(frame.image[frame_base + c]);
accum[canvas_base + c] += val * alpha;
}
weight[canvas_px_idx] += alpha;
}
}
}
let mut data = vec![0u8; n_px * bpp];
for px in 0..n_px {
let w = weight[px];
for c in 0..bpp {
let v = if w > 1e-6 {
accum[px * bpp + c] / w
} else {
0.0
};
data[px * bpp + c] = v.round().clamp(0.0, 255.0) as u8;
}
}
Ok(StitchedPanorama {
data,
width: canvas_w,
height: canvas_h,
channels: bpp,
format: self.config.output_format,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn flat_config(blend: u32) -> PanoramaConfig {
PanoramaConfig {
blend_width_px: blend,
output_format: PanoFormat::Flat,
}
}
fn solid_frame(w: u32, h: u32, ox: i64, oy: i64, val: u8) -> StitchFrame {
StitchFrame {
image: vec![val; (w * h) as usize],
width: w,
height: h,
offset_x: ox,
offset_y: oy,
}
}
#[test]
fn test_no_frames_error() {
let stitcher = PanoramaStitcher::new(flat_config(0));
assert_eq!(stitcher.stitch(&[]).unwrap_err(), StitchError::NoFrames);
}
#[test]
fn test_single_frame_passthrough() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame = solid_frame(10, 8, 0, 0, 200);
let pano = stitcher
.stitch(&[frame])
.expect("single frame should succeed");
assert_eq!(pano.width, 10);
assert_eq!(pano.height, 8);
assert_eq!(pano.data.len(), 10 * 8);
assert!(pano.data.iter().all(|&p| p == 200), "pixel mismatch");
}
#[test]
fn test_single_frame_negative_offset() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame = solid_frame(5, 5, -3, -2, 128);
let pano = stitcher
.stitch(&[frame])
.expect("negative offset should work");
assert_eq!(pano.width, 5);
assert_eq!(pano.height, 5);
}
#[test]
fn test_two_frames_no_overlap() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame_a = solid_frame(10, 5, 0, 0, 100);
let frame_b = solid_frame(10, 5, 10, 0, 200);
let pano = stitcher
.stitch(&[frame_a, frame_b])
.expect("should succeed");
assert_eq!(pano.width, 20);
assert_eq!(pano.height, 5);
for y in 0..5usize {
for x in 0..10usize {
assert_eq!(pano.data[y * 20 + x], 100, "left half");
assert_eq!(pano.data[y * 20 + x + 10], 200, "right half");
}
}
}
#[test]
fn test_two_frames_with_overlap_blend() {
let stitcher = PanoramaStitcher::new(flat_config(4));
let frame_a = solid_frame(10, 4, 0, 0, 100);
let frame_b = solid_frame(10, 4, 6, 0, 200);
let pano = stitcher
.stitch(&[frame_a, frame_b])
.expect("blend should succeed");
assert_eq!(pano.width, 16);
assert_eq!(pano.height, 4);
let overlap_px = pano.data[0 * 16 + 7]; assert!(
overlap_px > 100 && overlap_px < 200,
"expected blended value in overlap, got {overlap_px}"
);
}
#[test]
fn test_equirectangular_format_preserved() {
let cfg = PanoramaConfig {
blend_width_px: 0,
output_format: PanoFormat::Equirectangular,
};
let stitcher = PanoramaStitcher::new(cfg);
let frame = solid_frame(8, 4, 0, 0, 128);
let pano = stitcher.stitch(&[frame]).expect("should succeed");
assert_eq!(pano.format, PanoFormat::Equirectangular);
}
#[test]
fn test_rgb_frames() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let rgb_pixels = vec![255u8, 0, 0].repeat(8 * 8); let frame = StitchFrame {
image: rgb_pixels,
width: 8,
height: 8,
offset_x: 0,
offset_y: 0,
};
let pano = stitcher.stitch(&[frame]).expect("RGB should work");
assert_eq!(pano.channels, 3);
assert_eq!(pano.data.len(), 8 * 8 * 3);
}
#[test]
fn test_dimension_mismatch_error() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame_gray = solid_frame(4, 4, 0, 0, 128);
let frame_rgb = StitchFrame {
image: vec![255u8; 4 * 4 * 3],
width: 4,
height: 4,
offset_x: 4,
offset_y: 0,
};
assert_eq!(
stitcher.stitch(&[frame_gray, frame_rgb]).unwrap_err(),
StitchError::DimensionMismatch
);
}
#[test]
fn test_vertical_stack() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let top = solid_frame(6, 4, 0, 0, 50);
let bottom = solid_frame(6, 4, 0, 4, 150);
let pano = stitcher.stitch(&[top, bottom]).expect("vertical stack");
assert_eq!(pano.width, 6);
assert_eq!(pano.height, 8);
for x in 0..6usize {
assert_eq!(pano.data[0 * 6 + x], 50, "top row");
assert_eq!(pano.data[7 * 6 + x], 150, "bottom row");
}
}
#[test]
fn test_blend_width_zero_is_hard_edge() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame_a = solid_frame(8, 4, 0, 0, 80);
let frame_b = solid_frame(8, 4, 4, 0, 160);
let pano = stitcher.stitch(&[frame_a, frame_b]).expect("hard edge");
let overlap_px = pano.data[0 * 12 + 4];
assert_eq!(overlap_px, 120, "expected averaged overlap pixel");
}
#[test]
fn test_canvas_size_with_offset() {
let stitcher = PanoramaStitcher::new(flat_config(0));
let frame = StitchFrame {
image: vec![255u8; 5 * 5],
width: 5,
height: 5,
offset_x: 10,
offset_y: 20,
};
let pano = stitcher.stitch(&[frame]).expect("offset frame");
assert_eq!(pano.width, 5);
assert_eq!(pano.height, 5);
}
}