#!/usr/bin/env python # -*- coding: utf-8 -*- ''' Classes used by main program. ''' from datetime import datetime, timedelta import os import shutil from fractions import Fraction import pyexiv2 class Radiation: ''' Receives values vom CSV file and creates a list of the relevant data Arguments: timestamp: Date/time string from CSV as string radiation: Radiation from CSV in CP/M as float local_timezone: timezone for timezone-unaware CSV / Photo, if GPX is timezone aware si_factor: CP/M to (µS/h) conversion factor - specific to GMC-tube Returns: timestamp: timestamp of CSV value als datetime object radiation: radiation in µS/h as str (for Exif comment, UTF-8) ''' def __init__( self, timestamp, radiation, local_timezone, si_factor ): self.timestamp = self._time_conversion(timestamp, local_timezone) self.radiation = self._radiation_conversion(radiation, si_factor) def __repr__(self): return '%s %f µS/h' % (str(self.timestamp), self.radiation) def _time_conversion(self, timestamp, local_timezone): csv_naive_time = datetime.fromisoformat(timestamp) # Set timezone csv_aware_time = csv_naive_time.localize(local_timezone) return csv_aware_time def _radiation_conversion(self, radiation, si_factor): # Convert CP/M to µS/h using si_factor radiation = float(radiation) * si_factor return radiation class Photo: ''' Reads Exif metadata. Arguments: photo: source photo () local_timezone: timezone for timezone-unaware CSV / Photo, if GPX is timezone aware dest_dir: destination directory where the photo is going to be copied to. dry_run: whether to actually write (True / False) Returns: get_date: timestamp of photo als datetime object get_photo_filename: full path to photo file to work on get_photo_basename: only filename (e. g. for print output) ''' def __init__(self, photo, local_timezone, dest_dir, dry_run): self.get_date = self._get_creation_date(photo, local_timezone) self.get_photo_filename = self._copy_photo(photo, dest_dir, dry_run)[1] self.get_photo_basename = self._copy_photo(photo, dest_dir, dry_run)[0] def __repr__(self): return 'Photo: %s Creation Date: %s' % ( str(self.get_photo_basename), str(self.get_date) ) def _copy_photo(self, photo, dest_dir, dry_run): # Determine where to work on photo and copy it there if needed. # Get image file name out of path photo_basename = os.path.basename(photo) # be os aware and use the correct directory delimiter for dest_photo dest_photo = os.path.join(dest_dir, photo_basename) # Copy photo to dest_dir and return its (new) filename # if not in dry_run mode or if dest_dir is different from src_dir. if dry_run is True: return photo_basename, photo if dest_dir != '.': shutil.copy(photo, dest_photo) return photo_basename, dest_photo return photo_basename, photo def _get_creation_date(self, photo, local_timezone): # Load Exif data from photo metadata = pyexiv2.ImageMetadata(photo) metadata.read() date = metadata['Exif.Photo.DateTimeOriginal'] # date.value creates datetime object in pic_naive_time pic_naive_time = date.value # Set timezone pic_aware_time = pic_naive_time.localize(local_timezone) return pic_aware_time class Match: ''' Receives lists of time / radiation and GPS data and compares it to timestamp.Then returns relevant values matching to time - or None Arguments: photo_time: timestamp of photo radiation_list: list of timestamp / radiation values position_list: list of timestamp / position / elevation values Returns: minimal timedelta: as timedelta object best matching values ''' def __init__(self, photo_time, radiation_list, position_list): self.radiation_value = self._find_radiation_match( photo_time, radiation_list )[1] self.radiation_delta = self._find_radiation_match( photo_time, radiation_list )[0] self.position_delta = self._find_position_match( photo_time, position_list )[0] self.position_latitude = self._find_position_match( photo_time, position_list )[1][1] self.position_longitude = self._find_position_match( photo_time, position_list )[1][2] self.position_altitude = self._find_position_match( photo_time, position_list )[1][3] def __repr__(self): if self.radiation_value: radiation = round(self.radiation_value, 2) else: radiation = None if self.position_altitude: altitude = round(self.position_altitude) else: altitude = None return 'Radiation: %s µS/h (Δt %s) \nPosition: Lat: %s, Long: %s, Alt: %sm (Δt %s)' % \ (str(radiation), str(self.radiation_delta), str(self.position_latitude), \ str(self.position_longitude), altitude, str(self.position_delta)) def _find_radiation_match(self, photo_time, list): valuelist = [] for row in list: # Define timedelta and define timedelta datetime object. delta = timedelta(seconds=60) if row.timestamp: time_delta = abs(row.timestamp - photo_time) # datetime objects should match with 1 minute precision. if time_delta < delta: element = (time_delta, row) valuelist.append(element) # Return the list item with the lowest timedelta in column 0. # Column 2 contains the source objects untouched. if valuelist: result = min(valuelist, key=lambda x: x[0]) return result[0], result[1].radiation # Return a tuple of 2x None, if there was no match. return None, None def _find_position_match(self, photo_time, list): valuelist = [] for row in list: # Define timedelta and define timedelta datetime object. delta = timedelta(seconds=300) if row[0]: time_delta = abs(row[0] - photo_time) # datetime objects should match with 5 minute precision. if time_delta < delta: element = (time_delta, row) valuelist.append(element) # Return the list item with the lowest timedelta in column 0. # Column 2 contains the source objects untouched. if valuelist: #print(min(valuelist, key=lambda x: x[0])) return min(valuelist, key=lambda x: x[0]) # Return Nones in the same cascaded manner as if it matched. return [None, [None, None, None, None]] class Exif: ''' Converts, compiles and writes Exif/ITPC/XMP-Tags from given arguments. Arguments: photo: file name of photo to modify radiation: radiation levels float latitude: latitude as float longitude: longitude as float elevation: elevation as float dry_run: whether to actually write (True / False) Returns: Latitude / Longitude: in degrees Exif-Comment: that has been written (incl. radiation) ''' def __init__( self, photo, dry_run, radiation, latitude, longitude, elevation ): self.write_exif = self._write_exif( photo, dry_run, radiation, latitude, longitude, elevation ) def __repr__(self): return 'Position: %s, %s: %s ' % self.write_exif def _to_degree(self, value, loc): if value < 0: loc_value = loc[0] elif value > 0: loc_value = loc[1] else: loc_value = "" abs_value = abs(value) deg = int(abs_value) t1 = (abs_value - deg) * 60 minute = int(t1) second = round((t1 - minute) * 60, 5) return (deg, minute, second, loc_value) def _write_exif( self, photo, dry_run, radiation, latitude, longitude, elevation ): metadata = pyexiv2.ImageMetadata(photo) metadata.read() if latitude and longitude: latitude_degree = self._to_degree(latitude, ["S", "N"]) longitude_degree = self._to_degree(longitude, ["W", "E"]) # convert decimal coordinates into fractions required for pyexiv2 exiv2_latitude = ( Fraction(latitude_degree[0] * 60 + latitude_degree[1], 60), Fraction(int(round(latitude_degree[2] * 100, 0)), 6000), Fraction(0, 1) ) exiv2_longitude = ( Fraction(longitude_degree[0] * 60 + longitude_degree[1], 60), Fraction(int(round(longitude_degree[2] * 100, 0)), 6000), Fraction(0, 1) ) # Exif tags to write metadata['Exif.GPSInfo.GPSLatitude'] = exiv2_latitude metadata['Exif.GPSInfo.GPSLatitudeRef'] = latitude_degree[3] metadata['Exif.GPSInfo.GPSLongitude'] = exiv2_longitude metadata['Exif.GPSInfo.GPSLongitudeRef'] = longitude_degree[3] metadata['Exif.Image.GPSTag'] = 654 metadata['Exif.GPSInfo.GPSMapDatum'] = "WGS-84" metadata['Exif.GPSInfo.GPSVersionID'] = '2 0 0 0' if not elevation: metadata['Exif.GPSInfo.GPSAltitude'] = Fraction(elevation) metadata['Exif.GPSInfo.GPSAltitudeRef'] = '0' else: latitude_degree = None longitude_degree = None if radiation: # Set new UserComment new_comment = 'Radiation ☢ : %s µS/h' % str(round(radiation, 2)) metadata['Exif.Photo.UserComment'] = new_comment metadata['Exif.Image.ImageDescription'] = new_comment metadata['Iptc.Application2.Caption'] = [new_comment] metadata['Xmp.dc.description'] = new_comment else: new_comment = None # Write Exif tags to file, if not in dry-run mode if dry_run is not True: metadata.write() return latitude_degree, longitude_degree, new_comment class Output: ''' Receives values to be printed, formats them and returns a string for printing. Arguments: radiation: radiation as float latitude: latitude as float longitude: longitude as float elevation: elevation as float Returns: A String that can be printed in output ''' def __init__(self, radiation, latitude, longitude, altitude): self.get_string = self._get_string( radiation, latitude, longitude, altitude ) def __repr__(self): return self.get_string def _get_string(self, radiation, latitude, longitude, altitude): # Convert values to styled strings if radiation: rad = '☢: %sµS/h ' % str(round(radiation, 2)) else: rad = '☢: N/A ' if latitude and longitude: latlon = 'Lat.: %s Long.: %s ' % (str(latitude), str(longitude)) else: latlon = 'Lat.: N/A, Long.: N/A ' if altitude: alt = 'Alt.: %sm' % str(round(altitude, 1)) else: alt = 'Alt.: N/A' data = rad + latlon + alt # Return data string return data