apee.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. import mimetypes
  4. import threading
  5. import os
  6. import csv
  7. from tkinter import *
  8. from tkinter import filedialog, messagebox
  9. from datetime import datetime
  10. import pyexiv2
  11. from PIL import Image, ImageTk
  12. class TagLabel(Label):
  13. """Les étiquettes précisant les tags à saisir"""
  14. def __init__(self, boss, width=10, text=""):
  15. Label.__init__(self, boss, width=width, relief=RAISED, anchor=W,
  16. borderwidth=0, padx=4, pady=4, text=text)
  17. class TagEntry(Entry):
  18. """Les boîtes dans lesquelles l'utilisateur saisit les tags"""
  19. def __init__(self, boss, width=40, textvariable=""):
  20. self.v = StringVar()
  21. self.v.set(textvariable)
  22. Entry.__init__(self, boss, width=width, textvariable=self.v)
  23. class MenuBar(Frame):
  24. """Barre de menus"""
  25. def __init__(self):
  26. Frame.__init__(self, borderwidth=2)
  27. # Menu Fichier
  28. file_menu = Menubutton(self, text="Fichier")
  29. file_menu.pack(side=LEFT)
  30. # Menu Chantier
  31. chantier_menu = Menubutton(self, text="Chantier")
  32. chantier_menu.pack(side=LEFT)
  33. # Menu Aide
  34. help_menu = Menubutton(self, text="Aide")
  35. help_menu.pack(side=RIGHT)
  36. # Partie déroulante, on y ajoute les entrées
  37. me1 = Menu(file_menu)
  38. me1.add_command(label="Ouvrir une image", underline=0, command=choose_img)
  39. me1.add_command(label="Ouvrir un dossier", underline=0, command=lambda: choose_dir(1))
  40. me1.add_command(label="Quitter", underline=0, command=quitter)
  41. me2 = Menu(chantier_menu)
  42. me2.add_command(label="Éditer les tags communs", underline=0, command=launch_global_tagger)
  43. me2.add_command(label="Export csv", underline=0, command=csv_export)
  44. me3 = Menu(help_menu)
  45. me3.add_command(label="Manuel", underline=0, command=show_help)
  46. me3.add_command(label="À propos", underline=0, command=show_about)
  47. # Intégration des éléments du menu
  48. file_menu.configure(menu=me1)
  49. chantier_menu.configure(menu=me2)
  50. help_menu.configure(menu=me3)
  51. class GlobalTagger(Toplevel):
  52. """Une sous-fenêtre permettant d'appliquer des tags sur une arborescence complète"""
  53. def __init__(self):
  54. """Contructeur de la classe, sous-fenêtre Tk"""
  55. Toplevel.__init__(self)
  56. self.title("Éditeur de tags Exif")
  57. tag_labels = ["Dossier", "Auteur", "Chantier"]
  58. # Création des étiquettes
  59. for i, e in enumerate(tag_labels):
  60. tlabel = TagLabel(self, text=e)
  61. tlabel.grid(row=(0 + i), column=0)
  62. # Ce champ affiche le nom du dossier choisi
  63. self.gchantier_name = TagLabel(self, text="")
  64. self.gchantier_name.grid(row=0, column=1, columnspan=2)
  65. # Les champs des tags globaux
  66. self.gauteur_entry = TagEntry(self)
  67. self.gauteur_entry.grid(row=1, column=1, columnspan=2)
  68. self.gchantier_entry = TagEntry(self)
  69. self.gchantier_entry.grid(row=2, column=1, columnspan=2)
  70. self.b_open = Button(self, text="Ouvrir...", command=self.open_dir)
  71. self.b_open.grid(row=4, column=0)
  72. self.b_write = Button(self, text="Écrire", command=self.tag_recursively)
  73. self.b_write.grid(row=4, column=1)
  74. self.b_quit = Button(self, text="Fermer", command=self.destroy)
  75. self.b_quit.grid(row=4, column=2)
  76. def open_dir(self):
  77. """Sélectionner un dossier racine, afficher son nom"""
  78. choose_dir(0)
  79. self.gchantier_name.configure(text=os.path.basename(img_dir))
  80. def tag_recursively(self):
  81. """Parcourir le dossier récursivement et appliquer les tags sur chaque fichier"""
  82. global img_dir
  83. # On parcourt le dossier à la recherche de fichiers
  84. for dirpath, dirs, files in os.walk(img_dir):
  85. for filename in files:
  86. # Vérification que c'est bien une image
  87. if not_mimetype(filename) == 1:
  88. # Si pas une image on passe au fichier suivant
  89. continue
  90. else:
  91. # On applique les tags saisis
  92. metadata = pyexiv2.ImageMetadata(dirpath + "/" + filename)
  93. metadata.read()
  94. metadata["Exif.Image.Artist"] = self.gauteur_entry.v.get()
  95. metadata["Exif.Photo.UserComment"] = self.gchantier_entry.v.get()
  96. metadata.write()
  97. # Une fois l'opération terminée, on avertit l'utilisateur
  98. messagebox.showinfo("Opération terminée", "Les photos ont été correctement étiquetées.")
  99. class Aide(Toplevel):
  100. """Sous fenêtre d'aide"""
  101. def __init__(self):
  102. # Constructeur de la classe sous-fenêtre Tk
  103. Toplevel.__init__(self)
  104. self.title("Aide")
  105. self.aide = Label(self, takefocus=0, text=self.get_help(), justify=LEFT)
  106. self.aide.pack()
  107. def get_help(self):
  108. """Lire le fichier README et l'afficher"""
  109. with open("HELP.txt", "r") as f:
  110. help_text = f.read()
  111. return help_text
  112. def show_help():
  113. """Affiche l'aide dans une sous-fenêtre"""
  114. Aide()
  115. def show_about():
  116. """Affiche la licence et le contact de l'auteur de ce logiciel"""
  117. messagebox.showinfo("À propos de ce logiciel", "Ce logiciel a été écrit pour le personnel d'Éveha.\n\
  118. Il est diffusé sous la licence libre GNU GPLv3 ou supérieure.\n\n\
  119. Auteur : sogal\n\
  120. Contact : sogal@volted.net\n\
  121. Version : 0.2")
  122. def launch_global_tagger():
  123. """Afficher l'utilitaire de tags récursif"""
  124. GlobalTagger()
  125. def choose_dir(show):
  126. """Choisir et mémoriser le dossier racine contenant les images à traiter"""
  127. img_extensions = ('.png', '.PNG', '.jpg', '.JPG')
  128. global img_dir
  129. if img_dir != "":
  130. open_dir = img_dir
  131. else:
  132. open_dir = os.path.expanduser("~/")
  133. global img_path
  134. global img_list
  135. img_list = []
  136. img_dir = filedialog.askdirectory(initialdir=(open_dir), title="Choisir le dossier contenant les \
  137. images du chantier", mustexist=True)
  138. # On vérifie qu'un dossier a bien été sélectionné
  139. if img_dir:
  140. # Si oui, on le parcourt et on liste les fichiers trouvés
  141. for dirpath, dirs, files in os.walk(img_dir):
  142. for filename in files:
  143. img_list.append(os.path.join(dirpath, filename))
  144. # On filtre la liste pour ne garder que les images
  145. img_list = ["{}".format(i) for i in img_list if os.path.splitext(i)[1] in img_extensions]
  146. img_path = img_list[0]
  147. # On affiche la première image de la liste si nécessaire
  148. if show == 1:
  149. show_img(img_path)
  150. return img_list
  151. else:
  152. # Si non, on avertit l'utilisateur
  153. messagebox.showerror("Dossier manquant", "Aucun dossier choisi, ce programme ne peut continuer.")
  154. return 1
  155. def choose_img():
  156. """Ouvre un dialogue permettant de choisir une image dont le chemin est renvoyé"""
  157. img_extensions = ('.png', '.PNG', '.jpg', '.JPG')
  158. global img_dir
  159. global img_list
  160. img_list = []
  161. global img_path
  162. img_path = filedialog.askopenfilename(initialdir=(os.path.expanduser(
  163. img_dir)), filetypes=[('Images', img_extensions), ('Tout', '.*')],
  164. title="Image à ouvrir", parent=w)
  165. # On vérifie qu'une image a été choisie
  166. if img_path:
  167. # Si oui on l'affiche
  168. show_img(img_path)
  169. img_dir = os.path.dirname(img_path)
  170. # Puis on parcourt son dossier à la recherche d'autres images que l'on liste
  171. for dirpath, dirs, files in os.walk(img_dir):
  172. for filename in files:
  173. img_list.append(os.path.join(dirpath, filename))
  174. # On filtre la liste pour ne conserver que les images
  175. img_list = ["{}".format(i) for i in img_list if os.path.splitext(i)[1] in img_extensions]
  176. return img_list
  177. else:
  178. # Si non, on avertit l'utilisateur
  179. messagebox.showerror("Fichier manquant", "Aucun fichier image choisi, ce programme ne peut continuer.")
  180. return 1
  181. def not_mimetype(file):
  182. """Vérification du type MIME du fichier pour éviter les erreurs lors
  183. de la lecture ou écriture des tags"""
  184. mimet = mimetypes.guess_type(file)[0]
  185. if not mimet or "image" not in mimet:
  186. return 1
  187. else:
  188. return 0
  189. def resize_img(image):
  190. """Redimensionne l'image au côté max de son conteneur"""
  191. x, y = image.size[0], image.size[1]
  192. if x > 800 or y > 600:
  193. if x > y:
  194. y = int((800 * y) / x)
  195. x = 800
  196. else:
  197. x = int((x * 600) / y)
  198. y = 600
  199. image = image.resize((x, y), Image.ANTIALIAS)
  200. # Après redimensionnement, l'image (son conteneur) doit toujours rester centrée verticalement
  201. imgcan.pack_configure(ipady=600 - y)
  202. return image
  203. def get_tags(path):
  204. """Lire les metadonnées et les afficher à leur place"""
  205. metadata = pyexiv2.ImageMetadata(path)
  206. metadata.read()
  207. a = "Exif.Image.Artist"
  208. b = "Exif.Photo.DateTimeOriginal"
  209. c = "Exif.Photo.UserComment"
  210. d = "Exif.Image.ImageDescription"
  211. # On vérifie que ces tags existent
  212. for tag in a, b, c, d:
  213. if tag not in metadata.exif_keys:
  214. metadata[tag] = ""
  215. # On les affiche dans les boîtes Entry correspondantes après formatage
  216. # Si la date est vide, impossible de la formater, la valeur du champ reste vide
  217. try:
  218. date_entry.v.set(metadata[b].value.strftime('%A %d %B %Y'))
  219. except:
  220. date_entry.v.set("")
  221. auteur_entry.v.set(metadata[a].value)
  222. descrip_entry.v.set(metadata[d].value)
  223. # Si le commentaire est composé de valeurs au format .csv (liées par des ;)
  224. if ";" in list(metadata[c].value):
  225. c = metadata[c].value
  226. # On les sépare dans une liste
  227. comment_list = c.split(";")
  228. view = comment_list[0]
  229. comment = comment_list[1]
  230. view_entry.v.set(view)
  231. chantier_entry.v.set(comment)
  232. else:
  233. # Sinon on affiche le tag UserComment au complet
  234. comment = metadata[c].value
  235. chantier_entry.v.set(comment)
  236. def get_img_name(path):
  237. """Récupérer et afficher le nom du fichier image"""
  238. name = os.path.basename(path)
  239. img_name.configure(text=name)
  240. def delete_img(path):
  241. """Supprimer l'image actuellement affichée après confirmation de l'uttlisateur"""
  242. # On vérifie qu'une image est active ( = affichée)
  243. if img_path:
  244. name = os.path.basename(path)
  245. if messagebox.askokcancel("Supprimer l'image", "Voulez-vous vraiment supprimer " + name + " ?",
  246. icon="warning"):
  247. if len(img_list) > 1:
  248. # On affiche l'image suivante
  249. navigate("next")
  250. else:
  251. # Sauf si la liste n'en contenait qu'une
  252. imgcan.configure(image="")
  253. img_name.configure(text="")
  254. # On sort l'image à supprimer de la liste
  255. img_list.pop(img_list.index(path))
  256. # Puis on l'efface
  257. os.remove(path)
  258. def quitter():
  259. """Demander confirmation avant de quitter"""
  260. if messagebox.askokcancel("Quitter", "Voulez-vous vraiment quitter l'application ?"):
  261. w.quit()
  262. def show_img(path):
  263. """Affiche l'image redimensionnée et ses infos, noms et tags Exif"""
  264. # Choisir l'image
  265. img1 = Image.open(path)
  266. # La redimensionner si nécessaire
  267. imgr = resize_img(img1)
  268. # Créer un objet Image
  269. img2 = ImageTk.PhotoImage(imgr)
  270. # Qu'on garde en mémoire pour éviter sa destruction par le ramasse-miettes de Python
  271. dicimg['img3'] = img2
  272. # On insère notre image à sa place
  273. imgcan.configure(image=img2)
  274. imgcan.image = img2
  275. imgcan.path = img2
  276. # On affiche le nom de l'image
  277. get_img_name(path)
  278. # On affiche les tags de l'image
  279. get_tags(path)
  280. def clean_tag_entries():
  281. """Nettoyer les champs entre chaque photo"""
  282. for entry in (date_entry, auteur_entry, view_entry, descrip_entry, chantier_entry):
  283. entry.v.set("")
  284. def navigate(sens):
  285. """Permet de naviguer dans la liste des images contenues dans le dossier choisi"""
  286. global img_path
  287. global img_list
  288. img_pos = 0
  289. # On vérifie qu'il reste des images dans la liste (utile après suppression de la dernière image d'un dossier)
  290. if len(img_list) > 0:
  291. # On vérifie qu'il y a plus d'une seule image dans la liste
  292. if len(img_list) > 1:
  293. # On récupère la position de l'image active dans la liste
  294. img_index = img_list.index(img_path)
  295. if sens == "prev":
  296. img_pos = img_index - 1
  297. if img_pos < 0:
  298. img_pos = len(img_list) - 1
  299. elif sens == "next":
  300. img_pos = img_index + 1
  301. if img_pos > len(img_list) - 1:
  302. img_pos = 0
  303. # L'image active est celle occupant la nouvelle position
  304. img_path = (img_list[img_pos])
  305. clean_tag_entries()
  306. # S'il s'avère que l'image active n'est pas vraiment une image ( mimetype != image), on passe à la suivante
  307. if not_mimetype(img_path) == 1:
  308. navigate("next")
  309. else:
  310. # Si c'est bon, on l'affiche
  311. show_img(img_path)
  312. def set_tags(path):
  313. """Modifier ou créer les metadonnées Exif de l'image affichée"""
  314. if img_path:
  315. metadata = pyexiv2.ImageMetadata(path)
  316. metadata.read()
  317. metadata["Exif.Image.Artist"] = auteur_entry.v.get()
  318. metadata["Exif.Image.ImageDescription"] = descrip_entry.v.get()
  319. # On agrège les valeurs de certains champs au format .csv
  320. metadata["Exif.Photo.UserComment"] = view_entry.v.get() + ";" + chantier_entry.v.get()
  321. metadata.write()
  322. messagebox.showinfo("Opération terminée", "Cette photo a été correctement étiquetée.")
  323. def csv_export():
  324. """Choisir le dossier contenant les photos, lire les tags, les exporter dans un fichier .csv"""
  325. photos_list = choose_dir(0)
  326. if img_dir:
  327. messagebox.showinfo("Export CSV", "Export en cours, veuillez patienter, un message vous informera \n\
  328. de la bonne fin des opérations")
  329. def write_tags():
  330. """Fonction principale d'export des tags dans un fichier"""
  331. # Ouverture du fichier listing.csv dans le dossier 'img_dir'
  332. with open(img_dir+"/listing.csv", 'w', newline='') as f:
  333. listing_csv = csv.writer(f, delimiter=',')
  334. # On passe chaque photo en revue
  335. for photo in photos_list:
  336. photo_tags_list = []
  337. metadata = pyexiv2.ImageMetadata(photo)
  338. metadata.read()
  339. # On vérifie que les tags demandés existent bien dans les photos
  340. for tag in ["Exif.Image.Artist",
  341. "Exif.Image.ImageDescription",
  342. "Exif.Photo.DateTimeOriginal",
  343. "Exif.Photo.UserComment"]:
  344. # Si oui on insère leur valeur dans une liste...
  345. if tag in metadata.exif_keys:
  346. if tag == "Exif.Photo.DateTimeOriginal":
  347. old_date = metadata[tag].value
  348. new_date = datetime.strptime(
  349. str(old_date), '%Y-%m-%d %H:%M:%S').strftime(
  350. '%d/%m/%Y %H:%M:%S')
  351. photo_tags_list.append(new_date)
  352. else:
  353. photo_tags_list.append(metadata[tag].value)
  354. listing_csv.writerow([os.path.dirname(photo)] +
  355. [os.path.basename(photo)] +
  356. photo_tags_list)
  357. messagebox.showinfo("Export CSV", "Export des métadonnées vers listing.csv terminé.")
  358. # Création d'un thread parallèle pour que l'application ne paraisse pas "freezée" lors de longs exports
  359. thread = threading.Thread(target=write_tags)
  360. thread.daemon = True
  361. thread.start()
  362. if __name__ == "__main__":
  363. # Initialisation des variables globales
  364. img_path = ""
  365. img_dir = ""
  366. img_list = []
  367. dicimg = {}
  368. # La fenêtre principale
  369. w = Tk()
  370. w.resizable(width=False, height=False)
  371. w.title("Apee : éditeur de métadonnées Exif pour photos archéologiques")
  372. # Tk Call pour forcer l'ajout d'un filtre pour "dot{file,dir}"
  373. try:
  374. # Tentative d'initialisation d'un dialogue factice pour pouvoir passer les
  375. # variables qui vont bien
  376. try:
  377. w.tk.call('tk_getOpenFile', '-foobar')
  378. except TclError:
  379. pass
  380. w.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1')
  381. w.tk.call('set', '::tk::dialog::file::showHiddenVar', '0')
  382. except:
  383. pass
  384. # Icône du logiciel
  385. ico = PhotoImage(file="apee.png")
  386. #ico = PhotoImage(file="/usr/local/share/apee/apee.png")
  387. w.call('wm', 'iconphoto', w, ico)
  388. # La barre de menu
  389. menu = MenuBar()
  390. menu.grid(row=0, column=0, sticky=W)
  391. # Le cadre de gauche qui servira à afficher l'image
  392. imgframe = Frame(w, bd=2, bg="#444444", width=800, height=600, padx=5, pady=5)
  393. # On force la mise à la taille définie ci-dessus
  394. imgframe.pack_propagate(0)
  395. imgframe.grid(row=1, column=0, sticky=NW, padx=5, pady=5)
  396. # Le conteneur qui contiendra l'image affichée
  397. imgcan = Label(imgframe, background="#444444", text="Choisissez une image pour l'afficher ici", image="")
  398. imgcan.pack()
  399. imgcan.pack_configure(ipady=600)
  400. # Le cadre de droite qui contient les éléments de contrôle
  401. ctlframe = Frame(w, bd=1, padx=5, pady=5)
  402. ctlframe.grid(row=1, column=1, sticky=NE)
  403. # Création en chaîne des étiquettes de champs
  404. tag_labels = ["Date", "Auteur", "Description", "Vue depuis", "Chantier"]
  405. for i, e in enumerate(tag_labels):
  406. tlabel = TagLabel(ctlframe, text=e)
  407. tlabel.grid(row=(1 + i), column=0)
  408. # Étiquette affichant le nom de l'image active
  409. img_name = Label(ctlframe, text="", padx=5, pady=5)
  410. img_name.grid(row=0, column=1, columnspan=3)
  411. # Création des champs pour les metadonnées Exif
  412. date_entry = TagEntry(ctlframe)
  413. date_entry.grid(row=1, column=1, columnspan=2)
  414. auteur_entry = TagEntry(ctlframe)
  415. auteur_entry.grid(row=2, column=1, columnspan=2)
  416. descrip_entry = TagEntry(ctlframe)
  417. descrip_entry.grid(row=3, column=1, columnspan=2)
  418. view_entry = TagEntry(ctlframe)
  419. view_entry.grid(row=4, column=1, columnspan=2)
  420. chantier_entry = TagEntry(ctlframe)
  421. chantier_entry.grid(row=5, column=1, columnspan=2)
  422. # Boutons de navigation et étiquette du nom de l'image
  423. Button(ctlframe, text="<", padx=5, pady=5, takefocus=0, command=lambda: navigate("prev")).grid(row=6,
  424. column=0, sticky=W)
  425. Button(ctlframe, text=">", padx=5, pady=5, takefocus=0, command=lambda: navigate("next")).grid(row=6,
  426. column=2, sticky=E)
  427. # Boutons de choix d'image, d'écriture des tags définis et de sortie
  428. Button(ctlframe, text="Enregistrer", pady=5, takefocus=1, command=lambda: set_tags(img_path)).grid(row=6, column=1)
  429. # Forcer la taille de la dernière ligne
  430. ctlframe.rowconfigure(7, minsize=410)
  431. Button(ctlframe, text="Ouvrir...", pady=5, takefocus=0, command=lambda: choose_img()).grid(row=7,
  432. column=0,
  433. sticky=SW)
  434. Button(ctlframe, text="Supprimer", pady=5, takefocus=0, command=lambda: delete_img(img_path)).grid(row=7,
  435. column=1,
  436. sticky=S)
  437. Button(ctlframe, text="Quitter", pady=5, takefocus=0, command=lambda: quitter()).grid(row=7, column=2,
  438. sticky=SE)
  439. # Contrôle clavier
  440. w.bind("<Left>", lambda e: navigate("prev"))
  441. w.bind("<Right>", lambda e: navigate("next"))
  442. w.bind("<Control - q>", lambda e: quitter())
  443. w.bind("<Control - s>", lambda e: set_tags(img_path))
  444. w.bind("<Control - o>", lambda e: choose_img(1))
  445. w.bind("<Control - O>", lambda e: choose_dir(0))
  446. w.mainloop()