From 9d71a9b9a13f709e50158bdb61cf7ee5d379f845 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Wed, 27 Nov 2024 12:11:44 +0000 Subject: [PATCH 01/18] Update note on Exif SubjectArea to Region mapping The IPTC have withdrawn their suggested mapping between Exif SubjectArea and IPTC Image Region. --- src/photini/types.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/photini/types.py b/src/photini/types.py index 30616f39..db0240db 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1861,6 +1861,10 @@ def from_exiv2(cls, file_value, tag): return cls(file_value) # Convert Exif.Photo.SubjectArea to an image region. See # https://www.iptc.org/std/photometadata/documentation/userguide/#_mapping_exif_subjectarea_iptc_image_region + # Later the above was deprecated. See + # https://www.iptc.org/std/photometadata/documentation/userguide/#_note_about_the_exif_subjectarea_and_the_iptc_image_region + # I'm leaving this in for now as it's a one way mapping so should be + # mostly harmless. if len(file_value) == 2: region = {'Iptc4xmpExt:rbShape': 'polygon', 'Iptc4xmpExt:rbVertices': [{ From a83ad7f6bc0f44696840195c0b5c8b86becd8cfa Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Wed, 27 Nov 2024 13:11:39 +0000 Subject: [PATCH 02/18] Select nearest aspect ratio for crop image regions Previously a ratio of 16:9 was forced, now it is either 4:3, 3:2 or 16:9 according to the current region shape. --- src/photini/regions.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index 4e017949..03f5191d 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -529,6 +529,17 @@ def set_image(self, image): scene.addItem(item) scene.setSceneRect(item.boundingRect()) + @staticmethod + def nearest_aspect(width, height): + aspect = width / height + candidates = (4.0 / 3.0, 3.0 / 2.0, 16.0 / 9.0) + lo = candidates[0] + for hi in candidates[1:]: + if aspect <= math.sqrt(lo * hi): + return lo + lo = hi + return candidates[-1] + @QtSlot(int, list) @catch_all def draw_boundaries(self, idx, regions): @@ -547,12 +558,16 @@ def draw_boundaries(self, idx, regions): boundary = region['Iptc4xmpExt:RegionBoundary'] if boundary['Iptc4xmpExt:rbShape'] == 'rectangle': aspect_ratio = 0.0 + width = boundary['Iptc4xmpExt:rbW'] + height = boundary['Iptc4xmpExt:rbH'] + if self.transform().isRotating(): + width, height = height, width if region.has_role('imgregrole:squareCropping'): aspect_ratio = 1.0 elif region.has_role('imgregrole:landscapeCropping'): - aspect_ratio = 16.0 / 9.0 + aspect_ratio = self.nearest_aspect(width, height) elif region.has_role('imgregrole:portraitCropping'): - aspect_ratio = 9.0 / 16.0 + aspect_ratio = 1.0 / self.nearest_aspect(height, width) if aspect_ratio and self.transform().isRotating(): aspect_ratio = 1.0 / aspect_ratio boundary = RectangleRegion( From 74b5ec4065378d01a6231fd8dc0875cb4a5b8bff Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Fri, 29 Nov 2024 11:36:19 +0000 Subject: [PATCH 03/18] Allow for Qt versions with .devNNN appended --- src/photini/pyqt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/photini/pyqt.py b/src/photini/pyqt.py index 03267d70..a800df1d 100644 --- a/src/photini/pyqt.py +++ b/src/photini/pyqt.py @@ -121,12 +121,12 @@ else: qt_version_info = namedtuple( 'qt_version_info', ('major', 'minor', 'micro'))._make( - map(int, QtCore.QT_VERSION_STR.split('.'))) + map(int, QtCore.QT_VERSION_STR.split('.')[:3])) qt_version = 'PyQt {}, Qt {}'.format( QtCore.PYQT_VERSION_STR, QtCore.QT_VERSION_STR) pyqt_version_info = namedtuple( 'pyqt_version_info', ('major', 'minor', 'micro'))._make( - map(int, QtCore.PYQT_VERSION_STR.split('.'))) + map(int, QtCore.PYQT_VERSION_STR.split('.')[:3])) if pyqt_version_info < (5, 11): raise ImportError( 'PyQt version {}.{}.{} is less than 5.11'.format(*pyqt_version_info)) From c67f3ae2515a991401683b83f0464e9effb8df0f Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Fri, 29 Nov 2024 12:04:16 +0000 Subject: [PATCH 04/18] Set crop aspect ratio during resizing of region This allows the user to change it from 4:3 to 16:9 (for example) easily. --- src/photini/regions.py | 69 +++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index 03f5191d..4641d715 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -127,11 +127,11 @@ def set_scale(self): class RectangleRegion(QtWidgets.QGraphicsRectItem, RegionMixin): def __init__(self, region, display_widget, draw_unit, active, - aspect_ratio=0.0, *arg, **kw): + constraint=None, *arg, **kw): super(RectangleRegion, self).__init__(*arg, **kw) self.initialise(region, display_widget, active) self.setFlag(self.GraphicsItemFlag.ItemSendsGeometryChanges) - self.aspect_ratio = aspect_ratio + self.constraint = constraint self.handles = [] if active: for idx in range(4): @@ -149,7 +149,7 @@ def __init__(self, region, display_widget, draw_unit, active, def itemChange(self, change, value): scene = self.scene() if scene and change == self.GraphicsItemChange.ItemSceneHasChanged: - if self.aspect_ratio: + if self.constraint: self.handle_drag(self.handles[3], self.handles[3].pos()) return if scene and change == self.GraphicsItemChange.ItemPositionChange: @@ -175,16 +175,33 @@ def mouseReleaseEvent(self, event): super(RectangleRegion, self).mouseReleaseEvent(event) self.new_boundary() + @staticmethod + def nearest_aspect(width, height): + aspect = abs(width / height) + candidates = (4.0 / 3.0, 3.0 / 2.0, 16.0 / 9.0) + lo = candidates[0] + for hi in candidates[1:]: + if aspect <= math.sqrt(lo * hi): + return lo + lo = hi + return candidates[-1] + def handle_drag(self, handle, pos): idx = self.handles.index(handle) anchor = self.handles[3-idx].pos() rect = QtCore.QRectF(anchor, pos) - if self.aspect_ratio: + if self.constraint: # enlarge rectangle to correct aspect ratio w = rect.width() h = rect.height() - w_new = abs(h * self.aspect_ratio) - h_new = abs(w / self.aspect_ratio) + if self.constraint == 'square': + aspect_ratio = 1.0 + elif self.constraint == 'landscape': + aspect_ratio = self.nearest_aspect(w, h) + elif self.constraint == 'portrait': + aspect_ratio = 1.0 / self.nearest_aspect(h, w) + w_new = abs(h * aspect_ratio) + h_new = abs(w / aspect_ratio) if h_new < abs(h): rect.setWidth(w_new * abs(w) / w) else: @@ -200,12 +217,12 @@ def handle_drag(self, handle, pos): min(max(pos.x(), bounds.x()), bounds.right()), min(max(pos.y(), bounds.y()), bounds.bottom())) rect = QtCore.QRectF(anchor, pos) - if self.aspect_ratio: + if self.constraint: # shrink rectangle to correct aspect ratio w = rect.width() h = rect.height() - w_new = abs(h * self.aspect_ratio) - h_new = abs(w / self.aspect_ratio) + w_new = abs(h * aspect_ratio) + h_new = abs(w / aspect_ratio) if h_new > abs(h): rect.setWidth(w_new * abs(w) / w) else: @@ -529,17 +546,6 @@ def set_image(self, image): scene.addItem(item) scene.setSceneRect(item.boundingRect()) - @staticmethod - def nearest_aspect(width, height): - aspect = width / height - candidates = (4.0 / 3.0, 3.0 / 2.0, 16.0 / 9.0) - lo = candidates[0] - for hi in candidates[1:]: - if aspect <= math.sqrt(lo * hi): - return lo - lo = hi - return candidates[-1] - @QtSlot(int, list) @catch_all def draw_boundaries(self, idx, regions): @@ -557,21 +563,22 @@ def draw_boundaries(self, idx, regions): active = n == idx boundary = region['Iptc4xmpExt:RegionBoundary'] if boundary['Iptc4xmpExt:rbShape'] == 'rectangle': - aspect_ratio = 0.0 - width = boundary['Iptc4xmpExt:rbW'] - height = boundary['Iptc4xmpExt:rbH'] - if self.transform().isRotating(): - width, height = height, width if region.has_role('imgregrole:squareCropping'): - aspect_ratio = 1.0 + constraint = 'square' elif region.has_role('imgregrole:landscapeCropping'): - aspect_ratio = self.nearest_aspect(width, height) + if self.transform().isRotating(): + constraint = 'portrait' + else: + constraint = 'landscape' elif region.has_role('imgregrole:portraitCropping'): - aspect_ratio = 1.0 / self.nearest_aspect(height, width) - if aspect_ratio and self.transform().isRotating(): - aspect_ratio = 1.0 / aspect_ratio + if self.transform().isRotating(): + constraint = 'landscape' + else: + constraint = 'portrait' + else: + constraint = None boundary = RectangleRegion( - region, self, draw_unit, active, aspect_ratio=aspect_ratio) + region, self, draw_unit, active, constraint=constraint) elif boundary['Iptc4xmpExt:rbShape'] == 'circle': boundary = CircleRegion(region, self, draw_unit, active) elif len(boundary['Iptc4xmpExt:rbVertices']) == 1: From 0d41f17404dd164f23d756bb0fe356f5e4132264 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Fri, 29 Nov 2024 12:20:17 +0000 Subject: [PATCH 05/18] Precompute thresholds for aspect ratio setting --- src/photini/regions.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index 4641d715..a6856847 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -175,16 +175,13 @@ def mouseReleaseEvent(self, event): super(RectangleRegion, self).mouseReleaseEvent(event) self.new_boundary() - @staticmethod - def nearest_aspect(width, height): + @classmethod + def nearest_aspect(cls, width, height): aspect = abs(width / height) - candidates = (4.0 / 3.0, 3.0 / 2.0, 16.0 / 9.0) - lo = candidates[0] - for hi in candidates[1:]: - if aspect <= math.sqrt(lo * hi): - return lo - lo = hi - return candidates[-1] + for idx, boundary in enumerate(cls.ar_thresholds): + if aspect <= boundary: + return cls.aspect_ratios[idx] + return cls.aspect_ratios[-1] def handle_drag(self, handle, pos): idx = self.handles.index(handle) @@ -250,6 +247,13 @@ def adjust_handles(self): self.handles[2].setPos(rect.bottomLeft()) self.handles[3].setPos(rect.bottomRight()) + aspect_ratios = (4.0 / 3.0, 3.0 / 2.0, 16.0 / 9.0) + +RectangleRegion.ar_thresholds = [ + math.sqrt(RectangleRegion.aspect_ratios[x-1] * + RectangleRegion.aspect_ratios[x]) + for x in range(1, len(RectangleRegion.aspect_ratios))] + class CircleRegion(QtWidgets.QGraphicsEllipseItem, RegionMixin): def __init__(self, region, display_widget, draw_unit, active, *arg, **kw): From cf7acfe8dbc0f2e3c71b80ccd9b20b65439a2a9b Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Fri, 13 Dec 2024 15:09:22 +0000 Subject: [PATCH 06/18] Enable reading of MWG image regions This isn't fully implemented yet, but should be enough to be useful. --- src/photini/metadata.py | 3 +- src/photini/types.py | 125 ++++++++++++++++++++++++++++++---------- 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/photini/metadata.py b/src/photini/metadata.py index 78108ef3..10cbd08f 100644 --- a/src/photini/metadata.py +++ b/src/photini/metadata.py @@ -444,7 +444,8 @@ def get_all_tags(self): 'headline' : (('WA', 'Xmp.photoshop.Headline'), ('WA', 'Iptc.Application2.Headline')), 'image_region' : (('WN', 'Exif.Photo.SubjectArea'), - ('WA', 'Xmp.iptcExt.ImageRegion')), + ('WA', 'Xmp.iptcExt.ImageRegion'), + ('WN', 'Xmp.mwg-rs.Regions')), 'instructions' : (('WA', 'Xmp.photoshop.Instructions'), ('WA', 'Iptc.Application2.SpecialInstructions')), 'keywords' : (('WA', 'Xmp.dc.subject'), diff --git a/src/photini/types.py b/src/photini/types.py index db0240db..de418456 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1851,44 +1851,96 @@ class ImageRegionItem(MD_Structure): 'Iptc4xmpExt:OrganisationInImageName': MD_MultiString, 'photoshop:CaptionWriter': MD_String, 'dc:description': MD_LangAlt, + 'xmpRights:UsageTerms': MD_LangAlt, } @classmethod def from_exiv2(cls, file_value, tag): if not file_value: return None - if tag.startswith('Xmp'): + if tag == 'Xmp.iptcExt.ImageRegion': return cls(file_value) - # Convert Exif.Photo.SubjectArea to an image region. See - # https://www.iptc.org/std/photometadata/documentation/userguide/#_mapping_exif_subjectarea_iptc_image_region - # Later the above was deprecated. See - # https://www.iptc.org/std/photometadata/documentation/userguide/#_note_about_the_exif_subjectarea_and_the_iptc_image_region - # I'm leaving this in for now as it's a one way mapping so should be - # mostly harmless. - if len(file_value) == 2: - region = {'Iptc4xmpExt:rbShape': 'polygon', - 'Iptc4xmpExt:rbVertices': [{ + if tag == 'Xmp.mwg-rs.Regions': + pprint.pprint(file_value) + # convert MWG region data to IPTC format + area = file_value['mwg-rs:Area'] + x = float(area['stArea:x']) + y = float(area['stArea:y']) + if 'stArea:h' in area: + # rectangle + w = float(area['stArea:w']) + h = float(area['stArea:h']) + region = {'Iptc4xmpExt:rbShape': 'rectangle', + 'Iptc4xmpExt:rbX': x - (w / 2), + 'Iptc4xmpExt:rbY': y - (h / 2), + 'Iptc4xmpExt:rbW': w, + 'Iptc4xmpExt:rbH': h} + elif 'stArea:d' in area: + # circle + # TODO: d is relative to smaller of image w & h, + # IPTC radius is along X axis + d = float(area['stArea:d']) + region = {'Iptc4xmpExt:rbShape': 'circle', + 'Iptc4xmpExt:rbX': x, + 'Iptc4xmpExt:rbY': y, + 'Iptc4xmpExt:rbRx': d / 2} + else: + # point + region = {'Iptc4xmpExt:rbShape': 'polygon', + 'Iptc4xmpExt:rbVertices': [{ + 'Iptc4xmpExt:rbX': x, + 'Iptc4xmpExt:rbY': y}]} + if area['stArea:unit'] == 'normalized': + region['Iptc4xmpExt:rbUnit'] = 'relative' + else: + raise ValueError('Unrecognised stArea:unit value "{}"'.format( + area['stArea:unit'])) + region = {'Iptc4xmpExt:RegionBoundary': region} + if 'mwg-rs:Type' in file_value: + region['Iptc4xmpExt:rCtype'] = [{ + 'Iptc4xmpExt:Name': {'en-GB': file_value['mwg-rs:Type']}}] + if 'mwg-rs:Name' in file_value: + region['Iptc4xmpExt:Name'] = file_value['mwg-rs:Name'] + if 'mwg-rs:Description' in file_value: + region['dc:description'] = file_value['mwg-rs:Description'] + if 'mwg-rs:Extensions' in file_value: + region.update(file_value['mwg-rs:Extensions']) + if 'rdfs:seeAlso' in file_value: + logger.warning('MWG image region refers to other data: %s', + file_value['rdfs:seeAlso']) + return cls(region) + if tag == 'Exif.Photo.SubjectArea': + # Convert Exif.Photo.SubjectArea to an image region. See + # https://www.iptc.org/std/photometadata/documentation/userguide/#_mapping_exif_subjectarea_iptc_image_region + # Later the above was deprecated. See + # https://www.iptc.org/std/photometadata/documentation/userguide/#_note_about_the_exif_subjectarea_and_the_iptc_image_region + # I'm leaving this in for now as it's a one way mapping so should be + # mostly harmless. + if len(file_value) == 2: + region = {'Iptc4xmpExt:rbShape': 'polygon', + 'Iptc4xmpExt:rbVertices': [{ + 'Iptc4xmpExt:rbX': file_value[0], + 'Iptc4xmpExt:rbY': file_value[1]}]} + elif len(file_value) == 3: + region = {'Iptc4xmpExt:rbShape': 'circle', 'Iptc4xmpExt:rbX': file_value[0], - 'Iptc4xmpExt:rbY': file_value[1]}]} - elif len(file_value) == 3: - region = {'Iptc4xmpExt:rbShape': 'circle', - 'Iptc4xmpExt:rbX': file_value[0], - 'Iptc4xmpExt:rbY': file_value[1], - 'Iptc4xmpExt:rbRx': file_value[2] // 2} - elif len(file_value) == 4: - region = {'Iptc4xmpExt:rbShape': 'rectangle', - 'Iptc4xmpExt:rbX': file_value[0] - (file_value[2] // 2), - 'Iptc4xmpExt:rbY': file_value[1] - (file_value[3] // 2), - 'Iptc4xmpExt:rbW': file_value[2], - 'Iptc4xmpExt:rbH': file_value[3]} - else: - return None - region['Iptc4xmpExt:rbUnit'] = 'pixel' - return cls({ - 'Iptc4xmpExt:RegionBoundary': region, - 'Iptc4xmpExt:rRole': [image_region_roles[ - image_region_roles_idx['imgregrole:mainSubjectArea']]['data']], - }) + 'Iptc4xmpExt:rbY': file_value[1], + 'Iptc4xmpExt:rbRx': file_value[2] // 2} + elif len(file_value) == 4: + region = {'Iptc4xmpExt:rbShape': 'rectangle', + 'Iptc4xmpExt:rbX': file_value[0] - (file_value[2] // 2), + 'Iptc4xmpExt:rbY': file_value[1] - (file_value[3] // 2), + 'Iptc4xmpExt:rbW': file_value[2], + 'Iptc4xmpExt:rbH': file_value[3]} + else: + return None + region['Iptc4xmpExt:rbUnit'] = 'pixel' + return cls({ + 'Iptc4xmpExt:RegionBoundary': region, + 'Iptc4xmpExt:rRole': [image_region_roles[ + image_region_roles_idx['imgregrole:mainSubjectArea']]['data']], + }) + return None def has_uid(self, key, uid): if key not in self: @@ -1927,6 +1979,19 @@ def convert_unit(self, unit, image): class MD_ImageRegion(MD_StructArray): item_type = ImageRegionItem + @classmethod + def from_exiv2(cls, file_value, tag): + if not file_value: + return cls() + if 'mwg' in tag: + file_value = file_value['mwg-rs:RegionList'] + # Exif and IPTC only store one item, XMP stores any number + if tag.startswith('Xmp'): + file_value = [cls.item_type.from_exiv2(x, tag) for x in file_value] + else: + file_value = [cls.item_type.from_exiv2(file_value, tag)] + return cls(file_value) + def new_region(self, region, idx=None): if idx is None: idx = len(self) From c6fb33ec3f022b3d0f58304644b2362e5ac6aae5 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Fri, 13 Dec 2024 15:46:29 +0000 Subject: [PATCH 07/18] Cludgy way to handle rdfs:seeAlso in MWG regions This reads a couple of 'popular' bits of data that might be referenced and passes them to the data type reader. A more general solution would be better. --- src/photini/metadata.py | 5 ++++- src/photini/types.py | 23 +++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/photini/metadata.py b/src/photini/metadata.py index 10cbd08f..275eeed8 100644 --- a/src/photini/metadata.py +++ b/src/photini/metadata.py @@ -352,6 +352,9 @@ def get_all_tags(self): 'Iptc.Legacy.Location*': ( 'Xmp.iptc.Location', 'Xmp.photoshop.City', 'Xmp.photoshop.State', 'Xmp.photoshop.Country', 'Xmp.iptc.CountryCode'), + 'Xmp.mwg-rs.Regions*': ( + 'Xmp.mwg-rs.Regions', 'Xmp.iptcExt.PersonInImage', + 'Xmp.dc.subject'), 'Xmp.video.Dims*': ('Xmp.video.Width', 'Xmp.video.Height'), 'Xmp.video.Make*': ('Xmp.video.Make', 'Xmp.video.Model'), 'Xmp.xmpRights.*': ( @@ -445,7 +448,7 @@ def get_all_tags(self): ('WA', 'Iptc.Application2.Headline')), 'image_region' : (('WN', 'Exif.Photo.SubjectArea'), ('WA', 'Xmp.iptcExt.ImageRegion'), - ('WN', 'Xmp.mwg-rs.Regions')), + ('WN', 'Xmp.mwg-rs.Regions*')), 'instructions' : (('WA', 'Xmp.photoshop.Instructions'), ('WA', 'Iptc.Application2.SpecialInstructions')), 'keywords' : (('WA', 'Xmp.dc.subject'), diff --git a/src/photini/types.py b/src/photini/types.py index de418456..dae23a02 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1850,7 +1850,9 @@ class ImageRegionItem(MD_Structure): 'Iptc4xmpExt:PersonInImage': MD_MultiString, 'Iptc4xmpExt:OrganisationInImageName': MD_MultiString, 'photoshop:CaptionWriter': MD_String, + 'dc:creator': MD_MultiString, 'dc:description': MD_LangAlt, + 'dc:subject': MD_MultiString, 'xmpRights:UsageTerms': MD_LangAlt, } @@ -1860,8 +1862,7 @@ def from_exiv2(cls, file_value, tag): return None if tag == 'Xmp.iptcExt.ImageRegion': return cls(file_value) - if tag == 'Xmp.mwg-rs.Regions': - pprint.pprint(file_value) + if tag == 'Xmp.mwg-rs.Regions*': # convert MWG region data to IPTC format area = file_value['mwg-rs:Area'] x = float(area['stArea:x']) @@ -1906,8 +1907,12 @@ def from_exiv2(cls, file_value, tag): if 'mwg-rs:Extensions' in file_value: region.update(file_value['mwg-rs:Extensions']) if 'rdfs:seeAlso' in file_value: - logger.warning('MWG image region refers to other data: %s', - file_value['rdfs:seeAlso']) + key = file_value['rdfs:seeAlso'] + if key in file_value: + region[key] = file_value[key] + else: + logger.warning( + 'MWG image region refers to other data: %s', key) return cls(region) if tag == 'Exif.Photo.SubjectArea': # Convert Exif.Photo.SubjectArea to an image region. See @@ -1983,8 +1988,14 @@ class MD_ImageRegion(MD_StructArray): def from_exiv2(cls, file_value, tag): if not file_value: return cls() - if 'mwg' in tag: - file_value = file_value['mwg-rs:RegionList'] + if tag == 'Xmp.mwg-rs.Regions*': + if not file_value[0]: + return None + new_value = file_value[0]['mwg-rs:RegionList'] + for value in new_value: + value['Iptc4xmpExt:PersonInImage'] = file_value[1] + value['dc:subject'] = file_value[2] + file_value = new_value # Exif and IPTC only store one item, XMP stores any number if tag.startswith('Xmp'): file_value = [cls.item_type.from_exiv2(x, tag) for x in file_value] From 57b8af77df1ee896dd50c44286efc6cb3119ab89 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 09:26:01 +0000 Subject: [PATCH 08/18] Better way to handle rdfs:seeAlso Xmp keys This is more general than before and should cope with any occurrence of rdfs:seeAlso anywhere in the XMP data. --- src/photini/exiv2.py | 6 +++++- src/photini/metadata.py | 5 +---- src/photini/types.py | 20 +++++--------------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/photini/exiv2.py b/src/photini/exiv2.py index e1b76488..da5d6da1 100644 --- a/src/photini/exiv2.py +++ b/src/photini/exiv2.py @@ -1,6 +1,6 @@ ## Photini - a simple photo metadata editor. ## http://github.com/jim-easterbrook/Photini -## Copyright (C) 2012-23 Jim Easterbrook jim@jim-easterbrook.me.uk +## Copyright (C) 2012-24 Jim Easterbrook jim@jim-easterbrook.me.uk ## ## This program is free software: you can redistribute it and/or ## modify it under the terms of the GNU General Public License as @@ -511,6 +511,10 @@ def get_xmp_value(self, tag): array_type = value.xmpArrayType() if array_type == exiv2.XmpValue.XmpArrayType.xaNone: value = str(value) + if key.endswith('/rdfs:seeAlso'): + sub_key = 'Xmp.' + value.replace(':', '.') + sub_key = sub_key.replace('Iptc4xmpExt', 'iptcExt') + value = {value: self.get_xmp_value(sub_key)} else: value = [] type_id = { diff --git a/src/photini/metadata.py b/src/photini/metadata.py index 275eeed8..10cbd08f 100644 --- a/src/photini/metadata.py +++ b/src/photini/metadata.py @@ -352,9 +352,6 @@ def get_all_tags(self): 'Iptc.Legacy.Location*': ( 'Xmp.iptc.Location', 'Xmp.photoshop.City', 'Xmp.photoshop.State', 'Xmp.photoshop.Country', 'Xmp.iptc.CountryCode'), - 'Xmp.mwg-rs.Regions*': ( - 'Xmp.mwg-rs.Regions', 'Xmp.iptcExt.PersonInImage', - 'Xmp.dc.subject'), 'Xmp.video.Dims*': ('Xmp.video.Width', 'Xmp.video.Height'), 'Xmp.video.Make*': ('Xmp.video.Make', 'Xmp.video.Model'), 'Xmp.xmpRights.*': ( @@ -448,7 +445,7 @@ def get_all_tags(self): ('WA', 'Iptc.Application2.Headline')), 'image_region' : (('WN', 'Exif.Photo.SubjectArea'), ('WA', 'Xmp.iptcExt.ImageRegion'), - ('WN', 'Xmp.mwg-rs.Regions*')), + ('WN', 'Xmp.mwg-rs.Regions')), 'instructions' : (('WA', 'Xmp.photoshop.Instructions'), ('WA', 'Iptc.Application2.SpecialInstructions')), 'keywords' : (('WA', 'Xmp.dc.subject'), diff --git a/src/photini/types.py b/src/photini/types.py index dae23a02..25997e0c 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1862,7 +1862,7 @@ def from_exiv2(cls, file_value, tag): return None if tag == 'Xmp.iptcExt.ImageRegion': return cls(file_value) - if tag == 'Xmp.mwg-rs.Regions*': + if tag == 'Xmp.mwg-rs.Regions': # convert MWG region data to IPTC format area = file_value['mwg-rs:Area'] x = float(area['stArea:x']) @@ -1907,12 +1907,7 @@ def from_exiv2(cls, file_value, tag): if 'mwg-rs:Extensions' in file_value: region.update(file_value['mwg-rs:Extensions']) if 'rdfs:seeAlso' in file_value: - key = file_value['rdfs:seeAlso'] - if key in file_value: - region[key] = file_value[key] - else: - logger.warning( - 'MWG image region refers to other data: %s', key) + region.update(file_value['rdfs:seeAlso']) return cls(region) if tag == 'Exif.Photo.SubjectArea': # Convert Exif.Photo.SubjectArea to an image region. See @@ -1988,14 +1983,9 @@ class MD_ImageRegion(MD_StructArray): def from_exiv2(cls, file_value, tag): if not file_value: return cls() - if tag == 'Xmp.mwg-rs.Regions*': - if not file_value[0]: - return None - new_value = file_value[0]['mwg-rs:RegionList'] - for value in new_value: - value['Iptc4xmpExt:PersonInImage'] = file_value[1] - value['dc:subject'] = file_value[2] - file_value = new_value + if tag == 'Xmp.mwg-rs.Regions': + pprint.pprint(file_value) + file_value = file_value['mwg-rs:RegionList'] # Exif and IPTC only store one item, XMP stores any number if tag.startswith('Xmp'): file_value = [cls.item_type.from_exiv2(x, tag) for x in file_value] From bd8af2c6d4dbefd24654cd22f8fc01e6b384df27 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 09:32:20 +0000 Subject: [PATCH 09/18] Limit XMP rdfs:seeAlso recursion depth It would be possible to make an XMP file with circular rdfs:seeAlso references, so this limits the recursion to a sensible amount. --- src/photini/exiv2.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/photini/exiv2.py b/src/photini/exiv2.py index da5d6da1..797d1db0 100644 --- a/src/photini/exiv2.py +++ b/src/photini/exiv2.py @@ -486,7 +486,7 @@ def set_item(self, result, key, value): else: result[root] = value - def get_xmp_value(self, tag): + def get_xmp_value(self, tag, see_also_count=2): # XMP has a nested structure of arbitrary depth. Exiv2 converts # this to "flat" tag names. This method converts back to nested # dicts and lists. @@ -512,9 +512,13 @@ def get_xmp_value(self, tag): if array_type == exiv2.XmpValue.XmpArrayType.xaNone: value = str(value) if key.endswith('/rdfs:seeAlso'): - sub_key = 'Xmp.' + value.replace(':', '.') - sub_key = sub_key.replace('Iptc4xmpExt', 'iptcExt') - value = {value: self.get_xmp_value(sub_key)} + if see_also_count > 0: + sub_key = 'Xmp.' + value.replace(':', '.') + sub_key = sub_key.replace('Iptc4xmpExt', 'iptcExt') + value = {value: self.get_xmp_value( + sub_key, see_also_count=see_also_count-1)} + else: + value = {} else: value = [] type_id = { From 4cf5f3d5a0d5aba05144afcf1889517dfd8c80bb Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 11:09:35 +0000 Subject: [PATCH 10/18] Convert MD_ImageRegion to a struct type This is a preliminary to adding the image dimensions stored in MWG regions but not in IPTC regions. --- src/photini/types.py | 217 ++++++++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 98 deletions(-) diff --git a/src/photini/types.py b/src/photini/types.py index 25997e0c..99329051 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1857,90 +1857,91 @@ class ImageRegionItem(MD_Structure): } @classmethod - def from_exiv2(cls, file_value, tag): + def from_IPTC(cls, file_value): + return cls(file_value) + + @classmethod + def from_MWG(cls, file_value): if not file_value: return None - if tag == 'Xmp.iptcExt.ImageRegion': - return cls(file_value) - if tag == 'Xmp.mwg-rs.Regions': - # convert MWG region data to IPTC format - area = file_value['mwg-rs:Area'] - x = float(area['stArea:x']) - y = float(area['stArea:y']) - if 'stArea:h' in area: - # rectangle - w = float(area['stArea:w']) - h = float(area['stArea:h']) - region = {'Iptc4xmpExt:rbShape': 'rectangle', - 'Iptc4xmpExt:rbX': x - (w / 2), - 'Iptc4xmpExt:rbY': y - (h / 2), - 'Iptc4xmpExt:rbW': w, - 'Iptc4xmpExt:rbH': h} - elif 'stArea:d' in area: - # circle - # TODO: d is relative to smaller of image w & h, - # IPTC radius is along X axis - d = float(area['stArea:d']) - region = {'Iptc4xmpExt:rbShape': 'circle', - 'Iptc4xmpExt:rbX': x, - 'Iptc4xmpExt:rbY': y, - 'Iptc4xmpExt:rbRx': d / 2} - else: - # point - region = {'Iptc4xmpExt:rbShape': 'polygon', - 'Iptc4xmpExt:rbVertices': [{ - 'Iptc4xmpExt:rbX': x, - 'Iptc4xmpExt:rbY': y}]} - if area['stArea:unit'] == 'normalized': - region['Iptc4xmpExt:rbUnit'] = 'relative' - else: - raise ValueError('Unrecognised stArea:unit value "{}"'.format( - area['stArea:unit'])) - region = {'Iptc4xmpExt:RegionBoundary': region} - if 'mwg-rs:Type' in file_value: - region['Iptc4xmpExt:rCtype'] = [{ - 'Iptc4xmpExt:Name': {'en-GB': file_value['mwg-rs:Type']}}] - if 'mwg-rs:Name' in file_value: - region['Iptc4xmpExt:Name'] = file_value['mwg-rs:Name'] - if 'mwg-rs:Description' in file_value: - region['dc:description'] = file_value['mwg-rs:Description'] - if 'mwg-rs:Extensions' in file_value: - region.update(file_value['mwg-rs:Extensions']) - if 'rdfs:seeAlso' in file_value: - region.update(file_value['rdfs:seeAlso']) - return cls(region) - if tag == 'Exif.Photo.SubjectArea': - # Convert Exif.Photo.SubjectArea to an image region. See - # https://www.iptc.org/std/photometadata/documentation/userguide/#_mapping_exif_subjectarea_iptc_image_region - # Later the above was deprecated. See - # https://www.iptc.org/std/photometadata/documentation/userguide/#_note_about_the_exif_subjectarea_and_the_iptc_image_region - # I'm leaving this in for now as it's a one way mapping so should be - # mostly harmless. - if len(file_value) == 2: - region = {'Iptc4xmpExt:rbShape': 'polygon', - 'Iptc4xmpExt:rbVertices': [{ - 'Iptc4xmpExt:rbX': file_value[0], - 'Iptc4xmpExt:rbY': file_value[1]}]} - elif len(file_value) == 3: - region = {'Iptc4xmpExt:rbShape': 'circle', + # convert MWG region data to IPTC format + area = file_value['mwg-rs:Area'] + if area['stArea:unit'] == 'normalized': + boundary = {'Iptc4xmpExt:rbUnit': 'relative'} + else: + raise ValueError('Unrecognised stArea:unit value "{}"'.format( + area['stArea:unit'])) + x = float(area['stArea:x']) + y = float(area['stArea:y']) + if 'stArea:h' in area: + # rectangle + w = float(area['stArea:w']) + h = float(area['stArea:h']) + boundary['Iptc4xmpExt:rbShape'] = 'rectangle' + boundary['Iptc4xmpExt:rbX'] = x - (w / 2) + boundary['Iptc4xmpExt:rbY'] = y - (h / 2) + boundary['Iptc4xmpExt:rbW'] = w + boundary['Iptc4xmpExt:rbH'] = h + elif 'stArea:d' in area: + # circle + # TODO: d is relative to smaller of image w & h, + # IPTC radius is along X axis + d = float(area['stArea:d']) + boundary['Iptc4xmpExt:rbShape'] = 'circle' + boundary['Iptc4xmpExt:rbX'] = x + boundary['Iptc4xmpExt:rbY'] = y + boundary['Iptc4xmpExt:rbRx'] = d / 2 + else: + # point + boundary['Iptc4xmpExt:rbShape'] = 'polygon' + boundary['Iptc4xmpExt:rbVertices'] = [{ + 'Iptc4xmpExt:rbX': x, 'Iptc4xmpExt:rbY': y}] + region = {'Iptc4xmpExt:RegionBoundary': boundary} + if 'mwg-rs:Type' in file_value: + region['Iptc4xmpExt:rCtype'] = [{ + 'Iptc4xmpExt:Name': {'en-GB': file_value['mwg-rs:Type']}}] + if 'mwg-rs:Name' in file_value: + region['Iptc4xmpExt:Name'] = file_value['mwg-rs:Name'] + if 'mwg-rs:Description' in file_value: + region['dc:description'] = file_value['mwg-rs:Description'] + if 'mwg-rs:Extensions' in file_value: + region.update(file_value['mwg-rs:Extensions']) + if 'rdfs:seeAlso' in file_value: + region.update(file_value['rdfs:seeAlso']) + return cls(region) + + @classmethod + def from_Exif(cls, file_value): + # Convert Exif.Photo.SubjectArea to an image region. See + # https://www.iptc.org/std/photometadata/documentation/userguide/#_mapping_exif_subjectarea_iptc_image_region + # Later the above was deprecated. See + # https://www.iptc.org/std/photometadata/documentation/userguide/#_note_about_the_exif_subjectarea_and_the_iptc_image_region + # I'm leaving this in for now as it's a one way mapping so should be + # mostly harmless. + if len(file_value) == 2: + region = {'Iptc4xmpExt:rbShape': 'polygon', + 'Iptc4xmpExt:rbVertices': [{ 'Iptc4xmpExt:rbX': file_value[0], - 'Iptc4xmpExt:rbY': file_value[1], - 'Iptc4xmpExt:rbRx': file_value[2] // 2} - elif len(file_value) == 4: - region = {'Iptc4xmpExt:rbShape': 'rectangle', - 'Iptc4xmpExt:rbX': file_value[0] - (file_value[2] // 2), - 'Iptc4xmpExt:rbY': file_value[1] - (file_value[3] // 2), - 'Iptc4xmpExt:rbW': file_value[2], - 'Iptc4xmpExt:rbH': file_value[3]} - else: - return None - region['Iptc4xmpExt:rbUnit'] = 'pixel' - return cls({ - 'Iptc4xmpExt:RegionBoundary': region, - 'Iptc4xmpExt:rRole': [image_region_roles[ - image_region_roles_idx['imgregrole:mainSubjectArea']]['data']], - }) - return None + 'Iptc4xmpExt:rbY': file_value[1]}]} + elif len(file_value) == 3: + region = {'Iptc4xmpExt:rbShape': 'circle', + 'Iptc4xmpExt:rbX': file_value[0], + 'Iptc4xmpExt:rbY': file_value[1], + 'Iptc4xmpExt:rbRx': file_value[2] // 2} + elif len(file_value) == 4: + region = {'Iptc4xmpExt:rbShape': 'rectangle', + 'Iptc4xmpExt:rbX': file_value[0] - (file_value[2] // 2), + 'Iptc4xmpExt:rbY': file_value[1] - (file_value[3] // 2), + 'Iptc4xmpExt:rbW': file_value[2], + 'Iptc4xmpExt:rbH': file_value[3]} + else: + return None + region['Iptc4xmpExt:rbUnit'] = 'pixel' + return cls({ + 'Iptc4xmpExt:RegionBoundary': region, + 'Iptc4xmpExt:rRole': [image_region_roles[ + image_region_roles_idx['imgregrole:mainSubjectArea']]['data']], + }) def has_uid(self, key, uid): if key not in self: @@ -1976,36 +1977,56 @@ def convert_unit(self, unit, image): return result -class MD_ImageRegion(MD_StructArray): +class RegionList(MD_StructArray): item_type = ImageRegionItem + +class MD_ImageRegion(MD_Structure): + item_type = { + 'RegionList': RegionList, + } + + def __new__(cls, value=None): + if value is None: + value = {'RegionList': RegionList()} + return super(MD_ImageRegion, cls).__new__(cls, value) + @classmethod def from_exiv2(cls, file_value, tag): if not file_value: return cls() - if tag == 'Xmp.mwg-rs.Regions': + if tag == 'Xmp.iptcExt.ImageRegion': + regions = [ImageRegionItem.from_IPTC(x) for x in file_value] + elif tag == 'Xmp.mwg-rs.Regions': pprint.pprint(file_value) - file_value = file_value['mwg-rs:RegionList'] - # Exif and IPTC only store one item, XMP stores any number - if tag.startswith('Xmp'): - file_value = [cls.item_type.from_exiv2(x, tag) for x in file_value] - else: - file_value = [cls.item_type.from_exiv2(file_value, tag)] - return cls(file_value) + regions = [ImageRegionItem.from_MWG(x) + for x in file_value['mwg-rs:RegionList']] + elif tag == 'Exif.Photo.SubjectArea': + regions = [ImageRegionItem.from_Exif(file_value)] + return cls({'RegionList': regions}) + + # provide list-like methods for ease of use + def __bool__(self): + return bool(self['RegionList']) + + def __iter__(self): + return iter(self['RegionList']) + + def __len__(self): + return len(self['RegionList']) def new_region(self, region, idx=None): if idx is None: idx = len(self) - result = list(self) + regions = list(self) if region: - if idx < len(self): - result[idx] = region + if idx < len(regions): + regions[idx] = region else: - result.append(region) - elif idx < len(self): - result.pop(idx) - result = MD_ImageRegion(result) - return result + regions.append(region) + elif idx < len(regions): + regions.pop(idx) + return MD_ImageRegion({'RegionList': regions}) def index(self, other): if other.has_role('imgregrole:mainSubjectArea'): From 20cfc37459e2c459fbd0a6b85aa53bcc6d1ef39c Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 12:07:29 +0000 Subject: [PATCH 11/18] Add image dimensions to MD_ImageRegion type --- src/photini/types.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/photini/types.py b/src/photini/types.py index 99329051..110af689 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1981,34 +1981,45 @@ class RegionList(MD_StructArray): item_type = ImageRegionItem -class MD_ImageRegion(MD_Structure): +class AppliedToDimensions(MD_Structure): item_type = { - 'RegionList': RegionList, + 'stDim:w': MD_Int, + 'stDim:h': MD_Int, + 'stDim:unit': MD_String, } def __new__(cls, value=None): - if value is None: - value = {'RegionList': RegionList()} - return super(MD_ImageRegion, cls).__new__(cls, value) + if value and value['stDim:unit'] != 'pixel': + raise ValueError('Unrecognised stDim:unit value "{}"'.format( + value['stDim:unit'])) + return super(AppliedToDimensions, cls).__new__(cls, value) + + +class MD_ImageRegion(MD_Structure): + item_type = { + 'AppliedToDimensions': AppliedToDimensions, + 'RegionList': RegionList, + } @classmethod def from_exiv2(cls, file_value, tag): if not file_value: return cls() if tag == 'Xmp.iptcExt.ImageRegion': - regions = [ImageRegionItem.from_IPTC(x) for x in file_value] + value = {'RegionList': [ImageRegionItem.from_IPTC(x) + for x in file_value]} elif tag == 'Xmp.mwg-rs.Regions': - pprint.pprint(file_value) - regions = [ImageRegionItem.from_MWG(x) - for x in file_value['mwg-rs:RegionList']] + value = { + 'AppliedToDimensions': file_value['mwg-rs:AppliedToDimensions'], + 'RegionList': [ImageRegionItem.from_MWG(x) + for x in file_value['mwg-rs:RegionList']]} elif tag == 'Exif.Photo.SubjectArea': - regions = [ImageRegionItem.from_Exif(file_value)] - return cls({'RegionList': regions}) + value = {'RegionList': [ImageRegionItem.from_Exif(file_value)]} + else: + return cls() + return cls(value) # provide list-like methods for ease of use - def __bool__(self): - return bool(self['RegionList']) - def __iter__(self): return iter(self['RegionList']) @@ -2018,6 +2029,7 @@ def __len__(self): def new_region(self, region, idx=None): if idx is None: idx = len(self) + dimensions = dict(self['AppliedToDimensions']) regions = list(self) if region: if idx < len(regions): @@ -2026,7 +2038,8 @@ def new_region(self, region, idx=None): regions.append(region) elif idx < len(regions): regions.pop(idx) - return MD_ImageRegion({'RegionList': regions}) + return MD_ImageRegion({ + 'AppliedToDimensions': dimensions, 'RegionList': regions}) def index(self, other): if other.has_role('imgregrole:mainSubjectArea'): From 7dbf42cbe8d81786fb5e4a8d4bbf1e60442855be Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 12:23:27 +0000 Subject: [PATCH 12/18] Scale MWG region circle diameter correctly --- src/photini/types.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/photini/types.py b/src/photini/types.py index 110af689..0454f49b 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -1861,7 +1861,7 @@ def from_IPTC(cls, file_value): return cls(file_value) @classmethod - def from_MWG(cls, file_value): + def from_MWG(cls, file_value, scale_diameter): if not file_value: return None # convert MWG region data to IPTC format @@ -1884,9 +1884,7 @@ def from_MWG(cls, file_value): boundary['Iptc4xmpExt:rbH'] = h elif 'stArea:d' in area: # circle - # TODO: d is relative to smaller of image w & h, - # IPTC radius is along X axis - d = float(area['stArea:d']) + d = float(area['stArea:d']) * scale_diameter boundary['Iptc4xmpExt:rbShape'] = 'circle' boundary['Iptc4xmpExt:rbX'] = x boundary['Iptc4xmpExt:rbY'] = y @@ -2009,9 +2007,11 @@ def from_exiv2(cls, file_value, tag): value = {'RegionList': [ImageRegionItem.from_IPTC(x) for x in file_value]} elif tag == 'Xmp.mwg-rs.Regions': + dims = AppliedToDimensions(file_value['mwg-rs:AppliedToDimensions']) + scale_diameter = min(dims['stDim:h'] / dims['stDim:w'], 1.0) value = { - 'AppliedToDimensions': file_value['mwg-rs:AppliedToDimensions'], - 'RegionList': [ImageRegionItem.from_MWG(x) + 'AppliedToDimensions': dims, + 'RegionList': [ImageRegionItem.from_MWG(x, scale_diameter) for x in file_value['mwg-rs:RegionList']]} elif tag == 'Exif.Photo.SubjectArea': value = {'RegionList': [ImageRegionItem.from_Exif(file_value)]} From 44b3a175a49c7f349ff3475ffd9a6334e491bd9e Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 12:50:13 +0000 Subject: [PATCH 13/18] Display warning if image resized If the image dimensions stored with MWG regions don't match the actual image dimensions then the image may have been cropped. --- src/photini/regions.py | 17 +++++++++++++++++ src/photini/types.py | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/src/photini/regions.py b/src/photini/regions.py index a6856847..843af764 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -537,6 +537,23 @@ def set_image(self, image): else: transform = QtGui.QTransform() w_im, h_im = pixmap.width(), pixmap.height() + if image.metadata.image_region: + dims = image.metadata.image_region.get_dimensions() + if dims and (dims['w'] != w_im or dims['h'] != h_im): + dialog = QtWidgets.QMessageBox(parent=self) + dialog.setWindowTitle( + translate('RegionsTab', 'Photini: image size')) + dialog.setText('

