Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
205 lines
6.7 KiB
Rust
205 lines
6.7 KiB
Rust
//! Auto-detection of photo events and albums based on time and location proximity
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use crate::error::Result;
|
|
use crate::model::{MediaId, MediaItem};
|
|
|
|
/// Configuration for event detection
|
|
#[derive(Debug, Clone)]
|
|
pub struct EventDetectionConfig {
|
|
/// Maximum time gap between photos in the same event (in seconds)
|
|
pub max_time_gap_secs: i64,
|
|
/// Minimum number of photos to form an event
|
|
pub min_photos: usize,
|
|
/// Maximum distance between photos in the same event (in kilometers)
|
|
/// None means location is not considered
|
|
pub max_distance_km: Option<f64>,
|
|
/// Consider photos on the same day as potentially the same event
|
|
pub same_day_threshold: bool,
|
|
}
|
|
|
|
impl Default for EventDetectionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_time_gap_secs: 2 * 60 * 60, // 2 hours
|
|
min_photos: 5,
|
|
max_distance_km: Some(1.0), // 1km
|
|
same_day_threshold: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A detected photo event/album
|
|
#[derive(Debug, Clone)]
|
|
pub struct DetectedEvent {
|
|
/// Suggested name for the event (e.g., "Photos from 2024-01-15")
|
|
pub suggested_name: String,
|
|
/// Start time of the event
|
|
pub start_time: DateTime<Utc>,
|
|
/// End time of the event
|
|
pub end_time: DateTime<Utc>,
|
|
/// Media items in this event
|
|
pub items: Vec<MediaId>,
|
|
/// Representative location (if available)
|
|
pub location: Option<(f64, f64)>, // (latitude, longitude)
|
|
}
|
|
|
|
/// Calculate Haversine distance between two GPS coordinates in kilometers
|
|
fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
|
|
const EARTH_RADIUS_KM: f64 = 6371.0;
|
|
|
|
let dlat = (lat2 - lat1).to_radians();
|
|
let dlon = (lon2 - lon1).to_radians();
|
|
|
|
let a = (dlat / 2.0).sin().powi(2)
|
|
+ lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
|
|
|
|
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
|
|
|
|
EARTH_RADIUS_KM * c
|
|
}
|
|
|
|
/// Detect photo events from a list of media items
|
|
pub fn detect_events(
|
|
mut items: Vec<MediaItem>,
|
|
config: &EventDetectionConfig,
|
|
) -> Result<Vec<DetectedEvent>> {
|
|
// Filter to only photos with date_taken
|
|
items.retain(|item| item.date_taken.is_some());
|
|
|
|
if items.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Sort by date_taken
|
|
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
|
|
|
let mut events: Vec<DetectedEvent> = Vec::new();
|
|
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
|
let mut current_start_time = items[0].date_taken.unwrap();
|
|
let mut current_last_time = items[0].date_taken.unwrap();
|
|
let mut current_location = items[0].latitude.zip(items[0].longitude);
|
|
|
|
for item in items.iter().skip(1) {
|
|
let item_time = item.date_taken.unwrap();
|
|
let time_gap = (item_time - current_last_time).num_seconds();
|
|
|
|
// Check time gap
|
|
let time_ok = if config.same_day_threshold {
|
|
// Same day or within time gap
|
|
item_time.date_naive() == current_last_time.date_naive()
|
|
|| time_gap <= config.max_time_gap_secs
|
|
} else {
|
|
time_gap <= config.max_time_gap_secs
|
|
};
|
|
|
|
// Check location proximity if both have GPS data
|
|
let location_ok = match (
|
|
config.max_distance_km,
|
|
current_location,
|
|
item.latitude.zip(item.longitude),
|
|
) {
|
|
(Some(max_dist), Some((lat1, lon1)), Some((lat2, lon2))) => {
|
|
let dist = haversine_distance(lat1, lon1, lat2, lon2);
|
|
dist <= max_dist
|
|
}
|
|
// If no location constraint or missing GPS, consider location OK
|
|
_ => true,
|
|
};
|
|
|
|
if time_ok && location_ok {
|
|
// Add to current event
|
|
current_event_items.push(item.id);
|
|
current_last_time = item_time;
|
|
|
|
// Update location to average if available
|
|
if let (Some((lat1, lon1)), Some((lat2, lon2))) =
|
|
(current_location, item.latitude.zip(item.longitude))
|
|
{
|
|
current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0));
|
|
} else if item.latitude.is_some() && item.longitude.is_some() {
|
|
current_location = item.latitude.zip(item.longitude);
|
|
}
|
|
} else {
|
|
// Start new event if current has enough photos
|
|
if current_event_items.len() >= config.min_photos {
|
|
let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d"));
|
|
|
|
events.push(DetectedEvent {
|
|
suggested_name: event_name,
|
|
start_time: current_start_time,
|
|
end_time: current_last_time,
|
|
items: current_event_items.clone(),
|
|
location: current_location,
|
|
});
|
|
}
|
|
|
|
// Reset for new event
|
|
current_event_items = vec![item.id];
|
|
current_start_time = item_time;
|
|
current_last_time = item_time;
|
|
current_location = item.latitude.zip(item.longitude);
|
|
}
|
|
}
|
|
|
|
// Don't forget the last event
|
|
if current_event_items.len() >= config.min_photos {
|
|
let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d"));
|
|
|
|
events.push(DetectedEvent {
|
|
suggested_name: event_name,
|
|
start_time: current_start_time,
|
|
end_time: current_last_time,
|
|
items: current_event_items,
|
|
location: current_location,
|
|
});
|
|
}
|
|
|
|
Ok(events)
|
|
}
|
|
|
|
/// Detect photo bursts (rapid sequences of photos)
|
|
/// Returns groups of media IDs that are likely burst sequences
|
|
pub fn detect_bursts(
|
|
mut items: Vec<MediaItem>,
|
|
max_gap_secs: i64,
|
|
min_burst_size: usize,
|
|
) -> Result<Vec<Vec<MediaId>>> {
|
|
// Filter to only photos with date_taken
|
|
items.retain(|item| item.date_taken.is_some());
|
|
|
|
if items.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Sort by date_taken
|
|
items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap()));
|
|
|
|
let mut bursts: Vec<Vec<MediaId>> = Vec::new();
|
|
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
|
let mut last_time = items[0].date_taken.unwrap();
|
|
|
|
for item in items.iter().skip(1) {
|
|
let item_time = item.date_taken.unwrap();
|
|
let gap = (item_time - last_time).num_seconds();
|
|
|
|
if gap <= max_gap_secs {
|
|
current_burst.push(item.id);
|
|
} else {
|
|
if current_burst.len() >= min_burst_size {
|
|
bursts.push(current_burst.clone());
|
|
}
|
|
current_burst = vec![item.id];
|
|
}
|
|
|
|
last_time = item_time;
|
|
}
|
|
|
|
// Don't forget the last burst
|
|
if current_burst.len() >= min_burst_size {
|
|
bursts.push(current_burst);
|
|
}
|
|
|
|
Ok(bursts)
|
|
}
|