diff --git a/.gitignore b/.gitignore index 41ded9e..f8f9504 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ +testdata testsource testdest -testdata -usercomment.sh +__pycache__ diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd1396..dd76fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog +## Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), @@ -6,27 +6,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3] - 2020-03-23 + +Major rewrite following OOP style. Adds GPS/GPX handling. + +### Added +- Added GPX parser. +- Added optional dry-run modifier. +- Made timezone-aware for timesources that are not timezone aware. +- Added optional parameter to override timezone to local timezone, defaults to utc. +- Refactored variable name_scheme. +- Switch to oop style programming. Made code easier to read. +- Moved CSV processing and Exif reading / writing into a class. +- Moved photo copying into Photo class. +- Created Exif writing class. Can now also create GPS tags. +- Created Output formatting class. +- Added new working Match class for matching time against CSV and GPX. ## [0.2] - 2020-02-05 ### Added -- uses pyexiv2 instead of piexif which is also able to tag various camera raw formats, e. g. CR2 -- added copy function to be able to place files to outdir before modification -- added verbose output about what it going to happen -- added table header for output +- uses pyexiv2 instead of piexif which is also able to tag various camera raw formats, e. g. CR2 and is capable of writing properly formed UTF-8 strings. +- Added copy function to be able to place files to outdir before modification. +- Added verbose output about what it going to happen. +- Added table header for output. ### Changed -- removed unnecessary datetime object creation as pyexiv2 can output datetime objects itself -- removed obsolete "Processing..." message, as it was never visible. +- Removed unnecessary datetime object creation as pyexiv2 can output datetime objects itself. +- Removed obsolete "Processing..." message, as it was never visible. ## [0.1] - 2020-02-02 First prototype using piexif ### Added -- argument parsing enabled -- parametrized factor to calculate Sieverts -- parametrized output dir -- usage of os.path to be os-aware -- output in a tablish manner +- Argument parsing enabled. +- Parametrized factor to calculate Sieverts. +- Parametrized output dir. +- Usage of os.path to be os-aware. +- Output in a tablish manner. ### Changed - exchanged selfmade CSV parser by python's core CSV library diff --git a/Readme.md b/Readme.md index 6b62b86..57af6d0 100644 --- a/Readme.md +++ b/Readme.md @@ -1,23 +1,27 @@ # radiation tagger -exif_rad.py is a simple unix-style cross-platform Python 3 tool which can write certain tags to an image file. +rad_tag.py is a simple unix-style cross-platform Python 3 tool which can write certain tags to an image file. It can scan a couple of images, extract their Exif-tags, and compare the `DateTimeOriginal` with other sources. -By now it can parse a .his (CSV) file from a [GeigerLog](https://sourceforge.net/projects/Geigerlog/) file export and calculate the radiation in µS/h using the factor in `SIFACTOR`. +It can parse a .his (CSV) file from a [GeigerLog](https://sourceforge.net/projects/Geigerlog/) file export and calculate the radiation in µS/h using the factor in `sifactor`. -It then creates a `UserComment` Exif tag with the actual measured radiation at the time the photo has been taken. +It can optionally read a gpx-file, compare the timestamps to 'DateTimeOriginal' and determine closest-matching latitude / longitude / altitude. Timestamps in GPX files are ususally stored in UTC timezone, you can set --timezone to match the local timezone, your camera / geiger counter ran at. + +It then creates a `UserComment` with the actual measured radiation at the time the photo has been taken and writes the geocoordinates into the appropiate Exif tags. ## Dependencies Right now it depends on the following non-core Python 3 libraries: - * [py3exiv2](https://pypi.org/project/py3exiv2/) A Python 3 binding to the library exiv2. + * [py3exiv2](https://pypi.org/project/py3exiv2/) A Python 3 binding for (lib)exiv2. * [boost.python3](http://www.boost.org/libs/python/doc/index.html) Welcome to Boost.Python, a C++ library which enables seamless interoperability between C++ and the Python programming language. * [exiv2](http://www.exiv2.org/) Exiv2 is a Cross-platform C++ library and a command line utility to manage image metadata. + * [gpxpy](https://github.com/tkrajina/gpxpy) gpx-py is a python GPX parser. GPX (GPS eXchange Format) is an XML based file format for GPS tracks. ## Requirements + * A bunch of images (jpg, cr2, etc.) with its time of creation stored in `DateTimeOriginal`. * GeigerCounter log file in csv format as it is being exported by the software GeigerLog. - * A bunch of images (jpg, cr2, etc.) with its time of creation stored in `DateTimeOriginal` + * Optionally a GPX (1.0 / 1.1) track that has been recorded during the same timeperiod. All sources are matched by their timestamp, so all sources have to be recorded during the same time (and timezone). The Geiger counter has to log a value every second, as the script compares the timestamps exactly. @@ -31,10 +35,11 @@ These exported .his files look like this: ## Usage ``` -usage: exif_rad.py [-h] [-si SIFACTOR] [-o OUTDIR] CSV Photo [Photo ...] +usage: rad_tag.py [-h] [-si SIFACTOR] [-tz Timezone] [-d] [-g GPX] [-o OUTDIR] + CSV Photo [Photo ...] -A tool that writes radiation levels (and optionally geocoordinates) to image -files and extracts the infos from external sources. +A unix-tyle tool that extracts GPS and/or radiation data from GPX/CSV files +and writes them into the Exif tags of given photos. positional arguments: CSV Geiger counter history file in CSV format. @@ -45,31 +50,47 @@ optional arguments: -si SIFACTOR, --sifactor SIFACTOR Factor to multiply recorded CPM with. (default: 0.0065) + -tz Timezone, --timezone Timezone + Manually set timezone of CSV / and Photo timestamp, + defaults to UTC if omitted. This is useful, if the + GPS-Logger saves the time incl. timezone (default: + utc) + -d, --dry Dry-run, do not actually write anything. (default: + False) + -g GPX, --gpx GPX GPS track in GPX format (default: None) -o OUTDIR, --outdir OUTDIR - Directory to output processed photos (default: .) + Directory to output processed photos. (default: .) + ``` ### Examples -Use test.hisdb.his from current working dir and modify (overwrite) all .CR2 files in place. +Use test.hisdb.his and walk.gpx from testdata and modify (overwrite) all .JPG files in place. ``` -./exif_rad.py test.hisdb.his *.CR2 +./rad_tag.py ./testdata/walk.hisdb.his --gpx .d/testdata/walk.gpx -tz Europe/Berlin ./testdest/*.JPG Modifying photos in place (overwrite) -filename date / time Exif UserComment -DSC_0196.JPG 2020-03-03 18:33:33 NOT FOUND! -DSC_0197.JPG 2020-03-03 20:14:18 Radiation ☢ 0.15 µS/h -DSC_0198.JPG 2020-03-03 22:18:13 Radiation ☢ 0.07 µS/h -``` - -Use test.hisdb.his in folder 'testdata', read all .JPG files from 'testsource' and write them to 'testdest'. +filename date / time Matched Data +_MG_3824.JPG 2020-03-15 16:17:54+01:00 ☢: 0.05µS/h Lat.: 51.92611112 Long.: 7.69379252 Alt.: 93.0m +_MG_3825.JPG 2020-03-15 16:18:12+01:00 ☢: 0.08µS/h Lat.: 51.92620192 Long.: 7.69360727 Alt.: 91.7m +_MG_3826.JPG 2020-03-15 16:18:12+01:00 ☢: 0.08µS/h Lat.: 51.92620192 Long.: 7.69360727 Alt.: 91.7m +_MG_3827.JPG 2020-03-15 16:18:12+01:00 ☢: 0.08µS/h Lat.: 51.92620192 Long.: 7.69360727 Alt.: 91.7m ``` -./exif_rad.py testdata/test.hisdb.his -o testdest/ testsource/*.JPG -Modifying photos in testdest/ (copy) -filename date / time Exif UserComment -DSC_0196.JPG 2020-03-03 18:33:33 NOT FOUND! -DSC_0197.JPG 2020-03-03 20:14:18 Radiation ☢ 0.15 µS/h -DSC_0198.JPG 2020-03-03 22:18:13 Radiation ☢ 0.07 µS/h + +Use test.hisdb.his in folder 'testdata', read all files from 'testsource' and write them to 'testdest'. + +``` +./rad_tag.py ./testdata/walk.hisdb.his -o ./testdest --gpx ./testdata/walk.gpx -tz Europe/Berlin ./testsource/* +Modifying photos in /home/mscholz/testdest (copy) +filename date / time Matched Data +DSC_0226.JPG 2020-03-15 15:02:04+01:00 ☢: N/A Lat.: N/A, Long.: N/A Alt.: N/A +DSC_0227.JPG 2020-03-15 15:11:43+01:00 ☢: N/A Lat.: N/A, Long.: N/A Alt.: N/A +_MG_3804.JPG 2020-03-15 15:59:11+01:00 ☢: 0.06µS/h Lat.: 51.92582544 Long.: 7.68739496 Alt.: 95.4m +_MG_3805.CR2 2020-03-15 16:01:49+01:00 ☢: 0.05µS/h Lat.: 51.92314108 Long.: 7.69078156 Alt.: 104.2m +_MG_3805.JPG 2020-03-15 16:01:49+01:00 ☢: 0.05µS/h Lat.: 51.92314108 Long.: 7.69078156 Alt.: 104.2m +_MG_3807.CR2 2020-03-15 16:07:02+01:00 ☢: 0.08µS/h Lat.: 51.9235013 Long.: 7.69250565 Alt.: 101.3m +_MG_3807.JPG 2020-03-15 16:07:02+01:00 ☢: 0.08µS/h Lat.: 51.9235013 Long.: 7.69250565 Alt.: 101.3m + ``` ## GeigerLog setup @@ -89,6 +110,8 @@ The GMC* defaults are quite sane, but you might want to set the correct serial p `usbport = /dev/ttyUSB0` +GeigerLog can also use a bunch of other devices while still outputting a csv-file in compatible format. + ### Using GeigerLog to download history Now the program can be started by double-clicking `geigerlog` or by executing `./geigerlog` on the command prompt. @@ -100,11 +123,13 @@ GeigerLog now presents you a rendering of the radiation over time in its main wi [main_window]: images/geigerlog_main_window.png "GeigerLog Main Window with graph" -Once imported, you can export the history into a hisdb.his-file, which is basically the CSV-file `exif_rad.py` can process. Choose 'History' -> Save History Data into .his file (CSV)'. +Once imported, you can export the history into a hisdb.his-file, which is basically the CSV-file `rad_tag.py` can process. Choose 'History' -> Save History Data into .his file (CSV)'. +## GPS setup + +Especially if you use a mobile phone for GPS-logging. Take care, the app can use GPS when turned off, and let it write position sufficiently often. Threshold is 5 minutes by default, but precision will improve when logging more often. Especially "inactivity detection" might become a problem, when staying at one place for a period of time. ## future possibilities - * In the future it should also be able to do the same with a gpx-file to extract geolocations and to write them into the appropiate Exif-fields. * It might get a setup.py if I want to waste my time on it. * I might want to get rid of the requirement to use a bloated GUI application to download the history data off the Geigercounter. There must be a neat working command line tool. Maybe I'll write it myself. diff --git a/exif_rad.py b/exif_rad.py deleted file mode 100755 index 7ad52ad..0000000 --- a/exif_rad.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Iterates over a bunch of .jpg or .cr2 files and matches -DateTimeOriginal from Exif tag to DateTime in a csv log -of a GeigerMuellerCounter and writes its value to the UserComment -Exif tag in µS/h""" - -from datetime import datetime -import os -import shutil -import csv -import argparse -import pyexiv2 - -# SIFACTOR for GQ Geiger counters - -# 300 series: 0.0065 µSv/h / CPM -# 320 series: 0.0065 µSv/h / CPM -# 500 series: 0.0065 µSv/h / CPM -# 500+ series: 0.0065 µSv/h / CPM for the first tube -# 600 series: 0.0065 µSv/h / CPM -# 600+ series: 0.002637 µSv/h / CPM - -# Configure argument parser for cli options -parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, - description='''A tool that writes - radiation levels (and optionally geocoordinates) to image files - and extracts the infos from external sources.''') -parser.add_argument('-si', '--sifactor', type=float, default=0.0065, - help='Factor to multiply recorded CPM with.') -parser.add_argument('csv', metavar='CSV', type=str, - help='Geiger counter history file in CSV format.') -parser.add_argument('photos', metavar='Photo', type=str, nargs='+', - help='One or multiple photo image files to process.') -parser.add_argument('-o', '--outdir', type=str, default='.', - help='Directory to output processed photos') - -args = parser.parse_args() - -# Inform the user about what is going to happen -if args.outdir == ".": - print('Modifying photos in place (overwrite)') -else: - print('Modifying photos in', str(args.outdir), '(copy)') -# Print table header -print('{:<15} {:<20} {:<22}'.format('filename', 'date / time', 'Exif UserComment')) - -for srcphoto in args.photos: - # Get image file name out of path - photo_basename = os.path.basename(srcphoto) - - # Decide whether to modify photo in place or to copy it to outdir first - # Then set the destination file as 'photo' to work on - if args.outdir == ".": - photo = srcphoto - else: - # be os aware and use the correct directory delimiter for destfile - dstphoto = os.path.join(args.outdir, photo_basename) - shutil.copy(srcphoto, dstphoto) - photo = dstphoto - - # Load Exif data from image - metadata = pyexiv2.ImageMetadata(photo) - metadata.read() - tag = metadata['Exif.Photo.DateTimeOriginal'] - # tag.value creates datetime object in pictime - pictime = tag.value - - # Import GeigerCounter log - with open(args.csv, "r") as f: - csvreader = csv.reader(filter(lambda row: row[0] != '#', f), - delimiter=',', skipinitialspace=True) - - print('Processing file:', photo_basename, end='\r') - for _, csvrawtime, csvrawcpm, _ in csvreader: - csvtime = datetime.fromisoformat(csvrawtime) - # Process image if its timestamp is found in CSV log (compares 2 datetime objects) - if csvtime == pictime: - rad = round(float(csvrawcpm) * args.sifactor, 2) - - # Set key, value for new UserComment - key = 'Exif.Photo.UserComment' - new_comment = 'Radiation ☢ ' + str(rad) + ' µS/h' - metadata[key] = pyexiv2.ExifTag(key, new_comment) - - # print found radiation levels - print('{:<15} {:<20} {:<22}'.format(photo_basename, str(pictime), new_comment)) - # Write Exif tags to file - metadata.write() - break - else: - print('{:<15} {:<20} {:<22}'.format(photo_basename, str(pictime), 'NOT FOUND!')) - # close CSV file - f.close() diff --git a/functions.py b/functions.py new file mode 100644 index 0000000..981b976 --- /dev/null +++ b/functions.py @@ -0,0 +1,297 @@ +#!/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: + ''' + Reiceives 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-unware 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.astimezone(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-unware CSV / Photo, if GPX is timezone aware + dest_dir: destination directory where the photo is going to be copied to. + dry_run: whether to acutally 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.astimezone(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-Tags from given arguemnts. + + 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 acutally 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 + 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 + diff --git a/rad_tag.py b/rad_tag.py new file mode 100755 index 0000000..470d13b --- /dev/null +++ b/rad_tag.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +''' Iterates over a bunch of .jpg or .cr2 files and matches +DateTimeOriginal from Exif tag to DateTime in a csv log +of a GeigerMuellerCounter and writes its value to the UserComment +Exif tag in µS/h ''' + +import csv +import argparse +import pytz +import gpxpy +from functions import Radiation, Photo, Match, Exif, Output + +# SIFACTOR for GQ Geiger counters + +# 300 series: 0.0065 µSv/h / CPM +# 320 series: 0.0065 µSv/h / CPM +# 500 series: 0.0065 µSv/h / CPM +# 500+ series: 0.0065 µSv/h / CPM for the first tube +# 600 series: 0.0065 µSv/h / CPM +# 600+ series: 0.002637 µSv/h / CPM + +# Configure argument parser for cli options +parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description='''A unix-tyle tool that + extracts GPS and/or radiation data from GPX/CSV files and writes + them into the Exif tags of given photos.''') +parser.add_argument('-si', '--sifactor', type=float, default=0.0065, + help='Factor to multiply recorded CPM with.') +parser.add_argument('-tz', '--timezone', type=str, metavar='Timezone', default='utc', + help='''Manually set timezone of CSV / and Photo timestamp, + defaults to UTC if omitted. This is useful, if the GPS-Logger + saves the time incl. timezone''') +parser.add_argument('-d', '--dry', action='store_true', + help='Dry-run, do not actually write anything.') +parser.add_argument('csv', metavar='CSV', type=str, + help='Geiger counter history file in CSV format.') +parser.add_argument('-g', '--gpx', metavar='GPX', type=str, + help='GPS track in GPX format') +parser.add_argument('photos', metavar='Photo', type=str, nargs='+', + help='One or multiple photo image files to process.') +parser.add_argument('-o', '--outdir', type=str, default='.', + help='Directory to output processed photos.') + +args = parser.parse_args() + +# Create timezone datetime object +local_timezone = pytz.timezone(args.timezone) + +# Initialize two empty lists for all radiation / gps values +radiation_list = [] +position_list = [] + +# Import GeigerCounter log +with open(args.csv, "r") as f: + csv = csv.reader(filter(lambda row: row[0] != '#', f), + delimiter=',', skipinitialspace=True) + + # Import only relevant values, thats timestamp and CP/M + for _, csv_raw_time, csv_raw_cpm, _ in csv: + radiation = Radiation(csv_raw_time, csv_raw_cpm, local_timezone, args.sifactor) + radiation_list.append(radiation) + # close CSV file + f.close() + +# Import GPX track(s)print +if args.gpx: + gpx_file = open(args.gpx, 'r') + gpx_reader = gpxpy.parse(gpx_file) + for track in gpx_reader.tracks: + for segment in track.segments: + for point in segment.points: + point_aware_time = point.time.astimezone(local_timezone) + position = (point_aware_time, point.latitude, point.longitude, + point.elevation) + position_list.append(position) + +# Inform the user about what is going to happen +if args.dry: + print('Not modifying anything. Just print what would happen without --dry') +else: + if args.outdir == ".": + print('Modifying photos in place (overwrite)') + else: + print('Modifying photos in', str(args.outdir), '(copy)') + +# Print table header +print('{:<15} {:<25} {:<22}'.format('filename', 'date / time', 'Matched Data')) + +for src_photo in args.photos: + # Instantiate photo, copy it to destdir if needed and receive filename to work on + photo = Photo(src_photo, local_timezone, args.outdir, args.dry) + + # Here the matching magic takes place + match = Match(photo.get_date, radiation_list, position_list) + + # Formatted output: + data = Output(match.radiation_value, match.position_latitude, + match.position_longitude, match.position_altitude) + print('{:<15} {:<25} {:<22}'.format(photo.get_photo_basename, str(photo.get_date), str(data))) + + # Write exif data + Exif(photo.get_photo_filename, args.dry, match.radiation_value, + match.position_latitude, match.position_longitude, match.position_altitude)