{}

'.format( + translate('RegionsTab', 'Image has been resized.'))) + dialog.setInformativeText(translate( + 'RegionsTab', 'Image dimensions {w_im}x{h_im} do' + ' not match region definition {w_reg}x{h_reg}. The' + ' image regions may be incorrect.').format( + w_im=w_im, h_im=h_im, + w_reg=dims['w'], h_reg=dims['h'])) + dialog.setStandardButtons(dialog.StandardButton.Ok) + dialog.setIcon(dialog.Icon.Warning) + execute(dialog) w_sc, h_sc = rect.width(), rect.height() if w_im * h_sc < h_im * w_sc: w_sc -= self.verticalScrollBar().sizeHint().width() diff --git a/src/photini/types.py b/src/photini/types.py index 0454f49b..63b1b9b3 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -2026,6 +2026,12 @@ def __iter__(self): def __len__(self): return len(self['RegionList']) + def get_dimensions(self): + dims = self['AppliedToDimensions'] + if dims: + return {'w': dims['stDim:w'], 'h': dims['stDim:h']} + return {} + def new_region(self, region, idx=None): if idx is None: idx = len(self) From 3d314ac82b08e002546e52601fc0818ffa5ab7f2 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sat, 14 Dec 2024 17:46:50 +0000 Subject: [PATCH 14/18] Enable saving of MWG image regions --- src/photini/metadata.py | 2 +- src/photini/regions.py | 10 ++-- src/photini/types.py | 112 ++++++++++++++++++++++++++++++++++------ 3 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/photini/metadata.py b/src/photini/metadata.py index 10cbd08f..3e14d57a 100644 --- a/src/photini/metadata.py +++ b/src/photini/metadata.py @@ -445,7 +445,7 @@ def get_all_tags(self): ('WA', 'Iptc.Application2.Headline')), 'image_region' : (('WN', 'Exif.Photo.SubjectArea'), ('WA', 'Xmp.iptcExt.ImageRegion'), - ('WN', 'Xmp.mwg-rs.Regions')), + ('WA', 'Xmp.mwg-rs.Regions')), 'instructions' : (('WA', 'Xmp.photoshop.Instructions'), ('WA', 'Iptc.Application2.SpecialInstructions')), 'keywords' : (('WA', 'Xmp.dc.subject'), diff --git a/src/photini/regions.py b/src/photini/regions.py index 843af764..4e59ea9e 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -530,15 +530,16 @@ def set_image(self, image): translate('RegionsTab', 'Unreadable image format')) else: rect = self.contentsRect() - orientation = image.metadata.orientation + md = image.metadata + orientation = md.orientation transform = orientation and orientation.get_transform() if transform: rect = transform.mapRect(rect) else: transform = QtGui.QTransform() w_im, h_im = pixmap.width(), pixmap.height() - if image.metadata.image_region: - dims = image.metadata.image_region.get_dimensions() + if md.image_region: + dims = md.image_region.get_dimensions() if dims and (dims['w'] != w_im or dims['h'] != h_im): dialog = QtWidgets.QMessageBox(parent=self) dialog.setWindowTitle( @@ -1066,6 +1067,9 @@ def new_value(self, idx, value): elif key in region: del region[key] md.image_region = md.image_region.new_region(region, idx) + dims = md.dimensions + md.image_region = md.image_region.set_dimensions({ + 'w': dims['width'], 'h': dims['height']}) if key == 'Iptc4xmpExt:rRole': # aspect ratio constraint may have changed self.new_regions.emit(idx, list(md.image_region)) diff --git a/src/photini/types.py b/src/photini/types.py index 63b1b9b3..9b8c59ff 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -710,7 +710,7 @@ def merge(self, info, tag, other): if other == self: return self result = dict(self) - for key in other: + for key in other.keys(): if not other[key]: continue if key in result and result[key]: @@ -1860,6 +1860,49 @@ class ImageRegionItem(MD_Structure): def from_IPTC(cls, file_value): return cls(file_value) + def to_MWG(self, scale_diameter): + # convert some IPTC region data to MWG format + region = {'mwg-rs:Extensions': {}} + for key, value in self.items(): + if key == 'Iptc4xmpExt:RegionBoundary': + if value['Iptc4xmpExt:rbUnit'] != 'relative': + return None + area = {'stArea:unit': 'normalized'} + if value['Iptc4xmpExt:rbShape'] == 'rectangle': + w = value['Iptc4xmpExt:rbW'] + h = value['Iptc4xmpExt:rbH'] + area['stArea:x'] = value['Iptc4xmpExt:rbX'] + (w / 2) + area['stArea:y'] = value['Iptc4xmpExt:rbY'] + (h / 2) + area['stArea:w'] = w + area['stArea:h'] = h + elif value['Iptc4xmpExt:rbShape'] == 'circle': + area['stArea:x'] = value['Iptc4xmpExt:rbX'] + area['stArea:y'] = value['Iptc4xmpExt:rbY'] + area['stArea:d'] = value['Iptc4xmpExt:rbRx'] * 2 / scale_diameter + elif (value['Iptc4xmpExt:rbShape'] == 'polygon' and + len(value['Iptc4xmpExt:rbVertices']) == 1): + point = value['Iptc4xmpExt:rbVertices'][0] + area['stArea:x'] = point['Iptc4xmpExt:rbX'] + area['stArea:y'] = point['Iptc4xmpExt:rbY'] + else: + return None + region['mwg-rs:Area'] = area + elif key == 'Iptc4xmpExt:rCtype': + for item in value: + type_text, sep, focus_usage = item[ + 'Iptc4xmpExt:Name']['en-GB'].partition('-') + if type_text in ('Face', 'Pet', 'Focus', 'BarCode'): + region['mwg-rs:Type'] = type_text + region['mwg-rs:FocusUsage'] = focus_usage + break + elif key == 'Iptc4xmpExt:Name': + region['mwg-rs:Name'] = value.best_match() + elif key == 'dc:description': + region['mwg-rs:Description'] = value.best_match() + else: + region['mwg-rs:Extensions'][key] = value + return region + @classmethod def from_MWG(cls, file_value, scale_diameter): if not file_value: @@ -1896,8 +1939,15 @@ def from_MWG(cls, file_value, scale_diameter): 'Iptc4xmpExt:rbX': x, 'Iptc4xmpExt:rbY': y}] region = {'Iptc4xmpExt:RegionBoundary': boundary} if 'mwg-rs:Type' in file_value: - region['Iptc4xmpExt:rCtype'] = [{ - 'Iptc4xmpExt:Name': {'en-GB': file_value['mwg-rs:Type']}}] + ctype = file_value['mwg-rs:Type'] + if ctype == 'Focus': + if 'mwg-rs:FocusUsage' in file_value: + ctype += '-' + file_value['mwg-rs:FocusUsage'] + else: + ctype = None + if ctype: + region['Iptc4xmpExt:rCtype'] = [{ + 'Iptc4xmpExt:Name': {'en-GB': ctype}}] if 'mwg-rs:Name' in file_value: region['Iptc4xmpExt:Name'] = file_value['mwg-rs:Name'] if 'mwg-rs:Description' in file_value: @@ -1978,6 +2028,21 @@ def convert_unit(self, unit, image): class RegionList(MD_StructArray): item_type = ImageRegionItem + def index(self, other): + if other.has_role('imgregrole:mainSubjectArea'): + # only one main subject area region allowed + for n, value in enumerate(self): + if value.has_role('imgregrole:mainSubjectArea'): + return n + return len(self) + for n, value in enumerate(self): + match = True + for key in ('Iptc4xmpExt:RegionBoundary', 'Iptc4xmpExt:rId'): + match = match and value[key] and (value[key] == other[key]) + if match: + return n + return len(self) + class AppliedToDimensions(MD_Structure): item_type = { @@ -1987,7 +2052,7 @@ class AppliedToDimensions(MD_Structure): } def __new__(cls, value=None): - if value and value['stDim:unit'] != 'pixel': + if value and value['stDim:unit'] not in ('pixel', ''): raise ValueError('Unrecognised stDim:unit value "{}"'.format( value['stDim:unit'])) return super(AppliedToDimensions, cls).__new__(cls, value) @@ -2019,7 +2084,26 @@ def from_exiv2(cls, file_value, tag): return cls() return cls(value) + def to_exiv2(self, tag): + print('to_exiv2', tag) + if tag == 'Xmp.iptcExt.ImageRegion': + return self['RegionList'].to_exiv2(tag) + if tag == 'Xmp.mwg-rs.Regions': + dims = self['AppliedToDimensions'] + scale_diameter = min(dims['stDim:h'] / dims['stDim:w'], 1.0) + regions = [x.to_MWG(scale_diameter) for x in self] + if not any(regions): + return None + return {'mwg-rs:AppliedToDimensions': dims.to_exiv2(tag), + 'mwg-rs:RegionList': regions} + return None + # provide list-like methods for ease of use + def __getitem__(self, key): + if isinstance(key, int): + return self['RegionList'][key] + return super(MD_ImageRegion, self).__getitem__(key) + def __iter__(self): return iter(self['RegionList']) @@ -2032,6 +2116,13 @@ def get_dimensions(self): return {'w': dims['stDim:w'], 'h': dims['stDim:h']} return {} + def set_dimensions(self, dims): + dimensions = AppliedToDimensions({ + 'stDim:w': dims['w'], 'stDim:h': dims['h'], 'stDim:unit': 'pixel'}) + regions = list(self) + return MD_ImageRegion({ + 'AppliedToDimensions': dimensions, 'RegionList': regions}) + def new_region(self, region, idx=None): if idx is None: idx = len(self) @@ -2047,19 +2138,6 @@ def new_region(self, region, idx=None): return MD_ImageRegion({ 'AppliedToDimensions': dimensions, 'RegionList': regions}) - def index(self, other): - if other.has_role('imgregrole:mainSubjectArea'): - # only one main subject area region allowed - for n, value in enumerate(self): - if value.has_role('imgregrole:mainSubjectArea'): - return True - return len(self) - for n, value in enumerate(self): - for key in ('Iptc4xmpExt:RegionBoundary', 'Iptc4xmpExt:rId'): - if value[key] and value[key] == other[key]: - return n - return len(self) - @staticmethod def boundary_from_note(note, dims, image): if not ('x' in note and 'y' in note and From d5e3a8c02c84248a3f1608b81d9d4b63f1ef71f4 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Sun, 15 Dec 2024 09:44:45 +0000 Subject: [PATCH 15/18] Add separator between region CVs and added items This makes it clear if items are part of a known "controlled vocabulary" or are imported from the file's metadata. --- src/photini/regions.py | 66 ++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index 4e59ea9e..70b3073b 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -650,22 +650,30 @@ def __init__(self, key, vocab, *arg, **kw): self.menu = QtWidgets.QMenu(parent=self) self.menu.setToolTipsVisible(True) self.actions = [] - for item in vocab: + self.add_separator = False + self.add_menu_items(vocab) + + def mousePressEvent(self, event): + self.menu.popup(self.mapToGlobal(event.pos())) + + def add_menu_items(self, items, add_separator=True): + if self.add_separator: + self.menu.addSeparator() + self.add_separator = add_separator + for item in items: label = MD_LangAlt(item['name']).best_match() tip = MD_LangAlt(item['definition']).best_match() if item['note']: tip += ' ({})'.format(MD_LangAlt(item['note']).best_match()) action = self.menu.addAction(label) action.setCheckable(True) - action.setToolTip('

{}

'.format(tip)) + if tip: + action.setToolTip('

{}

'.format(tip)) action.setData(item['data']) action.toggled.connect(self.update_display) action.triggered.connect(self.action_triggered) self.actions.append(action) - def mousePressEvent(self, event): - self.menu.popup(self.mapToGlobal(event.pos())) - @QtSlot(bool) @catch_all def update_display(self, checked=None): @@ -690,41 +698,29 @@ def set_value(self, value): action.setChecked(False) for item in value: found = False - if 'xmp:Identifier' in item: - for uri in item['xmp:Identifier']: - for action in self.actions: - if uri in action.data()['xmp:Identifier']: - action.setChecked(True) - found = True - break + for uri in item['xmp:Identifier']: + for action in self.actions: + if uri in action.data()['xmp:Identifier']: + action.setChecked(True) + found = True + break if found: continue - if 'Iptc4xmpExt:Name' in item: - for name in item['Iptc4xmpExt:Name'].values(): - for action in self.actions: - if name in action.data()['Iptc4xmpExt:Name'].values(): - action.setChecked(True) - found = True - break + for name in item['Iptc4xmpExt:Name'].values(): + for action in self.actions: + if name in action.data()['Iptc4xmpExt:Name'].values(): + action.setChecked(True) + found = True + break if found: continue # add new action - data = {'Iptc4xmpExt:Name': {}, 'xmp:Identifier': []} - data.update(item) - label = data['Iptc4xmpExt:Name'] or { - 'x-default': '; '.join(data['xmp:Identifier'])} - label = MD_LangAlt(label).best_match() - if not label: - continue - action = self.menu.addAction(label) - action.setCheckable(True) - action.setToolTip('

{}

'.format( - '; '.join(data['xmp:Identifier']))) - action.setChecked(True) - action.setData(data) - action.toggled.connect(self.update_display) - action.triggered.connect(self.action_triggered) - self.actions.append(action) + self.add_menu_items([{ + 'data': item, + 'definition': None, + 'name': item[ + 'Iptc4xmpExt:Name'] or '; '.join(item['xmp:Identifier']), + 'note': None}], add_separator=False) self._updating = False self.update_display() From e036171d5419cc541b34ef38a4fe3d3369be84b9 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Mon, 16 Dec 2024 12:46:40 +0000 Subject: [PATCH 16/18] Make xmp:Identifier in CV list a tuple This is a better match to the data read from XMP metadata, enabling easier searching. --- src/photini/cv.py | 54 +++++++++++++++++++++---------------------- utils/download_cvs.py | 2 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/photini/cv.py b/src/photini/cv.py index 52ab9712..f89ae40c 100644 --- a/src/photini/cv.py +++ b/src/photini/cv.py @@ -7,101 +7,101 @@ # http://cv.iptc.org/newscodes/imageregiontype/ image_region_types = \ ({'data': {'Iptc4xmpExt:Name': {'en-GB': 'animal'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/animal']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/animal',)}, 'definition': {'en-GB': 'A living organism different from humans or flora'}, 'name': {'en-GB': 'animal'}, 'note': {}, 'qcode': 'imgregtype:animal'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'artwork'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/artwork']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/artwork',)}, 'definition': {'en-GB': 'Artistic work'}, 'name': {'en-GB': 'artwork'}, 'note': {}, 'qcode': 'imgregtype:artwork'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'dividing line'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/dividingLine']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/dividingLine',)}, 'definition': {'en-GB': 'A line expressing a visual division of the image, ' 'such as a horizon'}, 'name': {'en-GB': 'dividing line'}, 'note': {}, 'qcode': 'imgregtype:dividingLine'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'plant'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/plant']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/plant',)}, 'definition': {'en-GB': 'A living organism different from humans and ' 'animals'}, 'name': {'en-GB': 'plant'}, 'note': {}, 'qcode': 'imgregtype:plant'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'geographic area'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/geoArea']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/geoArea',)}, 'definition': {'en-GB': 'A named area on the surface of the planet earth'}, 'name': {'en-GB': 'geographic area'}, 'note': {'en-GB': 'Specific details of the area can be expressed by other ' 'metadata'}, 'qcode': 'imgregtype:geoArea'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'graphic'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/graphic']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/graphic',)}, 'definition': {'en-GB': 'A graphic representation of information'}, 'name': {'en-GB': 'graphic'}, 'note': {}, 'qcode': 'imgregtype:graphic'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'machine-readable code'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/machineCode']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/machineCode',)}, 'definition': {'en-GB': 'Optical label such as barcode or QR code'}, 'name': {'en-GB': 'machine-readable code'}, 'note': {}, 'qcode': 'imgregtype:machineCode'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'human'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/human']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/human',)}, 'definition': {'en-GB': 'A human being'}, 'name': {'en-GB': 'human'}, 'note': {}, 'qcode': 'imgregtype:human'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'product'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/product']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/product',)}, 'definition': {'en-GB': 'A thing that was produced and can be handed over'}, 'name': {'en-GB': 'product'}, 'note': {}, 'qcode': 'imgregtype:product'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'text'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/text']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/text',)}, 'definition': {'en-GB': 'Human readable script of any language'}, 'name': {'en-GB': 'text'}, 'note': {}, 'qcode': 'imgregtype:text'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'building'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/building']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/building',)}, 'definition': {'en-GB': 'A structure with walls and roof in most cases'}, 'name': {'en-GB': 'building'}, 'note': {}, 'qcode': 'imgregtype:building'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'vehicle'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/vehicle']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/vehicle',)}, 'definition': {'en-GB': 'An object used for transporting something, like ' 'car, train, ship, plane or bike'}, 'name': {'en-GB': 'vehicle'}, 'note': {}, 'qcode': 'imgregtype:vehicle'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'food'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/food']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/food',)}, 'definition': {'en-GB': 'Substances providing nutrition for a living body'}, 'name': {'en-GB': 'food'}, 'note': {}, 'qcode': 'imgregtype:food'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'clothing'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/clothing']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/clothing',)}, 'definition': {'en-GB': 'Something worn to cover the body'}, 'name': {'en-GB': 'clothing'}, 'note': {}, 'qcode': 'imgregtype:clothing'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'rock formation'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/rockFormation']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/rockFormation',)}, 'definition': {'en-GB': 'A special formation of stone mass'}, 'name': {'en-GB': 'rock formation'}, 'note': {}, 'qcode': 'imgregtype:rockFormation'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'body of water'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregiontype/bodyOfWater']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregiontype/bodyOfWater',)}, 'definition': {'en-GB': 'A significant accumulation of water'}, 'name': {'en-GB': 'body of water'}, 'note': {'en-GB': 'Including a waterfall, a geyser and other phenomena of ' @@ -132,60 +132,60 @@ # http://cv.iptc.org/newscodes/imageregionrole/ image_region_roles = \ ({'data': {'Iptc4xmpExt:Name': {'en-GB': 'cropping'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/cropping']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/cropping',)}, 'definition': {'en-GB': 'Image region can be used for any cropping'}, 'name': {'en-GB': 'cropping'}, 'note': {}, 'qcode': 'imgregrole:cropping'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'recommended cropping'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/recomCropping']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/recomCropping',)}, 'definition': {'en-GB': 'Image region is recommended for cropping'}, 'name': {'en-GB': 'recommended cropping'}, 'note': {}, 'qcode': 'imgregrole:recomCropping'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'landscape format cropping'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/landscapeCropping']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/landscapeCropping',)}, 'definition': {'en-GB': 'Image region suggested for cropping in landscape ' 'format'}, 'name': {'en-GB': 'landscape format cropping'}, 'note': {'en-GB': 'Use for images of non-landscape format'}, 'qcode': 'imgregrole:landscapeCropping'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'portrait format cropping'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/portraitCropping']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/portraitCropping',)}, 'definition': {'en-GB': 'Image region suggested for cropping in portrait ' 'format'}, 'name': {'en-GB': 'portrait format cropping'}, 'note': {'en-GB': 'Use for images of non-portrait format'}, 'qcode': 'imgregrole:portraitCropping'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'square format cropping'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/squareCropping']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/squareCropping',)}, 'definition': {'en-GB': 'Image region suggested for cropping in square ' 'format'}, 'name': {'en-GB': 'square format cropping'}, 'note': {}, 'qcode': 'imgregrole:squareCropping'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'composite image item'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/compositeImageItem']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/compositeImageItem',)}, 'definition': {'en-GB': 'Image region of an item in a composite image'}, 'name': {'en-GB': 'composite image item'}, 'note': {}, 'qcode': 'imgregrole:compositeImageItem'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'copyright region'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/copyrightRegion']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/copyrightRegion',)}, 'definition': {'en-GB': 'Image region with a copyright different from the ' 'copyright of the whole picture'}, 'name': {'en-GB': 'copyright region'}, 'note': {}, 'qcode': 'imgregrole:copyrightRegion'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'subject area'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/subjectArea']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/subjectArea',)}, 'definition': {'en-GB': 'Image region contains a subject in the overall ' 'scene.'}, 'name': {'en-GB': 'subject area'}, 'note': {'en-GB': 'Multiple regions of an image may be set as subject area.'}, 'qcode': 'imgregrole:subjectArea'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'main subject area'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/mainSubjectArea']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/mainSubjectArea',)}, 'definition': {'en-GB': 'Image region contains the main subject in the ' 'overall scene. Same as the Exif SubjectArea.'}, 'name': {'en-GB': 'main subject area'}, @@ -193,14 +193,14 @@ 'subject area.'}, 'qcode': 'imgregrole:mainSubjectArea'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'area of interest'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/areaOfInterest']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/areaOfInterest',)}, 'definition': {'en-GB': 'Image region contains a thing of special interest ' 'to the viewer'}, 'name': {'en-GB': 'area of interest'}, 'note': {}, 'qcode': 'imgregrole:areaOfInterest'}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'business use'}, - 'xmp:Identifier': ['http://cv.iptc.org/newscodes/imageregionrole/businessUse']}, + 'xmp:Identifier': ('http://cv.iptc.org/newscodes/imageregionrole/businessUse',)}, 'definition': {'en-GB': 'Image region is dedicated to a specific business ' 'use'}, 'name': {'en-GB': 'business use'}, diff --git a/utils/download_cvs.py b/utils/download_cvs.py index 863c9cf2..ff4ae6aa 100644 --- a/utils/download_cvs.py +++ b/utils/download_cvs.py @@ -63,7 +63,7 @@ def main(argv=None): data[uri]['note'].update(concept['note']) data[uri]['qcode'] = concept['qcode'] data[uri]['data'] = { - 'xmp:Identifier': [concept['uri']], + 'xmp:Identifier': (concept['uri'],), 'Iptc4xmpExt:Name': concept['prefLabel']} py.write(data_name) py.write(' = \\\n') From 7c72ece55f5d8dfd598705ff6c83835c498c2c91 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Mon, 16 Dec 2024 15:12:47 +0000 Subject: [PATCH 17/18] Integrated MWG type into region's type widget --- src/photini/regions.py | 87 +++++++++++++++++++++++---------- src/photini/types.py | 108 +++++++++++++++++++++++++++++------------ 2 files changed, 139 insertions(+), 56 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index 70b3073b..a0bf6caf 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -555,6 +555,10 @@ def set_image(self, image): dialog.setStandardButtons(dialog.StandardButton.Ok) dialog.setIcon(dialog.Icon.Warning) execute(dialog) + changed = md.changed() + md.image_region = md.image_region.set_dimensions({ + 'w': w_im, 'h': h_im}) + md.set_changed(changed) w_sc, h_sc = rect.width(), rect.height() if w_im * h_sc < h_im * w_sc: w_sc -= self.verticalScrollBar().sizeHint().width() @@ -656,10 +660,13 @@ def __init__(self, key, vocab, *arg, **kw): def mousePressEvent(self, event): self.menu.popup(self.mapToGlobal(event.pos())) - def add_menu_items(self, items, add_separator=True): + def add_menu_items(self, items, add_separator=True, exclusive=False): if self.add_separator: self.menu.addSeparator() self.add_separator = add_separator + if exclusive: + group = QtWidgets.QActionGroup(self) + group.setExclusionPolicy(group.ExclusionPolicy.ExclusiveOptional) for item in items: label = MD_LangAlt(item['name']).best_match() tip = MD_LangAlt(item['definition']).best_match() @@ -667,6 +674,9 @@ def add_menu_items(self, items, add_separator=True): tip += ' ({})'.format(MD_LangAlt(item['note']).best_match()) action = self.menu.addAction(label) action.setCheckable(True) + action.setChecked(True) + if exclusive: + group.addAction(action) if tip: action.setToolTip('

