Merge branch 'oop' into 'master'
Major rewrite following OOP style. Adds GPS/GPX handling. See merge request Commander1024/radiation-tagger!1
This commit is contained in:
commit
032e7c408e
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
testdata
|
||||
testsource
|
||||
testdest
|
||||
testdata
|
||||
usercomment.sh
|
||||
__pycache__
|
||||
|
40
CHANGELOG.md
40
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
|
||||
|
77
Readme.md
77
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.
|
||||
|
95
exif_rad.py
95
exif_rad.py
@ -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()
|
297
functions.py
Normal file
297
functions.py
Normal file
@ -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
|
||||
|
105
rad_tag.py
Executable file
105
rad_tag.py
Executable file
@ -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)
|
Loading…
Reference in New Issue
Block a user