Session.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. from datetime import datetime, timedelta
  2. import pickle
  3. import random
  4. import cv2 as cv
  5. import subprocess
  6. from warnings import warn
  7. import os
  8. from tqdm import tqdm
  9. import matplotlib.image as mpimg
  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. class Session:
  15. def __init__(self, folder: str):
  16. self.folder = folder
  17. # session name = folder name[33:], the first 33 characters are always the same
  18. self.name = os.path.basename(folder)[33:]
  19. print(f"Session '{self.name}' at folder: {self.folder}")
  20. assert self.name != ""
  21. verify_expected_subfolders(self.folder)
  22. self.scanned = False
  23. # maps lapse files to their exif dates (for statistic and prediction purposes)
  24. self.lapse_dates = {}
  25. # maps motion files to their exif dates (for statistic purposes)
  26. self.motion_dates = {}
  27. # maps exif dates to lapse files (for prediction purposes)
  28. self.lapse_map = {}
  29. # maps exif dates to motion files (for csv mapping purposes, generated on demand)
  30. self.motion_map = None
  31. self.load_scans()
  32. if not self.scanned:
  33. print("Session not scanned. Run session.scan() to create scan files")
  34. def load_scans(self):
  35. lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
  36. motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
  37. lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
  38. lapse_dates_exists = os.path.isfile(lapse_dates_file)
  39. motion_dates_exists = os.path.isfile(motion_dates_file)
  40. lapse_map_exists = os.path.isfile(lapse_map_file)
  41. if lapse_dates_exists and motion_dates_exists and lapse_map_exists:
  42. with open(lapse_dates_file, "rb") as handle:
  43. self.lapse_dates = pickle.load(handle)
  44. with open(motion_dates_file, "rb") as handle:
  45. self.motion_dates = pickle.load(handle)
  46. with open(lapse_map_file, "rb") as handle:
  47. self.lapse_map = pickle.load(handle)
  48. self.scanned = True
  49. print("Loaded scans.")
  50. else:
  51. if not (not lapse_dates_exists and not motion_dates_exists and not lapse_map_exists):
  52. warn(f"Warning: Only partial scan data available. Not loading.")
  53. self.scanned = False
  54. def save_scans(self):
  55. os.makedirs(os.path.join("session_scans", self.name), exist_ok=True)
  56. lapse_dates_file = os.path.join("session_scans", self.name, "lapse_dates.pickle")
  57. motion_dates_file = os.path.join("session_scans", self.name, "motion_dates.pickle")
  58. lapse_map_file = os.path.join("session_scans", self.name, "lapse_map.pickle")
  59. with open(lapse_dates_file, "wb") as handle:
  60. pickle.dump(self.lapse_dates, handle, protocol=pickle.HIGHEST_PROTOCOL)
  61. print(f"Saved {lapse_dates_file}")
  62. with open(motion_dates_file, "wb") as handle:
  63. pickle.dump(self.motion_dates, handle, protocol=pickle.HIGHEST_PROTOCOL)
  64. print(f"Saved {motion_dates_file}")
  65. with open(lapse_map_file, "wb") as handle:
  66. pickle.dump(self.lapse_map, handle, protocol=pickle.HIGHEST_PROTOCOL)
  67. print(f"Saved {lapse_map_file}")
  68. def get_lapse_folder(self) -> str:
  69. return os.path.join(self.folder, "Lapse")
  70. def get_motion_folder(self) -> str:
  71. return os.path.join(self.folder, "Motion")
  72. def get_full_folder(self) -> str:
  73. return os.path.join(self.folder, "Full")
  74. def scan(self, force=False, auto_save=True):
  75. if self.scanned and not force:
  76. raise ValueError("Session is already scanned. Use force=True to scan anyway and override scan progress.")
  77. # Scan motion dates
  78. print("Scanning motion dates...")
  79. self.motion_dates = {}
  80. motion_folder = self.get_motion_folder()
  81. for file in tqdm(list_jpegs_recursive(motion_folder)):
  82. self.motion_dates[os.path.relpath(file, motion_folder)] = get_image_date(file)
  83. # Scan lapse dates
  84. print("Scanning lapse dates...")
  85. self.lapse_dates = {}
  86. lapse_folder = self.get_lapse_folder()
  87. for file in tqdm(list_jpegs_recursive(lapse_folder)):
  88. self.lapse_dates[os.path.relpath(file, lapse_folder)] = get_image_date(file)
  89. # Create lapse map
  90. print("Creating lapse map...")
  91. self.lapse_map = {}
  92. for file, date in self.lapse_dates.items():
  93. if date in self.lapse_map:
  94. self.lapse_map[date].append(file)
  95. else:
  96. self.lapse_map[date] = [file]
  97. self.scanned = True
  98. # Auto save
  99. if auto_save:
  100. print("Saving...")
  101. self.save_scans()
  102. def check_lapse_duplicates(self) -> bool:
  103. total = 0
  104. total_duplicates = 0
  105. total_multiples = 0
  106. deviant_duplicates = []
  107. for date, files in tqdm(self.lapse_map.items()):
  108. total += 1
  109. if len(files) > 1:
  110. total_duplicates += 1
  111. file_size = -1
  112. for f in files:
  113. f_size = os.path.getsize(os.path.join(self.folder, "Lapse", f))
  114. if file_size == -1:
  115. file_size = f_size
  116. elif f_size != file_size:
  117. deviant_duplicates.append(date)
  118. break
  119. if len(files) > 2:
  120. total_multiples += 1
  121. deviant_duplicates.sort()
  122. print(f"* {total} lapse dates")
  123. print(f"* {total_duplicates} duplicates")
  124. print(f"* {total_multiples} multiples (more than two files per date)")
  125. print(f"* {len(deviant_duplicates)} deviant duplicates: {deviant_duplicates}")
  126. return total, total_duplicates, total_multiples, deviant_duplicates
  127. def open_images_for_date(self, date: datetime):
  128. img_names = self.lapse_map.get(date, [])
  129. if len(img_names) == 0:
  130. warn("No images for this date!")
  131. for i, img_name in enumerate(img_names):
  132. full_path = os.path.join(self.folder, "Lapse", img_name)
  133. print(f"#{i+1} {full_path}")
  134. subprocess.call(("xdg-open", full_path))
  135. def get_motion_image_from_filename(self, filename: str) -> "MotionImage":
  136. if filename in self.motion_dates:
  137. return MotionImage(self, filename, self.motion_dates[filename])
  138. else:
  139. raise ValueError(f"Unknown motion file name: {filename}")
  140. def __generate_motion_map(self):
  141. """Populates self.motion_map which maps dates to motion images
  142. """
  143. if self.motion_map is not None:
  144. return
  145. print("Generating motion map...")
  146. self.motion_map = {}
  147. for filename, date in self.motion_dates.items():
  148. if date in self.motion_map:
  149. self.motion_map[date].append(filename)
  150. else:
  151. self.motion_map[date] = [filename]
  152. def get_motion_images_from_date(self, date: datetime):
  153. self.__generate_motion_map()
  154. filenames = self.motion_map.get(date, [])
  155. return [MotionImage(self, filename, date) for filename in filenames]
  156. def get_random_motion_image(self, day_only=False, night_only=False) -> "MotionImage":
  157. if len(self.motion_dates) == 0:
  158. raise ValueError("No motion images in session!")
  159. img = None
  160. while img is None or (day_only and img.is_nighttime()) or (night_only and img.is_daytime()):
  161. filename = random.choice(list(self.motion_dates.keys()))
  162. img = MotionImage(self, filename, self.motion_dates[filename])
  163. return img
  164. def get_random_motion_image_set(self, day_only=False, night_only=False) -> list:
  165. """Returns a list of all motion images with the same date +- 10 min.
  166. The date is picked randomly from all available dates.
  167. May loop indefinitely if there are no matching motion images.
  168. Args:
  169. day_only (bool, optional): Only pick daytime images. Defaults to False.
  170. night_only (bool, optional): Only pick nighttime images. Defaults to False.
  171. Raises:
  172. ValueError: No motion images in session
  173. Returns:
  174. list: Non-empty list of motion images with the same date
  175. """
  176. self.__generate_motion_map()
  177. if len(self.motion_map) == 0:
  178. raise ValueError("No motion images in session!")
  179. imgs = []
  180. date = None
  181. while len(imgs) == 0 or (day_only and imgs[0].is_nighttime()) or (night_only and imgs[0].is_daytime()):
  182. date = random.choice(list(self.motion_map.keys()))
  183. filenames = self.motion_map.get(date, [])
  184. imgs = [MotionImage(self, filename, date) for filename in filenames]
  185. # include all images within +- 5 min
  186. for other_date in self.motion_map.keys():
  187. if date != other_date and abs((date - other_date).total_seconds()) <= 60 * 5:
  188. filenames = self.motion_map.get(other_date, [])
  189. imgs += [MotionImage(self, filename, other_date) for filename in filenames]
  190. return imgs
  191. def generate_motion_image_sets(self) -> list:
  192. self.__generate_motion_map()
  193. if len(self.motion_map) == 0:
  194. raise ValueError("No motion images in session!")
  195. imgs = []
  196. dates = sorted(list(self.motion_map.keys()))
  197. start_date = dates[0]
  198. for date in dates:
  199. if abs((date - start_date).total_seconds()) > 60 * 20:
  200. # end image time series
  201. yield imgs
  202. start_date = date
  203. imgs = []
  204. # continue time series
  205. filenames = self.motion_map.get(date, [])
  206. imgs += [MotionImage(self, filename, date) for filename in filenames]
  207. # end of all time series
  208. yield imgs
  209. def generate_motion_images(self):
  210. """Yields all motion images in this session.
  211. Yields:
  212. MotionImage: A MotionImage
  213. """
  214. for file, date in self.motion_dates.items():
  215. yield MotionImage(self, file, date)
  216. def generate_lapse_images(self):
  217. """Yields all lapse images in this session.
  218. Yields:
  219. LapseImage: A LapseImage
  220. """
  221. for file, date in self.lapse_dates.items():
  222. yield LapseImage(self, file, date)
  223. def get_closest_lapse_images(self, motion_file: str):
  224. """Returns the lapse images taken closest before and after this image, respectively.
  225. If no such image is found, the corresponding returned image will be None.
  226. Args:
  227. motion_file (str): Filename of the motion image
  228. Returns:
  229. (MotionImage or None, MotionImage or None): Closest lapse images. Each image can be None if not found.
  230. """
  231. date: datetime = self.motion_dates[motion_file]
  232. previous_date = date.replace(minute=0, second=0)
  233. next_date = previous_date + timedelta(hours=1)
  234. i = 0
  235. while not previous_date in self.lapse_map:
  236. previous_date -= timedelta(hours=1)
  237. i += 1
  238. if i > 24:
  239. # no previous lapse image exists
  240. previous_date = None
  241. break
  242. i = 0
  243. while not next_date in self.lapse_map:
  244. next_date += timedelta(hours=1)
  245. i += 1
  246. if i > 24:
  247. # no next lapse image exists
  248. next_date = None
  249. break
  250. if previous_date is not None and len(self.lapse_map[previous_date]) > 1:
  251. warn(f"There are multiple lapse images for date {previous_date}! Choosing the first one.")
  252. if next_date is not None and len(self.lapse_map[next_date]) > 1:
  253. warn(f"There are multiple lapse images for date {next_date}! Choosing the first one.")
  254. previous_img = None if previous_date is None else LapseImage(self, self.lapse_map[previous_date][0], previous_date)
  255. next_img = None if next_date is None else LapseImage(self, self.lapse_map[next_date][0], next_date)
  256. return previous_img, next_img
  257. class SessionImage:
  258. def __init__(self, session: Session, subfolder: str, filename: str, date: datetime):
  259. self.session = session
  260. self.subfolder = subfolder
  261. self.filename = filename
  262. self.date = date
  263. if not os.path.isfile(self.get_full_path()):
  264. raise ValueError(f"File {subfolder}/{filename} in session folder {session.folder} not found!")
  265. def get_full_path(self) -> str:
  266. return os.path.join(self.session.folder, self.subfolder, self.filename)
  267. def open(self):
  268. full_path = self.get_full_path()
  269. print(f"Opening {full_path}...")
  270. subprocess.call(("xdg-open", full_path))
  271. def read(self, truncate_y = (40, 40), scale=1, gray=True):
  272. full_path = self.get_full_path()
  273. img = io.imread(full_path, as_gray=gray)
  274. # truncate
  275. if truncate_y is not None:
  276. if truncate_y[0] > 0 and truncate_y[1] > 0:
  277. img = img[truncate_y[0]:(-truncate_y[1]),:]
  278. elif truncate_y[0] > 0:
  279. img = img[truncate_y[0]:,:]
  280. elif truncate_y[1] > 0:
  281. img = img[:(-truncate_y[1]),:]
  282. # scale
  283. if scale is not None and scale < 1:
  284. img = transform.rescale(img, scale, multichannel=not gray)
  285. return img
  286. def read_opencv(self, truncate_y = (40, 40), scale=1, gray=True):
  287. full_path = self.get_full_path()
  288. img = cv.imread(full_path)
  289. # grayscale
  290. if gray:
  291. img = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
  292. # truncate
  293. if truncate_y is not None:
  294. if truncate_y[0] > 0 and truncate_y[1] > 0:
  295. img = img[truncate_y[0]:(-truncate_y[1])]
  296. elif truncate_y[0] > 0:
  297. img = img[truncate_y[0]:]
  298. elif truncate_y[1] > 0:
  299. img = img[:(-truncate_y[1])]
  300. # scale
  301. if scale is not None and scale < 1:
  302. img = cv.resize(img, None, fx=scale, fy=scale, interpolation=cv.INTER_LINEAR)
  303. return img
  304. def is_daytime(self):
  305. return 6 <= self.date.hour <= 18
  306. def is_nighttime(self):
  307. return not self.is_daytime()
  308. def to_ipython_image(self, width=500, height=None):
  309. return display.Image(filename=self.get_full_path(), width=width, height=height)
  310. class MotionImage(SessionImage):
  311. def __init__(self, session: Session, filename: str, date: datetime):
  312. super().__init__(session, "Motion", filename, date)
  313. if not self.filename in session.motion_dates:
  314. raise ValueError(f"File name {filename} not in session!")
  315. def get_closest_lapse_images(self):
  316. before, after = self.session.get_closest_lapse_images(self.filename)
  317. rel = -1
  318. # rel = 0 if motion image was taken at before lapse image, rel = 1 if motion image was taken at after lapse image
  319. if before is None and after is not None:
  320. rel = 1
  321. elif before is not None and after is None:
  322. rel = 0
  323. elif before is not None and after is not None:
  324. rel = (self.date - before.date).total_seconds() / (after.date - before.date).total_seconds()
  325. else:
  326. warn("No before and no after image!")
  327. return before, after, rel
  328. class LapseImage(SessionImage):
  329. def __init__(self, session: Session, filename: str, date: datetime):
  330. super().__init__(session, "Lapse", filename, date)
  331. if not self.filename in session.lapse_dates:
  332. raise ValueError(f"File name {filename} not in session!")