Merge pull request #1263 from brucejackson/Imagemetadata-grampet
| Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 289 KiB | 
| Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 607 KiB | 
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 408 KiB | 
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 145 KiB | 
| Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 645 KiB | 
							
								
								
									
										22
									
								
								example/gramps/image_credits.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| # Credits for Images used in Gramps Example database # | ||||
|  | ||||
| # O0.jpg ## | ||||
| "Riga Old Man" by liber is marked with CC BY-SA 2.0. To view the terms, visit https://creativecommons.org/licenses/by-sa/2.0/?ref=openverse  | ||||
| - https://www.flickr.com/photos/51035655291@N01/200858281 | ||||
|  | ||||
| ## O2.jpg ## | ||||
| "Brothers by Robert Boning (c.1870)" by pellethepoet is marked with CC BY 2.0. To view the terms, visit https://creativecommons.org | ||||
| - https://www.flickr.com/photos/47201412@N02/12675131334 | ||||
|  | ||||
| ## O3.jpg ## | ||||
| "Portrait of a Canon-man" by mescon is marked with CC BY 2.0. To view the terms, visit https://creativecommons.org/licenses/by/2.0/?ref=openverse  | ||||
| - https://www.flickr.com/photos/23666014@N08/3790571906 | ||||
|  | ||||
| ## 04.jpg ## | ||||
| "Woman Photographer" by pedrosimoes7 is marked with CC BY 2.0. To view the terms, visit https://creativecommons.org/licenses/by/2.0/?ref=openverse  | ||||
| - https://www.flickr.com/photos/46944516@N00/6872425924 | ||||
|  | ||||
| ## O5.jpg ## | ||||
| "Happy couple" by pedrosimoes7 is marked with CC BY 2.0. To view the terms, visit https://creativecommons.org/licenses/by/2.0/?ref=openverse  | ||||
| - https://www.flickr.com/photos/46944516@N00/4198095383 | ||||
|  | ||||
| @@ -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 <robhealey1@gmail.com> | ||||
| # Copyright (C) 2011,2014  Nick Hall | ||||
| # Copyright (C) 2011       Rob G. Healey <robhealey1@gmail.com> | ||||
| # 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,66 @@ class MetadataView(Gtk.TreeView): | ||||
|     def __init__(self): | ||||
|         Gtk.TreeView.__init__(self) | ||||
|         self.sections = {} | ||||
|         titles = [(_('Key'), 1, 235), | ||||
|                   (_('Value'), 2, 325)] | ||||
|         titles = [(_('Namespace'), 0, 150), | ||||
|                   (_('Label'), 1, 150), | ||||
|                   (_(' '), 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 | ||||
|  | ||||
|                     label = metadata.get_tag_label(key) | ||||
|                     namespace = self.__get_tag_namespace(key) | ||||
|  | ||||
|                     node = self.__add_section(section) | ||||
|                     self.model.add([namespace, label, None, human_value], node=node) | ||||
|  | ||||
|                     self.model.tree.expand_all() | ||||
|                     retval = self.model.count > 0 | ||||
| @@ -227,29 +290,131 @@ 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) | ||||
|  | ||||
|             label = metadata.get_tag_label(region_name % i) | ||||
|             namespace = self.__get_tag_namespace(region_name % i) | ||||
|  | ||||
|             node = self.__add_section(PEOPLE) | ||||
|             self.model.add([namespace, label, 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) | ||||
|  | ||||
|     def __get_tag_namespace(self, key): | ||||
|  | ||||
|         x = key.split(".") | ||||
|         del x[-1] | ||||
|         namespace =  '.'.join(x) | ||||
|  | ||||
|         return namespace | ||||
|   | ||||