#!/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 . */ #TODO: #Weather image is cropped #Make save button red if any data is inparsable #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 import os from datetime import datetime import tkinter as tk from tkinter import ttk 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 PIL.ExifTags import TAGS from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from time import strftime, localtime from datetime import datetime #import tkintermapview from pathlib import Path from exif import Image as exifImage from fractions import Fraction device_data = { "lights": [ { "id": 0, "brand":"", "name": "other" }, { "id": 1, "brand":"", "name": "Sun" }, { "id": 2, "brand":"Godox", "name": "AD200 Pro" }, { "id": 3, "brand":"Aputure", "name": "600D" } ], "cameras": [ { "id": 0, "brand":"Unkown", "model":"unkown", "serial_number":None },{ "id": 1, "brand":"Sony", "model":"ILCE7R4", "serial_number":"ThE_S3r4l_#" },{ "id": 2, "brand":"Insta360", "model":"x3", "serial_number":"7777777777" } ] } #Get current timestamp current_event_timestamp=int(time.time()) #Get exif from image file image = Image.open(image_path) exif_data = image._getexif() shutter_speed_apex = None exposure_time = None create_datetime= None for tag_id, value in exif_data.items(): tag = TAGS.get(tag_id, tag_id) if tag == 'ExposureTime': exposure_time = float(Fraction(value)) elif tag == 'DateTimeOriginal': dt_str = value dt = datetime.strptime(dt_str, '%Y:%m:%d %H:%M:%S') create_datetime=int(dt.timestamp()) # Unix epoch time #JSON output template data = { "program_version": "v1.0-dev", "data_spec_version": "v1.0-dev", "title": { "text" : "", "event_id" : -1 }, "description": { "text" : "", "event_id" : -1 }, "capture_duration_seconds": exposure_time, "single_capture_picture": True, "image_sha512": sha512Checksum(image_path), "image_filename": os.path.realpath(sys.argv[1]), "capture_start_on_original_metadata_ts": create_datetime, "capture_start_time_offset": 0, "events" : [{ "event_id":0, "event_type": "capture_start", "time": 0, "text": "" }, #{ "event_id":2, "event_type": "data_modification", "time": 1741745288, "text": "Raw file developed"}, { "event_id":1, "event_type": "metadata_modification", "time": current_event_timestamp, "text": "Initial metadata written" }, #{ "event_id":5, "event_type": "version_upgrade", "time": 1759876088, "text": "Metadata version updated" } ], #"GPS_lat_dec_N": 51.500789280409016, #"GPS_long_dec_W": -0.12472196184719725, #"lights": [{ "source":2, "type":"Flash", "Usage":"pointing to his face" }, # { "source":3, "type":"continious", "Usage":"hair light" }, # { "source":1, "type":"continious", "Usage":"doing its thing" }, # { "source":0, "type":"continious", "Usage":"street light" } # ] } def save_and_exit(): try: attribution_event=int(event_attribution.get().split()[2]) except ValueError as e: print("Error: internal error getting event id for save") return -1 data["title"]["text"] = title.get() data["title"]["event_id"] = attribution_event data["description"]["text"] = description.get("1.0",'end-1c') data["description"]["event_id"] = attribution_event data["single_capture_picture"] = one_capture_var.get() data["capture_start_time_offset"] = float(cap_offset_var.get()) data["capture_duration_seconds"] = float(cap_duration_var.get()) data["events"][0]["time"] = int(data["capture_start_time_offset"])+int(data["capture_start_on_original_metadata_ts"]) output_path = Path(data["image_filename"]).with_suffix(".json") with open(output_path, "w") as f: json.dump(data, f, indent=4) root.destroy() # GUI setup root = tk.Tk() root.title("Metadata Writer") background_color=root.cget('bg') editables=Frame() ################# # display image # ################# display_image_frame=TitledFrame(root, [("Image", ("TkDefaultFont", 10))] ) img = Image.open(image_path) img.thumbnail((400, 400)) # Resize for display photo = ImageTk.PhotoImage(img) img_label = tk.Label(display_image_frame, image=photo, borderwidth=4) img_label.grid(row=0,column=0) ######### # Texts # ######### texts_frame=TitledFrame(editables,[("[1]", ("TkDefaultFont", 12, "bold")),("Texts", ("TkDefaultFont", 10))]) title = TitledEntry(texts_frame,"Ttile","",input_state=tk.NORMAL) description = TextScrollCombo(texts_frame,"Description:") title.grid (row=0,column=0,sticky='we',padx=3,pady=3) description.grid (row=1,column=0,sticky='we',padx=3,pady=3) texts_frame.grid_columnconfigure(0, weight=1) ############# # Timestamp # ############# timestamp=TitledFrame(editables,[("[2]", ("TkDefaultFont", 12, "bold")),("Timestamp", ("TkDefaultFont", 10))]) #Callback for updating the explanation def update_timestamp_description(*args): date_value = cap_start_var.get() duration_value = cap_duration.get() check_value = one_capture_var.get() try: duration_value=str(float(duration_value)) date=time.strftime('%A %-d of %B %Y %H:%M:%S',time.gmtime(data["capture_start_on_original_metadata_ts"]+int(cap_offset_var.get()))) if check_value == False: explanation_var.set("A multi-picture image (focus stack/exposure stack/etc) that started being taken at " + date + " and took " + str(duration_value) + " seconds to capture" ) else: explanation_var.set("An image taken at " + date + " with a "+str(duration_value)+" second shutter speed") explanation.config(bg="grey64") except ValueError as e: explanation_var.set("Invalid vlaues!") explanation.config(bg="red") # explanation text explanation_var = tk.StringVar() explanation = tk.Label(timestamp, textvariable=explanation_var, wraplength=450) explanation.config(width=70) # Original capture date cap_start_var = tk.StringVar(value=strftime('%Y-%m-%d %H:%M:%S', time.gmtime(data["capture_start_on_original_metadata_ts"]) )) #TODO: don't hardcode these values #cap_start_var.trace_add("write", update_timestamp_description) cap_start_label=tk.Label(timestamp, text="Original capture start date:") cap_start = tk.Entry(timestamp,textvariable=cap_start_var,state=tk.DISABLED)#,state=tk.DISABLED) # Capture date offset cap_offset_var = tk.StringVar(value=data["capture_start_time_offset"]) #TODO: don't hardcode these values cap_offset_var.trace_add("write", update_timestamp_description) cap_offset_label=tk.Label(timestamp, text="Capture start date offset seconds:") cap_offset = tk.Entry(timestamp,textvariable=cap_offset_var)#,state=tk.DISABLED) # Capture duration cap_duration_var = tk.StringVar(value=str(data["capture_duration_seconds"])) cap_duration_var.trace_add("write", update_timestamp_description) cap_duration_label=tk.Label(timestamp, text="Capture duration (seconds):") cap_duration = tk.Entry(timestamp,textvariable=cap_duration_var)#,state=tk.DISABLED) # One shot checkbox one_capture_var = tk.BooleanVar() one_capture_var.trace_add( "write", update_timestamp_description) one_capture = tk.Checkbutton(timestamp, text="Final picture is comprised of one capture",variable=one_capture_var ) one_capture.select() #this also calles update_timestamp_description. If removed place a call to it to write the inital value on the text box cap_start_label.grid (row=0,column=0,padx=3,pady=3) cap_start.grid (row=0,column=1,padx=3,pady=3) cap_duration_label.grid (row=0,column=2,padx=3,pady=3) cap_duration.grid (row=0,column=3,padx=3,pady=3) cap_offset_label.grid (row=1,column=0,padx=3,pady=3) cap_offset.grid (row=1,column=1,padx=3,pady=3) one_capture.grid (row=1,column=2,padx=3,pady=3,columnspan=2) explanation.grid (row=2,column=0,padx=3,pady=3,columnspan=4) ############# # Constants # ############# constants_frame=TitledFrame(editables,[("Constants", ("TkDefaultFont", 10))]) sha512sum=TitledEntry(constants_frame,"Image SHA512",data["image_sha512"],input_state=tk.DISABLED) sha512sum=TitledEntry(constants_frame,"Image SHA512",data["image_sha512"],input_state=tk.DISABLED) filename=TitledEntry(constants_frame,"Image Filename",data["image_filename"],input_state=tk.DISABLED) program_version=TitledEntry(constants_frame,"Program version",data["program_version"],width=8,input_state=tk.DISABLED) data_spec_version=TitledEntry(constants_frame,"Data specification version",data["data_spec_version"],width=8,input_state=tk.DISABLED) filename.grid (row=0,column=0,padx=3,pady=3,columnspan=2,sticky='we') sha512sum.grid (row=1,column=0,padx=3,pady=3,columnspan=2,sticky='we') program_version.grid (row=2,column=0,padx=3,pady=3,sticky='we') data_spec_version.grid (row=2,column=1,padx=3,pady=3,sticky='we') constants_frame.grid_columnconfigure(0, weight=1) constants_frame.grid_columnconfigure(1, weight=1) ######## # Save # ######## save_frame=TitledFrame(editables,[("[3]", ("TkDefaultFont", 12, "bold")),("Save", ("TkDefaultFont", 10))]) save_button = tk.Button(save_frame, text="Save and Exit", command=save_and_exit) save_button.config(bg='green') event_list=[] for item in data["events"]: if item["event_type"] == "metadata_modification": event_list.append("event id "+str(item["event_id"])+" : "+item["text"]) event_attribution=TitledDropdown(save_frame,"Metadata change event attribution:",event_list,0) event_attribution.grid (row=0,column=0,padx=3,pady=3,sticky='we') save_button.grid (row=0,column=1,padx=3,pady=3) #save_button.grid (row=0,column=1,padx=3,pady=3,sticky='e') save_frame.grid_columnconfigure(0, weight=1) # ############## # # Map widget # # ############## # map_frame=Frame(root) # map_widget = tkintermapview.TkinterMapView(map_frame, width=400, height=400, corner_radius=10) # 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) ################## # timeline field # ################## def events_to_tags(json_events): capture_start=-1 return_data=[] for item in json_events: if item["event_type"] == "capture_start": capture_start=item["time"] else: return_data.append({"time":item["time"],"text":item["text"]}) return_data.append({"time":capture_start,"text":"Captured data"}) return return_data timeline_frame=TitledFrame(root,[("Timeline", ("TkDefaultFont", 10))]) timeline = event_timeline(timeline_frame,events_to_tags(data["events"]),matplotlib.pyplot,numpy,FigureCanvasTkAgg,background_color) timeline.configure(bg=background_color) timeline.grid(row=0,column=0) ################### # Media aqusition # ################### #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) ########## # Lights # ########## # #light table # table=[] # for item in data["lights"]: # for device in device_data["lights"]: # if device["id"] == item["source"]: # table.append([device["brand"]+device["name"],item["type"],item["Usage"]]) # light_table=TitledTable(editables,"List of lights / flashes used:",table,["Device","Type","Usage"],[140,100,450],['w','w','w']) #Root frame layout display_image_frame .grid(row=0,column=0,sticky='n') editables .grid(row=0,column=1,rowspan=2,sticky='ns') # map_frame .grid(row=1,column=0) timeline_frame .grid(row=2,column=0,columnspan=2) #editables frame layout texts_frame .grid(row=0,column=0,sticky="we",pady=5) timestamp .grid(row=1,column=0,sticky="we",pady=5) save_frame .grid(row=2,column=0,sticky="we",pady=5) constants_frame .grid(row=3,column=0,sticky="we",pady=5) # light_table .grid(row=6,column=0,sticky="we",pady=5) 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, root_window, title): super().__init__(root_window) # 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) self.txt.config(height=5) tk.Label(self, text=title).grid(row=0, column=0, sticky="w") self.txt.grid(row=1, column=0, sticky="we") # create a Scrollbar and associate it with txt scrollb = tk.Scrollbar(self, command=self.txt.yview) scrollb.grid(row=1, column=1, sticky='nsew') self.txt['yscrollcommand'] = scrollb.set def get(c,a,b): return c.txt.get(a,b) class TitledDropdown(tk.Frame): def __init__(self, root_window, text, options, default_opt): super().__init__(root_window) self.titled_dropdown = ttk.Combobox(self,value=options) self.titled_dropdown.set(options[default_opt]) self.titled_dropdown.config(width=8) tk.Label(self, text=text).grid (row=0,column=0,sticky='w') self.titled_dropdown.grid (row=0,column=1,sticky='we') self.grid_columnconfigure(1, weight=1) def get(c): return c.titled_dropdown.get() class TitledEntry(tk.Frame): def __init__(self, root_window, text, init_text, input_state=tk.NORMAL, width=None): super().__init__(root_window) self.title_entry = tk.Entry(self,state=input_state,textvariable=tk.StringVar(value=init_text),width=width) self.label=tk.Label(self, text=text) self.label.grid(row=0,column=0,sticky='w') self.title_entry.grid(row=0,column=1,sticky='we') self.grid_columnconfigure(1, weight=1) def get(c): return c.title_entry.get() class TitledTable(tk.Frame): def __init__(self, root_window, text, table, header, widths, anchors): super().__init__(root_window) header_=header.copy() del header_[0] self.treeview = ttk.Treeview(self,columns=(header_),height=3) for row in table: row_=row.copy() del row_[0] self.treeview.insert("",tk.END,text=row[0],values=(row_)) for i in range(len(header)): if i==0: self.treeview.heading("#0", text=header[i]) self.treeview.column("#0", width = widths[i], anchor=anchors[i]) else: self.treeview.heading(header[i], text=header[i]) self.treeview.column(header[i], width = widths[i], anchor=anchors[i]) self.scrollb = tk.Scrollbar(self, command=self.treeview.yview) self.utility_frame=tk.Frame(self) self.modify_button=tk.Button(self.utility_frame,text="Modify",width=8) self.add_button=tk.Button(self.utility_frame,text="Add",width=8) self.add_button.pack() self.modify_button.pack() tk.Label(self, text=text).grid(row=0,column=0,sticky="w") self.treeview.grid(row=1,column=0,sticky='we') self.scrollb.grid(row=1,column=1,sticky='ns') self.treeview['yscrollcommand'] = self.scrollb.set self.utility_frame.grid(row=1,column=2,sticky='n') def get(c): return c.title_entry.get() def event_timeline(window,events,plt,np,FigureCanvasTkAgg,background_color): 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() def TitledFrame(root, title_parts): lf = ttk.Labelframe(root, padding=2,borderwidth=4,relief="ridge") title = ttk.Frame(lf) for text, font in title_parts: ttk.Label(title, text=text, font=font).pack(side="left") lf.configure(labelwidget=title) # managed by the labelframe itself return lf if __name__ == "__main__": main()