use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PanoInfo {
pub use_panorama_viewer: bool,
pub is_photosphere: bool,
pub view_info: Option<PanoViewInfo>,
pub projection_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PanoViewInfo {
pub horizontal_fov_deg: f64,
pub vertical_fov_deg: f64,
pub center_yaw_deg: f64,
pub center_pitch_deg: f64,
}
pub fn get_pano_info(file_path: &Path, exif: &Value) -> PanoInfo {
let filename_lower = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
let has_pano_in_filename = filename_lower.contains(".pano.");
let projection_type = exif
.get("XMP-GPano:ProjectionType")
.or_else(|| exif.get("GPano:ProjectionType"))
.or_else(|| exif.get("ProjectionType"))
.and_then(|v| v.as_str())
.map(String::from);
let is_equirectangular = projection_type
.clone()
.is_some_and(|s| s.eq_ignore_ascii_case("equirectangular"));
let pano_info: Option<PanoViewInfo> = if is_equirectangular {
parse_partial_pano_info(exif).map_or(
Some(PanoViewInfo {
horizontal_fov_deg: 360.,
vertical_fov_deg: 180.,
center_yaw_deg: 0.,
center_pitch_deg: 0.,
}),
Some,
)
} else {
None
};
let use_panorama_viewer = pano_info.is_some() || has_pano_in_filename;
let is_photosphere = if is_equirectangular {
pano_info.as_ref().is_none_or(|info| {
let is_full_horizontal = (info.horizontal_fov_deg - 360.0).abs() < 0.1;
let is_full_vertical = (info.vertical_fov_deg - 180.0).abs() < 0.1;
is_full_horizontal && is_full_vertical
})
} else {
false
};
PanoInfo {
use_panorama_viewer,
is_photosphere,
projection_type,
view_info: pano_info,
}
}
pub fn parse_partial_pano_info(exif: &Value) -> Option<PanoViewInfo> {
let full_width = exif.get("FullPanoWidthPixels")?.as_f64()?;
let full_height = exif.get("FullPanoHeightPixels")?.as_f64()?;
let cropped_width = exif.get("CroppedAreaImageWidthPixels")?.as_f64()?;
let cropped_height = exif.get("CroppedAreaImageHeightPixels")?.as_f64()?;
let cropped_left = exif.get("CroppedAreaLeftPixels")?.as_f64()?;
let cropped_top = exif.get("CroppedAreaTopPixels")?.as_f64()?;
if full_width == 0.0 || full_height == 0.0 {
return None;
}
let horizontal_fov_deg = (cropped_width / full_width) * 360.0;
let vertical_fov_deg = (cropped_height / full_height) * 180.0;
let center_yaw_deg = ((cropped_left + cropped_width / 2.0) / full_width - 0.5) * 360.0;
let center_pitch_deg = ((cropped_top + cropped_height / 2.0) / full_height - 0.5) * -180.0;
Some(PanoViewInfo {
horizontal_fov_deg,
vertical_fov_deg,
center_yaw_deg,
center_pitch_deg,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
#[test]
fn test_full_photosphere_from_exif() {
let path = Path::new("photosphere.jpg");
let exif_data = json!({
"ProjectionType": "equirectangular"
});
let pano_info = get_pano_info(path, &exif_data);
assert!(
pano_info.is_photosphere,
"Should be a photosphere by default"
);
assert!(pano_info.use_panorama_viewer, "Should use panorama viewer");
assert_eq!(
pano_info.projection_type,
Some("equirectangular".to_string())
);
let view_info = pano_info.view_info.unwrap();
assert_eq!(view_info.horizontal_fov_deg, 360.0);
assert_eq!(view_info.vertical_fov_deg, 180.0);
}
#[test]
fn test_partial_equirectangular_pano_from_exif() {
let path = Path::new("partial_pano.jpg");
let exif_data = json!({
"ProjectionType": "equirectangular",
"FullPanoWidthPixels": 8192,
"FullPanoHeightPixels": 4096,
"CroppedAreaImageWidthPixels": 4096, "CroppedAreaImageHeightPixels": 2048, "CroppedAreaLeftPixels": 2048, "CroppedAreaTopPixels": 1024 });
let pano_info = get_pano_info(path, &exif_data);
assert!(
!pano_info.is_photosphere,
"A partial pano should not be a photosphere"
);
assert!(
pano_info.use_panorama_viewer,
"Should still use a panorama viewer"
);
assert_eq!(
pano_info.projection_type,
Some("equirectangular".to_string())
);
let view_info = pano_info
.view_info
.expect("Should have view info for partial pano");
assert!((view_info.horizontal_fov_deg - 180.0).abs() < 1e-9);
assert!((view_info.vertical_fov_deg - 90.0).abs() < 1e-9);
assert!((view_info.center_yaw_deg - 0.0).abs() < 1e-9);
assert!((view_info.center_pitch_deg - 0.0).abs() < 1e-9);
}
#[test]
fn test_filename_triggers_viewer_without_exif() {
let path = Path::new("some_image.pano.jpg");
let exif_data = json!({});
let pano_info = get_pano_info(path, &exif_data);
assert!(
pano_info.use_panorama_viewer,
"Filename '.pano.' should trigger viewer"
);
assert!(!pano_info.is_photosphere);
assert!(pano_info.projection_type.is_none());
assert!(pano_info.view_info.is_none());
}
#[test]
fn test_regular_image_is_not_a_pano() {
let path = Path::new("sunset.jpg");
let exif_data = json!({});
let pano_info = get_pano_info(path, &exif_data);
assert!(!pano_info.use_panorama_viewer);
assert!(!pano_info.is_photosphere);
assert!(pano_info.projection_type.is_none());
assert!(pano_info.view_info.is_none());
}
#[test]
fn test_parse_partial_fails_if_tag_is_missing() {
let incomplete_exif = json!({
"FullPanoWidthPixels": 8192,
"CroppedAreaImageWidthPixels": 4096,
"CroppedAreaImageHeightPixels": 2048,
"CroppedAreaLeftPixels": 2048,
"CroppedAreaTopPixels": 1024
});
let result = parse_partial_pano_info(&incomplete_exif);
assert!(
result.is_none(),
"Should fail gracefully if a required tag is missing"
);
}
#[test]
fn test_parse_partial_fails_on_division_by_zero() {
let zero_width_exif = json!({
"FullPanoWidthPixels": 0, "FullPanoHeightPixels": 4096,
"CroppedAreaImageWidthPixels": 4096,
"CroppedAreaImageHeightPixels": 2048,
"CroppedAreaLeftPixels": 2048,
"CroppedAreaTopPixels": 1024
});
let result = parse_partial_pano_info(&zero_width_exif);
assert!(
result.is_none(),
"Should fail gracefully on potential division by zero"
);
}
}