use std::os::unix::fs::MetadataExt; use std::path::PathBuf; use std::time::Duration; use anyhow::Context; use chrono::{NaiveDateTime, Utc}; use image::{GenericImageView, Pixel}; use log::{debug, error, info}; /// Folder the camera images are uploaded to const FOLDER_WITH_NEW_IMAGES: &str = "/dev/shm/camera"; /// File created in the uploads directory when the upload finishes. const COMMIT_FILENAME : &str = "commit"; /// Folder where the cropped bird pics are saved const FOLDER_WITH_BIRD_IMAGES: &str = "/backup/krmitko"; /// Image cropping config: left top X const CROP_X : u32 = 1872; /// Image cropping config: left top Y const CROP_Y : u32 = 1329; /// Image cropping config: crop width const CROP_W : u32 = 1000; /// Image cropping config: crop height const CROP_H : u32 = 750; /// Thumbnail width, used for bird detection (together with height creates the kernel size) /// This is a compromise between sensitivity and susceptibility to false positives. const THUMB_W: usize = 12; /// Thumbnail height const THUMB_H: usize = 8; /// Threshold for bird detection. /// Bird is detected if the sum of deviations from a median value in a picture exceeds this threshold. const THRESHOLD_DEVIATION: u32 = 500; fn main() { env_logger::init(); let commit_file = PathBuf::from(FOLDER_WITH_NEW_IMAGES).join(COMMIT_FILENAME); loop { if !commit_file.exists() { debug!("No commit file, wait."); std::thread::sleep(Duration::from_secs(10)); continue; } if let Err(e) = process_camera_pics() { error!("Error processing pics: {e}"); } let _ = std::fs::remove_dir_all(FOLDER_WITH_NEW_IMAGES); } } fn process_camera_pics() -> anyhow::Result<()> { info!("Processing camera pics"); let paths = std::fs::read_dir(FOLDER_WITH_NEW_IMAGES).context("Read folder with images")?; let mut crops = vec![]; let mut thumbs = vec![]; let mut timestamps = vec![]; info!("Loading files"); for dir_entry in paths { let Ok(dir_entry) = dir_entry else { continue; }; let Ok(meta) = dir_entry.metadata() else { continue; }; if !meta.is_file() || !dir_entry.file_name().to_string_lossy().ends_with(".jpg") { continue; } debug!("{}", dir_entry.path().display()); let Ok(img) = image::open(dir_entry.path()) else { continue; }; // File loaded fine, assume it's OK timestamps.push(meta.ctime()); let crop = img.crop_imm(CROP_X, CROP_Y, CROP_W, CROP_H); let thumb = crop.thumbnail(THUMB_W as u32, THUMB_H as u32); crops.push(crop); let mut thumb_ar = [0u8; THUMB_H * THUMB_W]; for y in 0..thumb.height() { for x in 0..thumb.width() { thumb_ar[(y * thumb.width() + x) as usize] = thumb.get_pixel(x, y).to_luma().0[0]; } } thumbs.push(thumb_ar); } info!("Computing digests & medians"); let mut transposed = vec![]; for thumb in thumbs.iter() { for (b, item) in thumb.iter().enumerate() { if transposed.len() <= b { transposed.push(vec![]); } transposed[b].push(*item); } } let mut medians = [0; THUMB_H * THUMB_W]; for (a, column) in transposed.iter().enumerate() { medians[a] = column[column.len() / 2]; // approximately median ... } debug!("Medians: {:?}", medians); info!("Finding deviations"); for (thumb_num, thumb) in thumbs.iter().enumerate() { let deviation = thumb.iter().zip(medians.iter()) .map(|(a, b)| (*a as i16 - *b as i16).abs() as u32).fold(0u32, |a, b| a + b); debug!("Thumb {thumb_num} summary diff from median is: {deviation}"); if deviation > THRESHOLD_DEVIATION { info!("LIKELY BIRD!!!! in picture #{thumb_num}"); let datetime_str = parse_unix_ts(timestamps[thumb_num]).format("%Y-%m-%d_%H-%M-%S"); let path = format!("{}/{}.jpg", FOLDER_WITH_BIRD_IMAGES, datetime_str); let mut pb = PathBuf::from(path); // Ensure we do not overwrite a file if there are multiple in the same second! // This adds -2, -3 etc. to the file name let mut cnt = 2; while pb.exists() { pb.set_file_name(format!("{}-{cnt}.jpg", datetime_str)); cnt += 1; } if let Err(e) = crops[thumb_num].save(pb) { error!("Fail to save pic: {e}"); } } } Ok(()) } fn parse_unix_ts(ts: i64) -> chrono::DateTime { chrono::DateTime::::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(ts, 0) .expect("Bad timestamp"), Utc) }