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
This commit is contained in:
Bruce Jackson 2021-02-16 12:39:39 -05:00 committed by Nick Hall
parent 70520be80c
commit 35306bc68c

View File

@ -2,8 +2,9 @@
# -*- coding: utf-8 -*-
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2011 Nick Hall
# 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,15 +60,19 @@ 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)
@ -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)