Skip to main content

smriti/services/
burst_detector.rs

1//! Burst photo detection - groups near-duplicate photos taken in short sessions
2
3use chrono::{DateTime, Duration, Utc};
4use image::{imageops::FilterType, DynamicImage};
5use rusqlite::Connection;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8
9/// A burst group of photos
10#[derive(Debug, Clone)]
11pub struct BurstGroup {
12    /// Photo IDs in this burst (ordered by time)
13    pub photo_ids: Vec<i64>,
14
15    /// Start timestamp
16    pub start_time: DateTime<Utc>,
17
18    /// End timestamp
19    pub end_time: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone)]
23pub struct BurstProgress {
24    pub processed: u64,
25    pub total: u64,
26    pub groups_found: u64,
27    pub message: String,
28}
29
30/// Burst detection configuration
31#[derive(Debug, Clone)]
32pub struct BurstConfig {
33    /// Maximum time gap between photos in a burst (seconds)
34    pub max_gap_seconds: i64,
35
36    /// Minimum photos to form a burst
37    pub min_photos: usize,
38
39    /// Maximum total span for a burst sequence (seconds)
40    pub max_burst_span_seconds: i64,
41
42    /// Require visual similarity between consecutive shots
43    pub similarity_threshold: f32,
44
45    /// Require photos from the same folder for a burst
46    pub require_same_folder: bool,
47}
48
49impl Default for BurstConfig {
50    fn default() -> Self {
51        // Loosened from "3+ photos in 3s, same folder, 80% similar" to
52        // "2+ photos in 10s anywhere, 65% similar". The strict defaults
53        // matched almost nothing on real-world libraries (one phone
54        // over years, photos organized by month not by burst, varied
55        // subjects). Users who want tighter detection can tune the
56        // window in Settings; require_same_folder is no longer the
57        // default because modern phones group by month, not burst.
58        Self {
59            max_gap_seconds: 10,
60            min_photos: 2,
61            max_burst_span_seconds: 60,
62            similarity_threshold: 0.65,
63            require_same_folder: false,
64        }
65    }
66}
67
68#[derive(Debug, Clone)]
69struct BurstPhotoCandidate {
70    id: i64,
71    date: DateTime<Utc>,
72    file_path: String,
73    file_hash: String,
74    thumbnail_path: Option<String>,
75    signature: Option<Vec<f32>>,
76}
77
78/// Burst detection service
79pub struct BurstDetector {
80    config: BurstConfig,
81}
82
83impl BurstDetector {
84    pub fn new(config: BurstConfig) -> Self {
85        Self { config }
86    }
87
88    /// Find all burst groups in the database.
89    ///
90    /// `thumb_root` should point at the Small thumbnail directory
91    /// (`<drive>/.photovault/thumbnails/small/`). When supplied, the
92    /// signature pass loads the cached 260px thumbnail by file_hash
93    /// instead of re-decoding the original — turning ~1 minute per
94    /// 1000 photos into seconds. Falls back to the original photo
95    /// when the thumb file isn't on disk yet.
96    pub fn find_bursts(
97        &self,
98        conn: &Connection,
99        drive_root: Option<&Path>,
100        thumb_root: Option<&Path>,
101    ) -> rusqlite::Result<Vec<BurstGroup>> {
102        self.find_bursts_with_progress(conn, drive_root, thumb_root, None, |_| {})
103    }
104
105    pub fn find_bursts_with_progress(
106        &self,
107        conn: &Connection,
108        drive_root: Option<&Path>,
109        thumb_root: Option<&Path>,
110        cancel: Option<&AtomicBool>,
111        mut progress: impl FnMut(BurstProgress),
112    ) -> rusqlite::Result<Vec<BurstGroup>> {
113        self.find_bursts_streaming(conn, drive_root, thumb_root, cancel, &mut progress, |_| {})
114    }
115
116    pub fn find_bursts_streaming(
117        &self,
118        conn: &Connection,
119        drive_root: Option<&Path>,
120        thumb_root: Option<&Path>,
121        cancel: Option<&AtomicBool>,
122        mut progress: impl FnMut(BurstProgress),
123        mut on_group: impl FnMut(&BurstGroup),
124    ) -> rusqlite::Result<Vec<BurstGroup>> {
125        // Get all photos ordered by date_taken. Signatures are built lazily
126        // after cheap timestamp/folder checks, so old libraries do not decode
127        // every image just to reject photos taken minutes or days apart.
128        let mut stmt = conn.prepare(
129            r#"
130            SELECT id, date_taken, file_path, file_hash, thumbnail_path
131            FROM photos
132            WHERE date_taken IS NOT NULL AND is_trashed = FALSE
133            ORDER BY date_taken ASC
134            "#,
135        )?;
136
137        let photos: Vec<(i64, String, String, String, Option<String>)> = stmt
138            .query_map([], |row| {
139                Ok((
140                    row.get(0)?,
141                    row.get(1)?,
142                    row.get(2)?,
143                    row.get(3)?,
144                    row.get(4)?,
145                ))
146            })?
147            .collect::<rusqlite::Result<Vec<_>>>()?;
148        drop(stmt);
149
150        if photos.is_empty() {
151            return Ok(Vec::new());
152        }
153
154        let mut photos: Vec<BurstPhotoCandidate> = photos
155            .into_iter()
156            .filter_map(|(id, date_str, file_path, file_hash, thumbnail_path)| {
157                Some(BurstPhotoCandidate {
158                    id,
159                    date: Self::parse_datetime(&date_str)?,
160                    file_path,
161                    file_hash,
162                    thumbnail_path,
163                    signature: None,
164                })
165            })
166            .collect();
167        photos.sort_by_key(|p| (p.date, p.id));
168
169        if photos.is_empty() {
170            return Ok(Vec::new());
171        }
172        let total = photos.len() as u64;
173        progress(BurstProgress {
174            processed: 0,
175            total,
176            groups_found: 0,
177            message: format!("checking {} dated photos", total),
178        });
179
180        let mut groups = Vec::new();
181        let mut current_group: Vec<BurstPhotoCandidate> = Vec::new();
182        let tick = total.div_ceil(40).max(250);
183
184        for (idx, candidate) in photos.into_iter().enumerate() {
185            if cancel
186                .map(|flag| flag.load(Ordering::Relaxed))
187                .unwrap_or(false)
188            {
189                break;
190            }
191
192            if current_group.is_empty() {
193                current_group.push(candidate);
194            } else {
195                let mut candidate = candidate;
196                let should_join = self.should_join_group(
197                    &mut current_group,
198                    &mut candidate,
199                    drive_root,
200                    thumb_root,
201                );
202
203                if should_join {
204                    current_group.push(candidate);
205                } else {
206                    // Finalize current group if candidate doesn't belong
207                    if current_group.len() >= self.config.min_photos {
208                        if let Some(group) = self.finalize_candidate_group(&current_group) {
209                            on_group(&group);
210                            groups.push(group);
211                        }
212                    }
213                    current_group = vec![candidate];
214                }
215            }
216
217            let processed = (idx + 1) as u64;
218            if processed.is_multiple_of(tick) || processed == total {
219                progress(BurstProgress {
220                    processed,
221                    total,
222                    groups_found: groups.len() as u64,
223                    message: format!("{} burst groups so far", groups.len()),
224                });
225            }
226        }
227
228        // Don't forget the last group
229        if !cancel
230            .map(|flag| flag.load(Ordering::Relaxed))
231            .unwrap_or(false)
232            && current_group.len() >= self.config.min_photos
233        {
234            if let Some(group) = self.finalize_candidate_group(&current_group) {
235                on_group(&group);
236                groups.push(group);
237            }
238        }
239
240        Ok(groups)
241    }
242
243    fn finalize_candidate_group(&self, photos: &[BurstPhotoCandidate]) -> Option<BurstGroup> {
244        let first = photos.first()?;
245        let last = photos.last()?;
246        let photo_ids: Vec<i64> = photos.iter().map(|p| p.id).collect();
247
248        Some(BurstGroup {
249            photo_ids,
250            start_time: first.date,
251            end_time: last.date,
252        })
253    }
254
255    fn should_join_group(
256        &self,
257        group: &mut [BurstPhotoCandidate],
258        candidate: &mut BurstPhotoCandidate,
259        drive_root: Option<&Path>,
260        thumb_root: Option<&Path>,
261    ) -> bool {
262        let last = match group.last() {
263            Some(p) => p,
264            None => return true,
265        };
266
267        let gap = candidate.date.signed_duration_since(last.date);
268        if gap > Duration::seconds(self.config.max_gap_seconds) {
269            return false;
270        }
271
272        let start = match group.first() {
273            Some(p) => p.date,
274            None => return true,
275        };
276        let span = candidate.date.signed_duration_since(start);
277        if span > Duration::seconds(self.config.max_burst_span_seconds) {
278            return false;
279        }
280
281        if self.config.require_same_folder
282            && Self::folder_key(&last.file_path) != Self::folder_key(&candidate.file_path)
283        {
284            return false;
285        }
286
287        let Some(last) = group.last_mut() else {
288            return true;
289        };
290        Self::ensure_signature(last, drive_root, thumb_root);
291        Self::ensure_signature(candidate, drive_root, thumb_root);
292
293        if let (Some(a), Some(b)) = (&last.signature, &candidate.signature) {
294            let sim = Self::cosine_similarity(a, b);
295            if sim < self.config.similarity_threshold {
296                return false;
297            }
298        }
299
300        true
301    }
302
303    fn ensure_signature(
304        photo: &mut BurstPhotoCandidate,
305        drive_root: Option<&Path>,
306        thumb_root: Option<&Path>,
307    ) {
308        if photo.signature.is_some() {
309            return;
310        }
311        photo.signature = Self::signature_source_path(drive_root, thumb_root, photo)
312            .and_then(|p| Self::build_signature(&p));
313    }
314
315    fn signature_source_path(
316        drive_root: Option<&Path>,
317        thumb_root: Option<&Path>,
318        photo: &BurstPhotoCandidate,
319    ) -> Option<PathBuf> {
320        let mut candidates = Vec::with_capacity(5);
321        if let (Some(root), Some(path)) = (drive_root, &photo.thumbnail_path) {
322            if let Ok(path) = crate::services::path_util::safe_join_relative(root, path) {
323                candidates.push(path);
324            }
325        }
326
327        if let Some(root) = thumb_root {
328            let subdir = &photo.file_hash[..2.min(photo.file_hash.len())];
329            candidates.push(root.join(subdir).join(format!("{}.jpg", photo.file_hash)));
330        }
331
332        if let Some(root) = drive_root {
333            let subdir = &photo.file_hash[..2.min(photo.file_hash.len())];
334            for size in ["medium", "small", "large"] {
335                candidates.push(
336                    root.join(".photovault")
337                        .join("thumbnails")
338                        .join(size)
339                        .join("v2")
340                        .join(subdir)
341                        .join(format!("{}.jpg", photo.file_hash)),
342                );
343            }
344            if let Ok(path) = crate::services::path_util::safe_join_relative(root, &photo.file_path)
345            {
346                candidates.push(path);
347            }
348        }
349
350        candidates.into_iter().find(|p| p.exists())
351    }
352
353    fn folder_key(file_path: &str) -> String {
354        Path::new(file_path)
355            .parent()
356            .map(|p| p.to_string_lossy().to_string())
357            .unwrap_or_default()
358    }
359
360    fn build_signature(path: &Path) -> Option<Vec<f32>> {
361        // Route HEIC/HEIF through libheif when feature on; otherwise
362        // identical to image::open.
363        let img = crate::services::image_io::open_image(path).ok()?;
364        Some(Self::signature_from_image(&img))
365    }
366
367    fn signature_from_image(img: &DynamicImage) -> Vec<f32> {
368        let gray = img
369            .grayscale()
370            .resize_exact(48, 48, FilterType::Triangle)
371            .to_luma8();
372        let mut sig = Vec::with_capacity(48 * 48);
373        for px in gray.pixels() {
374            sig.push(f32::from(px.0[0]) / 255.0);
375        }
376        sig
377    }
378
379    fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
380        if a.len() != b.len() || a.is_empty() {
381            return 0.0;
382        }
383
384        let mut dot = 0.0f32;
385        let mut a_norm = 0.0f32;
386        let mut b_norm = 0.0f32;
387        for (x, y) in a.iter().zip(b.iter()) {
388            dot += x * y;
389            a_norm += x * x;
390            b_norm += y * y;
391        }
392
393        if a_norm <= f32::EPSILON || b_norm <= f32::EPSILON {
394            return 0.0;
395        }
396
397        dot / (a_norm.sqrt() * b_norm.sqrt())
398    }
399
400    /// Parse datetime string to DateTime<Utc>
401    fn parse_datetime(s: &str) -> Option<DateTime<Utc>> {
402        // Try common formats
403        if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
404            return Some(dt.with_timezone(&Utc));
405        }
406
407        // Try SQLite datetime format: "YYYY-MM-DD HH:MM:SS"
408        if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
409            return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
410        }
411
412        None
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use chrono::Datelike;
420    use image::{DynamicImage, ImageBuffer, Luma};
421    use rusqlite::params;
422
423    #[test]
424    fn test_parse_datetime() {
425        let dt = BurstDetector::parse_datetime("2019-03-15 14:30:22");
426        assert!(dt.is_some());
427
428        let dt = dt.unwrap();
429        assert_eq!(dt.year(), 2019);
430        assert_eq!(dt.month(), 3);
431        assert_eq!(dt.day(), 15);
432    }
433
434    #[test]
435    fn test_similarity_identical_images_is_high() {
436        let img = DynamicImage::ImageLuma8(ImageBuffer::from_fn(32, 32, |_x, _y| Luma([200u8])));
437        let a = BurstDetector::signature_from_image(&img);
438        let b = BurstDetector::signature_from_image(&img);
439        let sim = BurstDetector::cosine_similarity(&a, &b);
440        assert!(sim > 0.999);
441    }
442
443    #[test]
444    fn finalize_candidate_group_ignores_empty_input() {
445        let detector = BurstDetector::new(BurstConfig::default());
446        assert!(detector.finalize_candidate_group(&[]).is_none());
447    }
448
449    #[test]
450    fn bursts_sort_by_parsed_utc_time_not_raw_timestamp_text() {
451        let conn = Connection::open_in_memory().unwrap();
452        conn.execute_batch(
453            r#"
454            CREATE TABLE photos (
455                id INTEGER PRIMARY KEY,
456                date_taken TEXT,
457                file_path TEXT NOT NULL,
458                file_hash TEXT NOT NULL,
459                thumbnail_path TEXT,
460                is_trashed BOOLEAN NOT NULL DEFAULT 0
461            );
462            "#,
463        )
464        .unwrap();
465        conn.execute(
466            "INSERT INTO photos (id, date_taken, file_path, file_hash, is_trashed)
467             VALUES (1, '2024-01-01T00:00:00-09:00', 'a.jpg', 'aa111', 0)",
468            [],
469        )
470        .unwrap();
471        conn.execute(
472            "INSERT INTO photos (id, date_taken, file_path, file_hash, is_trashed)
473             VALUES (2, '2024-01-01T00:00:20+09:00', 'b.jpg', 'bb222', 0)",
474            [],
475        )
476        .unwrap();
477
478        let detector = BurstDetector::new(BurstConfig::default());
479        let groups = detector.find_bursts(&conn, None, None).unwrap();
480
481        assert!(groups.is_empty());
482    }
483
484    #[test]
485    fn bursts_use_db_thumbnail_path_when_small_thumb_is_missing() {
486        let temp = tempfile::tempdir().unwrap();
487        let conn = Connection::open_in_memory().unwrap();
488        conn.execute_batch(
489            r#"
490            CREATE TABLE photos (
491                id INTEGER PRIMARY KEY,
492                date_taken TEXT,
493                file_path TEXT NOT NULL,
494                file_hash TEXT NOT NULL,
495                thumbnail_path TEXT,
496                is_trashed BOOLEAN NOT NULL DEFAULT 0
497            );
498            "#,
499        )
500        .unwrap();
501
502        let thumb_dir = temp.path().join(".photovault/thumbnails/medium/v2/aa");
503        std::fs::create_dir_all(&thumb_dir).unwrap();
504        for (id, hash, value) in [(1, "aa111", 190u8), (2, "aa222", 192u8)] {
505            let thumb = thumb_dir.join(format!("{hash}.jpg"));
506            DynamicImage::ImageLuma8(ImageBuffer::from_fn(32, 32, |_x, _y| Luma([value])))
507                .save(&thumb)
508                .unwrap();
509            conn.execute(
510                "INSERT INTO photos (id, date_taken, file_path, file_hash, thumbnail_path, is_trashed)
511                 VALUES (?1, ?2, ?3, ?4, ?5, 0)",
512                params![
513                    id,
514                    format!("2025-01-01 10:00:0{id}"),
515                    format!("missing/original-{id}.jpg"),
516                    hash,
517                    format!(".photovault/thumbnails/medium/v2/aa/{hash}.jpg")
518                ],
519            )
520            .unwrap();
521        }
522
523        let detector = BurstDetector::new(BurstConfig::default());
524        let groups = detector
525            .find_bursts(
526                &conn,
527                Some(temp.path()),
528                Some(&temp.path().join("missing-small")),
529            )
530            .unwrap();
531
532        assert_eq!(groups.len(), 1);
533        assert_eq!(groups[0].photo_ids, vec![1, 2]);
534    }
535}