#!/bin/env python # metadata_writer.py -- Single file python app to create metadata # # Copyright 2025 Efthymios Kritikos # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . */ #import stuff that's needed for both gui and check mode plus tkinter to make inheritance easier (for now) import sys import hashlib import json from datetime import datetime import tkinter as tk background_color='#DDDDDD' def main(): if len(sys.argv) < 2: print("Usage: python script.py path_to_image") sys.exit(1) image_path = sys.argv[1] import matplotlib.pyplot import numpy import time from tkinter import messagebox from tkinter import Frame from tkcalendar import Calendar from PIL import Image, ImageTk from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from time import strftime, localtime import tkintermapview data = { "version": "v0.0-dev", "title": "", "capture_time_start": 0, "capture_time_end": 0, "image_sha512": sha512Checksum(image_path), "description": "", "events" : [{ "time": 1733055790, "text": "Data captured" }, { "time": 1741745288, "text": "Raw file developed"}, { "time": 1747012088, "text": "Metadata written" }, { "time": 1747876088, "text": "Metadata modified" }, { "time": 1759876088, "text": "Metadata version updated" } ], "GPS_lat_dec_N": 51.500789280409016, "GPS_long_dec_W": -0.12472196184719725 } def save_and_exit(): description_value = description_entry.get("1.0",'end-1c') data["title"] = title.get() data["capture_time_start"] = int(time.mktime(time.strptime(timestamp_start.get(), '%Y-%m-%d %H:%M:%S'))) data["capture_time_end"] = int(time.mktime(time.strptime(timestamp_end.get(), '%Y-%m-%d %H:%M:%S'))) data["description"] = description_value with open("output.json", "w") as f: json.dump(data, f, indent=4) root.destroy() # GUI setup root = tk.Tk() root.title("Metadata Writer") root.configure(background=background_color) # Load and display image img = Image.open(image_path) img.thumbnail((400, 400)) # Resize for display photo = ImageTk.PhotoImage(img) img_label = tk.Label(root, image=photo, bg=background_color, borderwidth=15) #Start/end timestamp fields timestamp=Frame(root) timestamp.configure(bg=background_color) start_var = tk.StringVar(value=strftime('%Y-%m-%d %H:%M:%S', localtime(data["capture_time_start"]))) end_var = tk.StringVar(value=strftime('%Y-%m-%d %H:%M:%S', localtime(data["capture_time_end"]))) timestamp_start = tk.Entry(timestamp,textvariable=start_var) timestamp_end = tk.Entry(timestamp,textvariable=end_var) tk.Label(timestamp, text="Shot time/date start:", bg=background_color).grid(row=0,column=0) tk.Label(timestamp, text="Shot time/date end:",bg=background_color).grid(row=0,column=2) timestamp_start.grid(row=0,column=1) timestamp_end.grid(row=0,column=3) #Description field description=Frame(root) tk.Label(description, text="Description:",bg=background_color).pack(side=tk.LEFT) description_entry = TextScrollCombo(description) description_entry.pack() description.configure(bg=background_color) description_entry.config(width=600, height=100) #title field title=TitledEntry(root,"Ttile",tk.NORMAL,"") #sha512 field sha512sum=TitledEntry(root,"Image SHA512",tk.DISABLED,data["image_sha512"]) #version field version=TitledEntry(root,"Version",tk.DISABLED,data["version"]) #timeline field timeline = event_timeline(root,data["events"],matplotlib.pyplot,numpy,FigureCanvasTkAgg) timeline.configure(bg=background_color) # Save button save_button = tk.Button(root, text="Save and Exit", command=save_and_exit, bg=background_color) map_frame=Frame(root) map_widget = tkintermapview.TkinterMapView(map_frame, width=400, height=400, corner_radius=15, bg_color=background_color) map_widget.set_position(data["GPS_lat_dec_N"], data["GPS_long_dec_W"]) marker_1=map_widget.set_marker(data["GPS_lat_dec_N"], data["GPS_long_dec_W"]) map_widget.set_zoom(15) map_widget.pack(pady=15) #media_aqusition=TitledDropdown(root,"Media Aquisition",["unkown","Direct digital off of taking device","Received digitial unmodified from taking device","Received digital re-encoded and or metadata stripped","Received digital editied"],0) #Window layout img_label .grid(row=0,column=0,rowspan=6,sticky='n') title .grid(row=0,column=1,sticky="we") description .grid(row=1,column=1,sticky="we") timestamp .grid(row=2,column=1,sticky="we") sha512sum .grid(row=3,column=1,sticky="we") version .grid(row=4,column=1,sticky="we") save_button .grid(row=5,column=1) map_frame .grid(row=6,column=0) timeline .grid(row=7,column=0,columnspan=2) root.mainloop() #Got md5Checksum (sha512Checksum now) from someones blog https://www.joelverhagen.com/blog/2011/02/md5-hash-of-file-in-python/ def sha512Checksum(filePath): with open(filePath, 'rb') as fh: m = hashlib.sha512() while True: data = fh.read(8192) if not data: break m.update(data) return m.hexdigest() #Got TextScrollCombo from stack overflow https://stackoverflow.com/questions/13832720/how-to-attach-a-scrollbar-to-a-text-widget class TextScrollCombo(tk.Frame): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # ensure a consistent GUI size self.grid_propagate(False) # implement stretchability self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) # create a Text widget self.txt = tk.Text(self,height=10,bg=background_color) self.txt.grid(row=0, column=0, sticky="nsew", padx=2, pady=2) # create a Scrollbar and associate it with txt scrollb = tk.Scrollbar(self, command=self.txt.yview,bg=background_color) scrollb.grid(row=0, column=1, sticky='nsew') self.txt['yscrollcommand'] = scrollb.set self.configure(background=background_color) def get(c,a,b): return c.txt.get(a,b) #TODO: delete if unused before release #class TitledDropdown(tk.Frame): # # def __init__(self, root_window, text, options, default_opt): # # super().__init__(root_window) # # self.titled_dropdown = tk.OptionMenu(self,tk.StringVar(value=options[default_opt]),*options) # self.titled_dropdown.config(width=8) # tk.Label(self, text=text, bg=background_color).pack(side=tk.LEFT) # self.titled_dropdown.pack(fill=tk.X) # def get(c): # return c.titled_dropdown.get() class TitledEntry(tk.Frame): def __init__(self, root_window, text, input_state, init_text): super().__init__(root_window) self.title_entry = tk.Entry(self,state=input_state,textvariable=tk.StringVar(value=init_text),bg=background_color) tk.Label(self, text=text, bg=background_color).pack(side=tk.LEFT) self.title_entry.pack(fill=tk.X) def get(c): return c.title_entry.get() def event_timeline(window,events,plt,np,FigureCanvasTkAgg): plot_line_width=0.8 fig, ax = plt.subplots(figsize=(12, 1.8), constrained_layout=True) ax.set_facecolor('none') # Comment out to debug out of bound graph fig.patch.set_facecolor('none') ax.set_position([.01,0,0.8,1]) timelines=[] labels=[] for item in events: labels.append(item["text"]) timelines.append(datetime.fromtimestamp(item["time"])) offsets= [3,2,1] levels = np.tile(offsets, int(np.ceil(len(timelines)/len(offsets))))[:len(timelines)] ax.axhline(0, c="black",linewidth=2) ax.vlines(timelines, 0, levels, color='black',linewidth=plot_line_width ) #Draw event lines ax.plot(timelines, np.zeros_like(timelines), "-o", color="k", markerfacecolor="w",linewidth=plot_line_width ) #Draw the line with the points for t, l, b in zip(timelines, levels, labels): ax.annotate(b+"\n"+t.strftime("%d/%m/%Y"), xy=(t, l), xytext=(0, -20), textcoords='offset points', horizontalalignment='left', verticalalignment='bottom' if l > 0 else 'top', color='black', fontsize=9,bbox=dict(facecolor=background_color, edgecolor='black', boxstyle='round,pad=.5', linewidth=plot_line_width) ) ax.yaxis.set_visible(False) ax.spines[["left", "top", "right"]].set_visible(False) ax.spines['bottom'].set_position(('data', -8000)) canvas = FigureCanvasTkAgg(fig, master = window) canvas.draw() plt.close() return canvas.get_tk_widget() if __name__ == "__main__": main()