{}

'.format(tip)) action.setData(item['data']) @@ -697,30 +707,17 @@ def set_value(self, value): if action.isChecked(): action.setChecked(False) for item in value: - found = False - for uri in item['xmp:Identifier']: - for action in self.actions: - if uri in action.data()['xmp:Identifier']: - action.setChecked(True) - found = True - break - if found: - continue - for name in item['Iptc4xmpExt:Name'].values(): - for action in self.actions: - if name in action.data()['Iptc4xmpExt:Name'].values(): - action.setChecked(True) - found = True - break - if found: - continue - # add new action - self.add_menu_items([{ - 'data': item, - 'definition': None, - 'name': item[ - 'Iptc4xmpExt:Name'] or '; '.join(item['xmp:Identifier']), - 'note': None}], add_separator=False) + for action in self.actions: + if item == action.data(): + action.setChecked(True) + break + else: + # add new action + self.add_menu_items([{ + 'data': item, + 'definition': None, + 'name': item['Iptc4xmpExt:Name'], + 'note': None}], add_separator=False) self._updating = False self.update_display() @@ -778,6 +775,41 @@ def set_value(self, value): class RegionForm(QtWidgets.QScrollArea): new_value = QtSignal(int, dict) + MWG_region_types = ( + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Face'}, + 'xmp:Identifier': ('mwg-rs:Type Face',)}, + 'definition': None, + 'name': {'en-GB': 'Face'}, + 'note': None}, + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Pet'}, + 'xmp:Identifier': ('mwg-rs:Type Pet',)}, + 'definition': None, + 'name': {'en-GB': 'Pet'}, + 'note': None}, + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/EvaluatedUsed'}, + 'xmp:Identifier': ('mwg-rs:Type Focus', + 'mwg-rs:FocusUsage EvaluatedUsed')}, + 'definition': None, + 'name': {'en-GB': 'Focus (EvaluatedUsed)'}, + 'note': None}, + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/EvaluatedNotUsed'}, + 'xmp:Identifier': ('mwg-rs:Type Focus', + 'mwg-rs:FocusUsage EvaluatedNotUsed')}, + 'definition': None, + 'name': {'en-GB': 'Focus (EvaluatedNotUsed)'}, + 'note': None}, + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/NotEvaluatedNotUsed'}, + 'xmp:Identifier': ('mwg-rs:Type Focus' + 'mwg-rs:FocusUsage NotEvaluatedNotUsed')}, + 'definition': None, + 'name': {'en-GB': 'Focus (NotEvaluatedNotUsed)'}, + 'note': None}, + {'data': {'Iptc4xmpExt:Name': {'en-GB': 'BarCode'}, + 'xmp:Identifier': ('mwg-rs:Type BarCode',)}, + 'definition': None, + 'name': {'en-GB': 'BarCode'}, + 'note': None}, + ) def __init__(self, idx, *arg, **kw): super(RegionForm, self).__init__(*arg, **kw) @@ -830,6 +862,8 @@ def __init__(self, idx, *arg, **kw): # content types key = 'Iptc4xmpExt:rCtype' self.widgets[key] = EntityConceptWidget(key, image_region_types) + self.widgets[key].add_menu_items( + self.MWG_region_types, exclusive=True) self.widgets[key].setToolTip('

