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
|
||||
|