Session.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. # Copyright (c) 2023 Felix Kleinsteuber and Computer Vision Group, Friedrich Schiller University Jena
  2. from datetime import datetime, timedelta
  3. import pickle
  4. import random
  5. import cv2 as cv
  6. import subprocess
  7. from warnings import warn
  8. import os
  9. from tqdm import tqdm
  10. from skimage import transform, io
  11. import IPython.display as display
  12. from py.FileUtils import list_folders, list_jpegs_recursive, verify_expected_subfolders
  13. from py.ImageUtils import display_images, get_image_date
  14. # A session represents the images taken from a single camera trap at a single position.
  15. # Each session has a subfolder in the dataset directory specifying the session name.
  16. # Each session has Lapse, Motion, and Full images, which can be accessed via this class.
  17. class Session:
  18. def __init__(self, folder: str):
  19. self.folder = folder
  20. # session name = folder name[33:], the first 33 characters are always the same
  21. self.name = os.path.basename(folder)[33:]
  22. print(f"Session '{self.name}' at folder: {self.folder}")
  23. assert self.name != ""
  24. verify_expected_subfolders(self.folder)
  25. self.scanned = False
  26. # maps lapse files to their exif dates (for statistic and prediction purposes)
  27. self.lapse_dates = {}
  28. # maps motion files to their exif dates (for statistic purposes)
  29. self.motion_dates = {}
  30. # maps exif dates to lapse files (for prediction purposes)
  31. self.lapse_map = {}
  32. # maps exif dates to motion files (for csv mapping purposes, generated on demand)
  33. self.motion_map = None
  34. self.load_scans()
  35. if not self.scanned:
  36. print("Session not scanned. Run session.scan() to create scan files")
  37. def load_scans(self):
  38. """ Loads scan results (lapse dates, motion dates, lapse map) from files.
  39. Use save_scans() or scan(auto_save=True) to save scan results.
  40. """
  41. lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
  42. motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
  43. lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
  44. lapse_dates_exists = os.path.isfile(lapse_dates_file)
  45. motion_dates_exists = os.path.isfile(motion_dates_file)
  46. lapse_map_exists = os.path.isfile(lapse_map_file)
  47. if lapse_dates_exists and motion_dates_exists and lapse_map_exists:
  48. with open(lapse_dates_file, "rb") as handle:
  49. self.lapse_dates = pickle.load(handle)
  50. with open(motion_dates_file, "rb") as handle:
  51. self.motion_dates = pickle.load(handle)
  52. with open(lapse_map_file, "rb") as handle:
  53. self.lapse_map = pickle.load(handle)
  54. self.scanned = True
  55. print("Loaded scans.")
  56. else:
  57. if not (not lapse_dates_exists and not motion_dates_exists and not lapse_map_exists):
  58. warn(f"Warning: Only partial scan data available. Not loading.")
  59. self.scanned = False
  60. def save_scans(self):
  61. """ Saves scan results (lapse dates, motion dates, lapse map) to files using pickle.
  62. Use load_scans() to load scan results.
  63. The output directory is ./session_scans/{session.name}
  64. """
  65. os.makedirs(os.path.join("session_scans", self.name), exist_ok=True)
  66. lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
  67. motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
  68. lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
  69. with open(lapse_dates_file, "wb") as handle:
  70. pickle.dump(self.lapse_dates, handle, protocol=pickle.HIGHEST_PROTOCOL)
  71. print(f"Saved {lapse_dates_file}")
  72. with open(motion_dates_file, "wb") as handle:
  73. pickle.dump(self.motion_dates, handle, protocol=pickle.HIGHEST_PROTOCOL)
  74. print(f"Saved {motion_dates_file}")
  75. with open(lapse_map_file, "wb") as handle:
  76. pickle.dump(self.lapse_map, handle, protocol=pickle.HIGHEST_PROTOCOL)
  77. print(f"Saved {lapse_map_file}")
  78. def get_lapse_folder(self) -> str:
  79. """Returns the path of the Lapse folder."""
  80. return os.path.join(self.folder, "Lapse")
  81. def get_motion_folder(self) -> str:
  82. """Returns the path of the Motion folder."""
  83. return os.path.join(self.folder, "Motion")
  84. def get_full_folder(self) -> str:
  85. """Returns the path of the Full folder."""
  86. return os.path.join(self.folder, "Full")
  87. def scan(self, force=False, auto_save=True):
  88. """Scans Motion and Lapse images for their EXIF dates. This populates the fields
  89. motion_dates, lapse_dates and motion_map.
  90. Args:
  91. force (bool, optional): Scan even if this session was already scanned. Defaults to False.
  92. auto_save (bool, optional): Save scan results after scan. Defaults to True.
  93. Raises:
  94. ValueError: Session was already scanned and force=False.
  95. """
  96. if self.scanned and not force:
  97. raise ValueError("Session is already scanned. Use force=True to scan anyway and override scan progress.")
  98. # Scan motion dates
  99. print("Scanning motion dates...")
  100. self.motion_dates = {}
  101. motion_folder = self.get_motion_folder()
  102. for file in tqdm(list_jpegs_recursive(motion_folder)):
  103. self.motion_dates[os.path.relpath(file, motion_folder)] = get_image_date(file)
  104. # Scan lapse dates
  105. print("Scanning lapse dates...")
  106. self.lapse_dates = {}
  107. lapse_folder = self.get_lapse_folder()
  108. for file in tqdm(list_jpegs_recursive(lapse_folder)):
  109. self.lapse_dates[os.path.relpath(file, lapse_folder)] = get_image_date(file)
  110. # Create lapse map
  111. print("Creating lapse map...")
  112. self.lapse_map = {}
  113. for file, date in self.lapse_dates.items():
  114. if date in self.lapse_map:
  115. self.lapse_map[date].append(file)
  116. else:
  117. self.lapse_map[date] = [file]
  118. self.scanned = True
  119. # Auto save
  120. if auto_save:
  121. print("Saving...")
  122. self.save_scans()
  123. def check_lapse_duplicates(self):
  124. """Checks the Lapse images for duplicates and prints the results.
  125. A duplicate means there are two or more Lapse images with the same EXIF date.
  126. A multiple means there are three or more such images (includes duplicates).
  127. Deviant duplicate means there are two or more images which have the same EXIF date but are not identical (have different file sizes).
  128. Returns:
  129. total (int), total_duplicates (int), total_multiples (int), deviant_duplicates (int)
  130. """
  131. total = 0
  132. total_duplicates = 0
  133. total_multiples = 0
  134. deviant_duplicates = []
  135. for date, files in tqdm(self.lapse_map.items()):
  136. total += 1
  137. if len(files) > 1:
  138. total_duplicates += 1
  139. file_size = -1
  140. for f in files:
  141. f_size = os.path.getsize(os.path.join(self.folder, "Lapse", f))
  142. if file_size == -1:
  143. file_size = f_size
  144. elif f_size != file_size:
  145. deviant_duplicates.append(date)
  146. break
  147. if len(files) > 2:
  148. total_multiples += 1
  149. deviant_duplicates.sort()
  150. print(f"* {total} lapse dates")
  151. print(f"* {total_duplicates} duplicates")
  152. print(f"* {total_multiples} multiples (more than two files per date)")
  153. print(f"* {len(deviant_duplicates)} deviant duplicates: {deviant_duplicates}")
  154. return total, total_duplicates, total_multiples, deviant_duplicates
  155. def open_images_for_date(self, date: datetime):
  156. """Open all lapse images with the specified EXIF date using the system image viewer.
  157. Args:
  158. date (datetime): Lapse date.
  159. """
  160. img_names = self.lapse_map.get(date, [])
  161. if len(img_names) == 0:
  162. warn("No images for this date!")
  163. for i, img_name in enumerate(img_names):
  164. full_path = os.path.join(self.folder, "Lapse", img_name)
  165. print(f"#{i+1} {full_path}")
  166. subprocess.call(("xdg-open", full_path))
  167. def get_motion_image_from_filename(self, filename: str) -> "MotionImage":
  168. """Returns a MotionImage instance from the filename of a motion image.
  169. Args:
  170. filename (str): File name of motion image.
  171. Raises:
  172. ValueError: Unknown motion file name.
  173. Returns:
  174. MotionImage: MotionImage instance.
  175. """
  176. if filename in self.motion_dates:
  177. return MotionImage(self, filename, self.motion_dates[filename])
  178. else:
  179. raise ValueError(f"Unknown motion file name: {filename}")
  180. def __generate_motion_map(self):
  181. """Populates self.motion_map which maps dates to motion images"""
  182. if self.motion_map is not None:
  183. return
  184. print("Generating motion map...")
  185. self.motion_map = {}
  186. for filename, date in self.motion_dates.items():
  187. if date in self.motion_map:
  188. self.motion_map[date].append(filename)
  189. else:
  190. self.motion_map[date] = [filename]
  191. def get_motion_images_from_date(self, date: datetime):
  192. """Returns MotionImage instances for all motion images with the specified EXIF date.
  193. Args:
  194. date (datetime): Motion date.
  195. """
  196. self.__generate_motion_map()
  197. filenames = self.motion_map.get(date, [])
  198. return [MotionImage(self, filename, date) for filename in filenames]
  199. def get_random_motion_image(self, day_only=False, night_only=False) -> "MotionImage":
  200. """Returns a MotionImage instance of a random Motion image.
  201. Args:
  202. day_only (bool, optional): Only return daytime images. Defaults to False.
  203. night_only (bool, optional): Only return nighttime images. Defaults to False.
  204. Raises:
  205. ValueError: No motion images in this session.
  206. Returns:
  207. MotionImage: Random MotionImage or None if not found
  208. """
  209. if len(self.motion_dates) == 0:
  210. raise ValueError("No motion images in session!")
  211. img = None
  212. while img is None or (day_only and img.is_nighttime()) or (night_only and img.is_daytime()):
  213. filename = random.choice(list(self.motion_dates.keys()))
  214. img = MotionImage(self, filename, self.motion_dates[filename])
  215. return img
  216. def get_random_motion_image_set(self, day_only=False, night_only=False) -> list:
  217. """Returns a list of all motion images with the same date +- 10 min.
  218. The date is picked randomly from all available dates.
  219. May loop indefinitely if there are no matching motion images.
  220. Args:
  221. day_only (bool, optional): Only pick daytime images. Defaults to False.
  222. night_only (bool, optional): Only pick nighttime images. Defaults to False.
  223. Raises:
  224. ValueError: No motion images in session
  225. Returns:
  226. list: Non-empty list of motion images with the same date
  227. """
  228. self.__generate_motion_map()
  229. if len(self.motion_map) == 0:
  230. raise ValueError("No motion images in session!")
  231. imgs = []
  232. date = None
  233. while len(imgs) == 0 or (day_only and imgs[0].is_nighttime()) or (night_only and imgs[0].is_daytime()):
  234. date = random.choice(list(self.motion_map.keys()))
  235. filenames = self.motion_map.get(date, [])
  236. imgs = [MotionImage(self, filename, date) for filename in filenames]
  237. # include all images within +- 5 min
  238. for other_date in self.motion_map.keys():
  239. if date != other_date and abs((date - other_date).total_seconds()) <= 60 * 5:
  240. filenames = self.motion_map.get(other_date, [])
  241. imgs += [MotionImage(self, filename, other_date) for filename in filenames]
  242. return imgs
  243. def generate_motion_image_sets(self) -> list:
  244. """Generator function which yields consecutively taken motion image sets.
  245. Raises:
  246. ValueError: No motion images in this session.
  247. Returns:
  248. list: _description_
  249. Yields:
  250. Iterator[list of MotionImage]: consecutive motion image set
  251. """
  252. self.__generate_motion_map()
  253. if len(self.motion_map) == 0:
  254. raise ValueError("No motion images in session!")
  255. imgs = []
  256. dates = sorted(list(self.motion_map.keys()))
  257. start_date = dates[0]
  258. for date in dates:
  259. if abs((date - start_date).total_seconds()) > 60 * 5:
  260. # end image time series
  261. yield imgs
  262. start_date = date
  263. imgs = []
  264. # continue time series
  265. filenames = self.motion_map.get(date, [])
  266. imgs += [MotionImage(self, filename, date) for filename in filenames]
  267. # end of all time series
  268. yield imgs
  269. def generate_motion_images(self):
  270. """Yields all motion images in this session.
  271. Yields:
  272. MotionImage: A MotionImage
  273. """
  274. for file, date in self.motion_dates.items():
  275. yield MotionImage(self, file, date)
  276. def generate_lapse_images(self):
  277. """Yields all lapse images in this session.
  278. Yields:
  279. LapseImage: A LapseImage
  280. """
  281. for file, date in self.lapse_dates.items():
  282. yield LapseImage(self, file, date)
  283. def get_closest_lapse_images(self, motion_file: str):
  284. """Returns the lapse images taken closest before and after this image, respectively.
  285. If no such image is found, the corresponding returned image will be None.
  286. Args:
  287. motion_file (str): Filename of the motion image
  288. Returns:
  289. (MotionImage or None, MotionImage or None): Closest lapse images. Each image can be None if not found.
  290. """
  291. date: datetime = self.motion_dates[motion_file]
  292. previous_date = date.replace(minute=0, second=0)
  293. next_date = previous_date + timedelta(hours=1)
  294. i = 0
  295. while not previous_date in self.lapse_map:
  296. previous_date -= timedelta(hours=1)
  297. i += 1
  298. if i > 24:
  299. # no previous lapse image exists
  300. previous_date = None
  301. break
  302. i = 0
  303. while not next_date in self.lapse_map:
  304. next_date += timedelta(hours=1)
  305. i += 1
  306. if i > 24:
  307. # no next lapse image exists
  308. next_date = None
  309. break
  310. if previous_date is not None and len(self.lapse_map[previous_date]) > 1:
  311. warn(f"There are multiple lapse images for date {previous_date}! Choosing the first one.")
  312. if next_date is not None and len(self.lapse_map[next_date]) > 1:
  313. warn(f"There are multiple lapse images for date {next_date}! Choosing the first one.")
  314. previous_img = None if previous_date is None else LapseImage(self, self.lapse_map[previous_date][0], previous_date)
  315. next_img = None if next_date is None else LapseImage(self, self.lapse_map[next_date][0], next_date)
  316. return previous_img, next_img
  317. # Abstract class which represents an image in a session (either Motion or Lapse).
  318. class SessionImage:
  319. def __init__(self, session: Session, subfolder: str, filename: str, date: datetime):
  320. self.session = session
  321. self.subfolder = subfolder
  322. self.filename = filename
  323. self.date = date
  324. if not os.path.isfile(self.get_full_path()):
  325. raise ValueError(f"File {subfolder}/{filename} in session folder {session.folder} not found!")
  326. def get_full_path(self) -> str:
  327. """Returns the full path of this image. """
  328. return os.path.join(self.session.folder, self.subfolder, self.filename)
  329. def open(self):
  330. """Open this image using the system image viewer. """
  331. full_path = self.get_full_path()
  332. print(f"Opening {full_path}...")
  333. subprocess.call(("xdg-open", full_path))
  334. def read(self, truncate_y = (40, 40), scale=1, gray=True):
  335. """Read this image into a numpy array.
  336. Args:
  337. truncate_y (tuple, optional): Crop of the image at the top and bottom, respectively. Defaults to (40, 40).
  338. scale (int, optional): Scale factor for rescaling. Defaults to 1.
  339. gray (bool, optional): If True, read the image as grayscale. Defaults to True.
  340. Returns:
  341. np.array: image
  342. """
  343. full_path = self.get_full_path()
  344. img = io.imread(full_path, as_gray=gray)
  345. # truncate
  346. if truncate_y is not None:
  347. if truncate_y[0] > 0 and truncate_y[1] > 0:
  348. img = img[truncate_y[0]:(-truncate_y[1]),:]
  349. elif truncate_y[0] > 0:
  350. img = img[truncate_y[0]:,:]
  351. elif truncate_y[1] > 0:
  352. img = img[:(-truncate_y[1]),:]
  353. # scale
  354. if scale is not None and scale < 1:
  355. img = transform.rescale(img, scale, multichannel=not gray)
  356. return img
  357. def read_opencv(self, truncate_y = (40, 40), scale=1, gray=True):
  358. """Read this image into an OpenCV Mat.
  359. Args:
  360. truncate_y (tuple, optional): Crop of the image at the top and bottom, respectively. Defaults to (40, 40).
  361. scale (int, optional): Scale factor for rescaling. Defaults to 1.
  362. gray (bool, optional): If True, read the image as grayscale. Defaults to True.
  363. Returns:
  364. OpenCV Mat: image
  365. """
  366. full_path = self.get_full_path()
  367. img = cv.imread(full_path)
  368. # grayscale
  369. if gray:
  370. img = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
  371. # truncate
  372. if truncate_y is not None:
  373. if truncate_y[0] > 0 and truncate_y[1] > 0:
  374. img = img[truncate_y[0]:(-truncate_y[1])]
  375. elif truncate_y[0] > 0:
  376. img = img[truncate_y[0]:]
  377. elif truncate_y[1] > 0:
  378. img = img[:(-truncate_y[1])]
  379. # scale
  380. if scale is not None and scale < 1:
  381. img = cv.resize(img, None, fx=scale, fy=scale, interpolation=cv.INTER_LINEAR)
  382. return img
  383. def is_daytime(self):
  384. """Returns True if this image was taken at daytime based on the EXIF date. """
  385. return 6 <= self.date.hour <= 18
  386. def is_nighttime(self):
  387. """Returns True if this image was taken at nighttime based on the EXIF date. """
  388. return not self.is_daytime()
  389. def to_ipython_image(self, width=500, height=None):
  390. """Return an IPython image displaying this image. """
  391. return display.Image(filename=self.get_full_path(), width=width, height=height)
  392. # Represents a single Motion image. Should only be instantiated by Session.
  393. class MotionImage(SessionImage):
  394. def __init__(self, session: Session, filename: str, date: datetime):
  395. super().__init__(session, "Motion", filename, date)
  396. if not self.filename in session.motion_dates:
  397. raise ValueError(f"File name {filename} not in session!")
  398. def get_closest_lapse_images(self):
  399. """ Returns the closest lapse images before and after and the rel-value.
  400. rel is a value between 0 and 1. The close rel is to 0 (1), the closer the motion image is too
  401. the before (after) lapse image. If no lapse images were found, rel is -1.
  402. Returns:
  403. before (LapseImage or None), after (LapseImage or None), rel (float)
  404. """
  405. before, after = self.session.get_closest_lapse_images(self.filename)
  406. rel = -1
  407. # rel = 0 if motion image was taken at before lapse image, rel = 1 if motion image was taken at after lapse image
  408. if before is None and after is not None:
  409. rel = 1
  410. elif before is not None and after is None:
  411. rel = 0
  412. elif before is not None and after is not None:
  413. rel = (self.date - before.date).total_seconds() / (after.date - before.date).total_seconds()
  414. else:
  415. warn("No before and no after image!")
  416. return before, after, rel
  417. # Represents a single Lapse image. Should only be instantiated by Session.
  418. class LapseImage(SessionImage):
  419. def __init__(self, session: Session, filename: str, date: datetime):
  420. super().__init__(session, "Lapse", filename, date)
  421. if not self.filename in session.lapse_dates:
  422. raise ValueError(f"File name {filename} not in session!")