{}

'.format(translate( 'RegionsTab', 'The semantic type of what is shown inside the' ' region. The value SHOULD be taken from a Controlled' @@ -1013,6 +1047,9 @@ def add_region(self, boundary): region = {'Iptc4xmpExt:RegionBoundary': boundary} md = self.image.metadata md.image_region = md.image_region.new_region(region) + dims = md.dimensions + md.image_region = md.image_region.set_dimensions({ + 'w': dims['width'], 'h': dims['height']}) self.set_image(self.image) self.setCurrentIndex(len(md.image_region) - 1) diff --git a/src/photini/types.py b/src/photini/types.py index 9b8c59ff..d326220f 100644 --- a/src/photini/types.py +++ b/src/photini/types.py @@ -844,12 +844,17 @@ def to_iptc(self): def to_xmp(self): return [x.to_xmp() for x in self] + def find(self, other): + if other in self: + return self.index(other) + return len(self) + def merge(self, info, tag, other): result = self for item in other: if not isinstance(item, self.item_type): item = self.item_type(item) - idx = result.index(item) + idx = result.find(item) result = list(result) if idx < len(result): result[idx] = result[idx].merge(info, tag, item) @@ -1682,7 +1687,7 @@ def from_address(cls, gps, address, key_map): class MD_MultiLocation(MD_StructArray): item_type = MD_Location - def index(self, other): + def find(self, other): for n, value in enumerate(self): if value == other: return n @@ -1690,7 +1695,7 @@ def index(self, other): class MD_SingleLocation(MD_MultiLocation): - def index(self, other): + def find(self, other): return 0 @@ -1708,6 +1713,9 @@ class EntityConcept(MD_Structure): 'xmp:Identifier': ConceptIndentifier, } + def __eq__(self, other): + return self['xmp:Identifier'] == other['xmp:Identifier'] + class EntityConceptArray(MD_StructArray): item_type = EntityConcept @@ -1849,6 +1857,7 @@ class ImageRegionItem(MD_Structure): 'Iptc4xmpExt:rRole': EntityConceptArray, 'Iptc4xmpExt:PersonInImage': MD_MultiString, 'Iptc4xmpExt:OrganisationInImageName': MD_MultiString, + 'mwg-rs:BarCodeValue': MD_String, 'photoshop:CaptionWriter': MD_String, 'dc:creator': MD_MultiString, 'dc:description': MD_LangAlt, @@ -1856,14 +1865,59 @@ class ImageRegionItem(MD_Structure): 'xmpRights:UsageTerms': MD_LangAlt, } + @staticmethod + def ctype_IPTC_to_MWG(file_value): + if 'Iptc4xmpExt:rCtype' not in file_value: + return file_value, {} + # move MWG type info from Iptc4xmpExt:rCtype to extra info + ctype_list = [] + extras = {} + for item in file_value['Iptc4xmpExt:rCtype']: + if any(x.startswith('mwg') for x in item['xmp:Identifier']): + extras.update(x.split() for x in item['xmp:Identifier']) + else: + ctype_list.append(item) + file_value['Iptc4xmpExt:rCtype'] = ctype_list + return file_value, extras + + def to_IPTC(self): + # move MWG type info from Iptc4xmpExt:rCtype to extra info + file_value, extras = self.ctype_IPTC_to_MWG(dict(self)) + file_value.update(extras) + return file_value + + @staticmethod + def ctype_MWG_to_IPTC(file_value): + if 'mwg-rs:Type' not in file_value: + return file_value, {} + # move MWG type info from extra info to Iptc4xmpExt:rCtype + name = file_value['mwg-rs:Type'] + del file_value['mwg-rs:Type'] + identifier = ['mwg-rs:Type ' + name] + if 'mwg-rs:FocusUsage' in file_value: + focus_usage = file_value['mwg-rs:FocusUsage'] + del file_value['mwg-rs:FocusUsage'] + name += '/' + focus_usage + identifier.append('mwg-rs:FocusUsage ' + focus_usage) + return file_value, { + 'Iptc4xmpExt:Name': name, 'xmp:Identifier': identifier} + @classmethod def from_IPTC(cls, file_value): + file_value, ctype = cls.ctype_MWG_to_IPTC(file_value) + if ctype: + if 'Iptc4xmpExt:rCtype' not in file_value: + file_value['Iptc4xmpExt:rCtype'] = [] + file_value['Iptc4xmpExt:rCtype'].append(ctype) return cls(file_value) def to_MWG(self, scale_diameter): + file_value, region = self.ctype_IPTC_to_MWG(dict(self)) # convert some IPTC region data to MWG format - region = {'mwg-rs:Extensions': {}} - for key, value in self.items(): + region['mwg-rs:Extensions'] = {} + for key, value in file_value.items(): + if not value: + continue if key == 'Iptc4xmpExt:RegionBoundary': if value['Iptc4xmpExt:rbUnit'] != 'relative': return None @@ -1887,18 +1941,12 @@ def to_MWG(self, scale_diameter): else: return None region['mwg-rs:Area'] = area - elif key == 'Iptc4xmpExt:rCtype': - for item in value: - type_text, sep, focus_usage = item[ - 'Iptc4xmpExt:Name']['en-GB'].partition('-') - if type_text in ('Face', 'Pet', 'Focus', 'BarCode'): - region['mwg-rs:Type'] = type_text - region['mwg-rs:FocusUsage'] = focus_usage - break elif key == 'Iptc4xmpExt:Name': region['mwg-rs:Name'] = value.best_match() elif key == 'dc:description': region['mwg-rs:Description'] = value.best_match() + elif key == 'mwg-rs:BarCodeValue': + region[key] = value else: region['mwg-rs:Extensions'][key] = value return region @@ -1938,24 +1986,22 @@ def from_MWG(cls, file_value, scale_diameter): boundary['Iptc4xmpExt:rbVertices'] = [{ 'Iptc4xmpExt:rbX': x, 'Iptc4xmpExt:rbY': y}] region = {'Iptc4xmpExt:RegionBoundary': boundary} - if 'mwg-rs:Type' in file_value: - ctype = file_value['mwg-rs:Type'] - if ctype == 'Focus': - if 'mwg-rs:FocusUsage' in file_value: - ctype += '-' + file_value['mwg-rs:FocusUsage'] - else: - ctype = None - if ctype: - region['Iptc4xmpExt:rCtype'] = [{ - 'Iptc4xmpExt:Name': {'en-GB': ctype}}] + file_value, ctype = cls.ctype_MWG_to_IPTC(file_value) + region['Iptc4xmpExt:rCtype'] = [ctype] if 'mwg-rs:Name' in file_value: region['Iptc4xmpExt:Name'] = file_value['mwg-rs:Name'] if 'mwg-rs:Description' in file_value: region['dc:description'] = file_value['mwg-rs:Description'] - if 'mwg-rs:Extensions' in file_value: - region.update(file_value['mwg-rs:Extensions']) - if 'rdfs:seeAlso' in file_value: - region.update(file_value['rdfs:seeAlso']) + if 'mwg-rs:BarCodeValue' in file_value: + region['mwg-rs:BarCodeValue'] = file_value['mwg-rs:BarCodeValue'] + region = cls(region) + for key in ('mwg-rs:Extensions', 'rdfs:seeAlso'): + if key in file_value: + for k, v in file_value[key].items(): + if k in region: + region[k] = region[k].merge('info', 'tag', v) + else: + region[k] = v return cls(region) @classmethod @@ -2028,7 +2074,7 @@ def convert_unit(self, unit, image): class RegionList(MD_StructArray): item_type = ImageRegionItem - def index(self, other): + def find(self, other): if other.has_role('imgregrole:mainSubjectArea'): # only one main subject area region allowed for n, value in enumerate(self): @@ -2038,7 +2084,8 @@ def index(self, other): for n, value in enumerate(self): match = True for key in ('Iptc4xmpExt:RegionBoundary', 'Iptc4xmpExt:rId'): - match = match and value[key] and (value[key] == other[key]) + if value[key]: + match = match and (value[key] == other[key]) if match: return n return len(self) @@ -2085,9 +2132,8 @@ def from_exiv2(cls, file_value, tag): return cls(value) def to_exiv2(self, tag): - print('to_exiv2', tag) if tag == 'Xmp.iptcExt.ImageRegion': - return self['RegionList'].to_exiv2(tag) + return [x.to_IPTC() for x in self] if tag == 'Xmp.mwg-rs.Regions': dims = self['AppliedToDimensions'] scale_diameter = min(dims['stDim:h'] / dims['stDim:w'], 1.0) From 1f29cbfba8e8480fb18fb03080ee21046b7985d6 Mon Sep 17 00:00:00 2001 From: Jim Easterbrook Date: Mon, 16 Dec 2024 17:26:46 +0000 Subject: [PATCH 18/18] Add MWG content type definitions. --- src/photini/regions.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/photini/regions.py b/src/photini/regions.py index a0bf6caf..920f53d3 100644 --- a/src/photini/regions.py +++ b/src/photini/regions.py @@ -778,35 +778,46 @@ class RegionForm(QtWidgets.QScrollArea): MWG_region_types = ( {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Face'}, 'xmp:Identifier': ('mwg-rs:Type Face',)}, - 'definition': None, + 'definition': {'en-GB': "Region area for people's faces."}, 'name': {'en-GB': 'Face'}, 'note': None}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Pet'}, 'xmp:Identifier': ('mwg-rs:Type Pet',)}, - 'definition': None, + 'definition': {'en-GB': "Region area for pets."}, 'name': {'en-GB': 'Pet'}, 'note': None}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/EvaluatedUsed'}, 'xmp:Identifier': ('mwg-rs:Type Focus', 'mwg-rs:FocusUsage EvaluatedUsed')}, - 'definition': None, + 'definition': {'en-GB': "Region area for camera auto-focus regions." + "
EvaluatedUsed specifies that the focus point was" + " considered during focusing and was used in the final" + " image."}, 'name': {'en-GB': 'Focus (EvaluatedUsed)'}, 'note': None}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/EvaluatedNotUsed'}, 'xmp:Identifier': ('mwg-rs:Type Focus', 'mwg-rs:FocusUsage EvaluatedNotUsed')}, - 'definition': None, + 'definition': {'en-GB': "Region area for camera auto-focus regions." + "
EvaluatedNotUsed specifies that the focus point" + " was considered during focusing but not utilised in" + " the final image."}, 'name': {'en-GB': 'Focus (EvaluatedNotUsed)'}, 'note': None}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'Focus/NotEvaluatedNotUsed'}, 'xmp:Identifier': ('mwg-rs:Type Focus' 'mwg-rs:FocusUsage NotEvaluatedNotUsed')}, - 'definition': None, + 'definition': {'en-GB': "Region area for camera auto-focus regions." + "
NotEvaluatedNotUsed specifies that a focus point" + " was not evaluated and not used, e.g. a fixed focus" + " point on the camera which was not used in any" + " fashion."}, 'name': {'en-GB': 'Focus (NotEvaluatedNotUsed)'}, 'note': None}, {'data': {'Iptc4xmpExt:Name': {'en-GB': 'BarCode'}, 'xmp:Identifier': ('mwg-rs:Type BarCode',)}, - 'definition': None, + 'definition': {'en-GB': "One dimensional linear or two dimensional" + " matrix optical code."}, 'name': {'en-GB': 'BarCode'}, 'note': None}, )