From 35306bc68c3abec4364a2ad9fc942931899fcde4 Mon Sep 17 00:00:00 2001 From: Bruce Jackson Date: Tue, 16 Feb 2021 12:39:39 -0500 Subject: [PATCH] Image Metadata Gramplet expand metadata support - Enable showing XMP and IPTC metadata, - Add additional metadata fields that contain tags and text descriptions. - Display thumbnails for XMP-mwg-rs Regions --- gramps/plugins/lib/libmetadata.py | 237 ++++++++++++++++++++++++------ 1 file changed, 194 insertions(+), 43 deletions(-) diff --git a/gramps/plugins/lib/libmetadata.py b/gramps/plugins/lib/libmetadata.py index 8a84f0126..eca380d14 100644 --- a/gramps/plugins/lib/libmetadata.py +++ b/gramps/plugins/lib/libmetadata.py @@ -2,8 +2,9 @@ # -*- coding: utf-8 -*- # Gramps - a GTK+/GNOME based genealogy program # -# Copyright (C) 2011 Nick Hall -# Copyright (C) 2011 Rob G. Healey +# Copyright (C) 2011,2014 Nick Hall +# Copyright (C) 2011 Rob G. Healey +# Copyright (C) 2022 Bruce Jackson # # 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 @@ -25,6 +26,10 @@ # #------------------------------------------------------------------------- import os +import logging + +_LOG = logging.getLogger(".libmetadata") + #------------------------------------------------------------------------- # @@ -35,6 +40,10 @@ from gi.repository import Gtk import gi gi.require_version('GExiv2', '0.10') from gi.repository import GExiv2 +from gi.repository import Gdk +from gi.repository import GdkPixbuf +from gi.repository import GObject + #------------------------------------------------------------------------- # @@ -42,7 +51,7 @@ from gi.repository import GExiv2 # #------------------------------------------------------------------------- -from gramps.gui.listmodel import ListModel +from gramps.gui.listmodel import ListModel, NOSORT, IMAGE as COL_IMAGE from gramps.gen.const import GRAMPS_LOCALE as glocale _ = glocale.translation.gettext from gramps.gen.utils.place import conv_lat_lon @@ -51,21 +60,25 @@ from gramps.gen.lib import Date from gramps.gen.datehandler import displayer from datetime import datetime +THUMBNAIL_IMAGE_SIZE = (50, 50) + def format_datetime(datestring): """ Convert an exif timestamp into a string for display, using the - standard Gramps date format. + standard Gramps date format. Function not used for XMP Date Metatags: + https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#date-value-type """ try: timestamp = datetime.strptime(datestring, '%Y:%m:%d %H:%M:%S') except ValueError: return _('Invalid format') + date_part = Date() date_part.set_yr_mon_day(timestamp.year, timestamp.month, timestamp.day) date_str = displayer.display(date_part) time_str = _('%(hr)02d:%(min)02d:%(sec)02d') % {'hr': timestamp.hour, - 'min': timestamp.minute, - 'sec': timestamp.second} + 'min': timestamp.minute, + 'sec': timestamp.second} return _('%(date)s %(time)s') % {'date': date_str, 'time': time_str} def format_gps(raw_dms, nsew): @@ -97,23 +110,61 @@ def format_gps(raw_dms, nsew): return result if result is not None else _('Invalid format') -DESCRIPTION = _('Description') -IMAGE = _('Image') -CAMERA = _('Camera') -GPS = _('GPS') -ADVANCED = _('Advanced') + + +DESCRIPTION = _('Descriptive Tags') +DATE = _('Date and Time Tags') +PEOPLE = _('People Tags') +EVENT = _('Event Tags') +IMAGE = _('Image Tags') +CAMERA = _('Camera Information') +LOCATION = _('Location Tags') +ADVANCED = _('Advanced Tags') +RIGHTS = _('Rights Tags') +TAGGING = _('Keyword Tags') + +""" +List of tags available to plugin can be found at the Exiv2 project +https://www.exiv2.org/metadata.html +""" TAGS = [(DESCRIPTION, 'Exif.Image.ImageDescription', None, None), - (DESCRIPTION, 'Exif.Image.Artist', None, None), - (DESCRIPTION, 'Exif.Image.Copyright', None, None), - (DESCRIPTION, 'Exif.Photo.DateTimeOriginal', None, format_datetime), - (DESCRIPTION, 'Exif.Photo.DateTimeDigitized', None, format_datetime), - (DESCRIPTION, 'Exif.Image.DateTime', None, format_datetime), - (DESCRIPTION, 'Exif.Image.TimeZoneOffset', None, None), (DESCRIPTION, 'Exif.Image.XPSubject', None, None), (DESCRIPTION, 'Exif.Image.XPComment', None, None), - (DESCRIPTION, 'Exif.Image.XPKeywords', None, None), (DESCRIPTION, 'Exif.Image.Rating', None, None), + (DESCRIPTION, 'Xmp.dc.title', None, None), + (DESCRIPTION, 'Xmp.dc.description', None, None), + (DESCRIPTION, 'Xmp.dc.subject', None, None), + (DESCRIPTION, 'Xmp.acdsee.caption', None, None), + (DESCRIPTION, 'Xmp.acdsee.notes', None, None), + (DESCRIPTION, 'Iptc.Application2.Caption', None, None), + (DESCRIPTION, 'Exif.Photo.UserComment', None, None), + (DESCRIPTION, 'Xmp.iptcExt.AOTitle', None, None), + (DATE, 'Exif.Photo.DateTimeOriginal', None, format_datetime), + (DATE, 'Exif.Photo.DateTimeDigitized', None, format_datetime), + (DATE, 'Exif.Image.DateTime', None, format_datetime), + (DATE, 'Exif.Image.TimeZoneOffset', None, None), + (DATE, 'Xmp.Xmp.CreateDate', None, None), + (DATE, 'Xmp.photoshop.DateCreated', None, None), + (PEOPLE, 'Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name', None, None), + (PEOPLE, 'Xmp.mwg-rs.Regions', None, None), + (PEOPLE, 'Xmp.iptcExt.PersonInImage', None, None), + (EVENT, 'Xmp.iptcExt.Event', None, None), + (LOCATION, 'Xmp.iptcExt.LocationShown', None, None), + (LOCATION, 'Exif.GPSInfo.GPSLatitude', 'Exif.GPSInfo.GPSLatitudeRef', format_gps), + (LOCATION, 'Exif.GPSInfo.GPSLongitude', 'Exif.GPSInfo.GPSLongitudeRef', format_gps), + (LOCATION, 'Exif.GPSInfo.GPSAltitude', 'Exif.GPSInfo.GPSAltitudeRef', None), + (LOCATION, 'Exif.GPSInfo.GPSTimeStamp', None, None), + (LOCATION, 'Exif.GPSInfo.GPSSatellites', None, None), + (TAGGING, 'Exif.Image.XPKeywords', None, None), + (TAGGING, 'Iptc.Application2.Keywords', None, None), + (TAGGING, 'Xmp.mwg-kw.Hierarchy', None, None), + (TAGGING, 'Xmp.mwg-kw.Keywords', None, None), + (TAGGING, 'Xmp.digiKam.TagsList', None, None), + (TAGGING, 'Xmp.MicrosoftPhoto.LastKeywordXMP', None, None), + (TAGGING, 'Xmp.MicrosoftPhoto.LastKeywordIPTC', None, None), + (TAGGING, 'Xmp.lr.hierarchicalSubject', None, None), + (TAGGING, 'Xmp.acdsee.categories', None, None), (IMAGE, 'Exif.Image.DocumentName', None, None), (IMAGE, 'Exif.Photo.PixelXDimension', None, None), (IMAGE, 'Exif.Photo.PixelYDimension', None, None), @@ -126,6 +177,11 @@ TAGS = [(DESCRIPTION, 'Exif.Image.ImageDescription', None, None), (IMAGE, 'Exif.Image.Compression', None, None), (IMAGE, 'Exif.Photo.CompressedBitsPerPixel', None, None), (IMAGE, 'Exif.Image.PhotometricInterpretation', None, None), + (RIGHTS, 'Exif.Image.Copyright', None, None), + (RIGHTS, 'Exif.Image.Artist', None, None), + (RIGHTS, 'Xmp.xmpRights.Owner', None, None), + (RIGHTS, 'Xmp.xmpRights.UsageTerms', None, None), + (RIGHTS, 'Xmp.xmpRights.WebStatement', None, None), (CAMERA, 'Exif.Image.Make', None, None), (CAMERA, 'Exif.Image.Model', None, None), (CAMERA, 'Exif.Photo.FNumber', None, None), @@ -147,14 +203,6 @@ TAGS = [(DESCRIPTION, 'Exif.Image.ImageDescription', None, None), (CAMERA, 'Exif.Photo.Sharpness', None, None), (CAMERA, 'Exif.Photo.WhiteBalance', None, None), (CAMERA, 'Exif.Photo.DigitalZoomRatio', None, None), - (GPS, 'Exif.GPSInfo.GPSLatitude', - 'Exif.GPSInfo.GPSLatitudeRef', format_gps), - (GPS, 'Exif.GPSInfo.GPSLongitude', - 'Exif.GPSInfo.GPSLongitudeRef', format_gps), - (GPS, 'Exif.GPSInfo.GPSAltitude', - 'Exif.GPSInfo.GPSAltitudeRef', None), - (GPS, 'Exif.GPSInfo.GPSTimeStamp', None, None), - (GPS, 'Exif.GPSInfo.GPSSatellites', None, None), (ADVANCED, 'Exif.Image.Software', None, None), (ADVANCED, 'Exif.Photo.ImageUniqueID', None, None), (ADVANCED, 'Exif.Image.CameraSerialNumber', None, None), @@ -169,51 +217,62 @@ class MetadataView(Gtk.TreeView): def __init__(self): Gtk.TreeView.__init__(self) self.sections = {} - titles = [(_('Key'), 1, 235), - (_('Value'), 2, 325)] + titles = [(_('Key'), 0, 235), + (_(' '), NOSORT, 60, COL_IMAGE), + (_('Value'), NOSORT, 325)] + self.model = ListModel(self, titles, list_mode="tree") - def display_exif_tags(self, full_path): + + def display_exif_tags(self, image_path): """ Display the exif tags. """ self.sections = {} + + # set fixed_height_mode to FALSE so thumbnails are not truncated. + self.model.tree.set_fixed_height_mode(False) self.model.clear() - if not os.path.exists(full_path): + if not os.path.exists(image_path): return False retval = False - with open(full_path, 'rb') as fd: + with open(image_path, 'rb') as fd: try: buf = fd.read() metadata = GExiv2.Metadata() metadata.open_buf(buf) - + self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path) get_human = metadata.get_tag_interpreted_string for section, key, key2, func in TAGS: - if not key in metadata.get_exif_tags(): + if not key in self.__get_all_tags(metadata): continue if func is not None: if key2 is None: human_value = func(metadata[key]) else: - if key2 in metadata.get_exif_tags(): + if key2 in self.__get_all_tags(metadata): human_value = func(metadata[key], metadata[key2]) else: human_value = func(metadata[key], None) else: human_value = get_human(key) - if key2 in metadata.get_exif_tags(): + if key2 in self.__get_all_tags(metadata): human_value += ' ' + get_human(key2) - label = metadata.get_tag_label(key) - node = self.__add_section(section) if human_value is None: human_value = '' - self.model.add((label, human_value), node=node) + + # If first named region is found - find all named regions + if key == 'Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name': + self.__get_named_regions(metadata) + continue + + node = self.__add_section(section) + self.model.add([key, None, human_value], node=node) self.model.tree.expand_all() retval = self.model.count > 0 @@ -227,29 +286,121 @@ class MetadataView(Gtk.TreeView): Add the section heading node to the model. """ if section not in self.sections: - node = self.model.add([section, '']) + node = self.model.add([section, None, '']) self.sections[section] = node else: node = self.sections[section] return node - def get_has_data(self, full_path): + def get_has_data(self, image_path): """ Return True if the gramplet has data, else return False. """ - if not os.path.exists(full_path): + if not os.path.exists(image_path): return False - with open(full_path, 'rb') as fd: + with open(image_path, 'rb') as fd: retval = False try: buf = fd.read() metadata = GExiv2.Metadata() metadata.open_buf(buf) for tag in TAGS: - if tag in metadata.get_exif_tags(): + if tag in self.__get_all_tags(metadata): retval = True break except: pass return retval + + def __get_all_tags(self, metadata): + """ + Return a list of all XMP, IPTC and EXIF tags in the media file + """ + tag_list = metadata.get_exif_tags() + metadata.get_xmp_tags() + metadata.get_iptc_tags() + + return tag_list + + def __get_named_regions(self, metadata): + """ + Retrieve all XMP named regions in an image and populate the treeview row. + """ + + region_tag = 'Xmp.mwg-rs.Regions/mwg-rs:RegionList[%s]/' + region_name = region_tag + 'mwg-rs:Name' + region_x = region_tag + 'mwg-rs:Area/stArea:x' + region_y = region_tag + 'mwg-rs:Area/stArea:y' + region_w = region_tag + 'mwg-rs:Area/stArea:w' + region_h = region_tag + 'mwg-rs:Area/stArea:h' + + pixbuf_width = self.pixbuf.get_width() + pixbuf_height = self.pixbuf.get_height() + + i = 1 + while True: + name = metadata.get(region_name % i) + region_name_display = region_name % i + + if name is None: + break + try: + x = float(metadata.get(region_x % i)) * pixbuf_width + y = float(metadata.get(region_y % i)) * pixbuf_height + w = float(metadata.get(region_w % i)) * pixbuf_width + h = float(metadata.get(region_h % i)) * pixbuf_height + except ValueError: + x = pixbuf_width /2 + y = pixbuf_height / 2 + w = pixbuf_width + h = pixbuf_height + + # ensure region does not exceed bounds of image + region_p1 = x - (w / 2) + if region_p1 < 0: + region_p1 = 0 + region_p2 = y - (h / 2) + if region_p2 < 0: + region_p2 = 0 + region_p3 = x + (w / 2) + if region_p3 > pixbuf_width: + region_p3 = pixbuf_width + region_p4 = y + (h / 2) + if region_p4 > pixbuf_height: + region_p4 = pixbuf_height + + region = (region_p1, region_p2, region_p3, region_p4) + person_thumbnail = self.__get_thumbnail(region, THUMBNAIL_IMAGE_SIZE) + + node = self.__add_section(PEOPLE) + self.model.add([region_name_display, person_thumbnail, name], node=node) + + i += 1 + + def __get_thumbnail(self, region, thumbnail_size): + """ + Returns the thumbnail of the given region. + """ + w = region[2] - region[0] + h = region[3] - region[1] + + if w <= self.pixbuf.get_width() and h <= self.pixbuf.get_height() and self.pixbuf: + subpixbuf = self.pixbuf.new_subpixbuf(region[0], region[1], w, h) + size = self.__resize_keep_aspect(w, h, *thumbnail_size) + return subpixbuf.scale_simple(size[0], size[1], + GdkPixbuf.InterpType.BILINEAR) + else: + return None + + def __resize_keep_aspect(self, orig_x, orig_y, target_x, target_y): + """ + Calculates the dimensions of the rectangle obtained from + the rectangle orig_x * orig_y by scaling to fit + target_x * target_y keeping the aspect ratio. + """ + orig_aspect = orig_x / orig_y + target_aspect = target_x / target_y + if orig_aspect > target_aspect: + return (target_x, target_x * orig_y // orig_x) + else: + return (target_y * orig_x // orig_y, target_y) +