diff --git a/picframe/config/configuration_example.yaml b/picframe/config/configuration_example.yaml index 6f05253..88a887e 100644 --- a/picframe/config/configuration_example.yaml +++ b/picframe/config/configuration_example.yaml @@ -22,6 +22,7 @@ viewer: display_y: 0 # offset from top of screen (can be negative) display_w: null # width of display surface (null->None will use max returned by hardware) display_h: null # height of display surface + display_power: 0 # default=0. choices={0, 1}, 0 will use legacy `vcgencmd` and 1 will use `xset` to blank the display use_glx: False # default=False. Set to True on linux with xserver running mat_images: 0.01 # default=0.01, True, automatically mat all images. False, don't automatically mat any images. Real value, auto-mat all images with aspect ratio difference > than value diff --git a/picframe/get_image_meta.py b/picframe/get_image_meta.py index a3d62f8..1dbac68 100644 --- a/picframe/get_image_meta.py +++ b/picframe/get_image_meta.py @@ -1,7 +1,13 @@ -import exifread import logging import os from PIL import Image +from PIL.Image import Exif +from PIL.ExifTags import TAGS, GPSTAGS +from pi_heif import register_heif_opener +from fractions import Fraction + + +register_heif_opener() class GetImageMeta: @@ -9,17 +15,91 @@ def __init__(self, filename): self.__logger = logging.getLogger("get_image_meta.GetImageMeta") self.__tags = {} self.__filename = filename # in case no exif data in which case needed for size + image = self.get_image_object(filename) + if image: + exif = image.getexif() + self.__do_image_tags(exif) + self.__do_exif_tags(exif) + self.__do_geo_tags(exif) + self.__do_iptc_keywords() + xmp = image.getxmp() + if len(xmp) > 0: + self.__do_xmp_keywords(xmp) + + def __do_image_tags(self, exif): + tags = { + "Image " + TAGS.get(key, key): value + for key, value in exif.items() + } + self.__tags.update(tags) + + def __do_exif_tags(self, exif): + for key, value in TAGS.items(): + if value == "ExifOffset": + break + info = exif.get_ifd(key) + tags = { + "EXIF " + TAGS.get(key, key): value + for key, value in info.items() + } + self.__tags.update(tags) + + def __do_geo_tags(self, exif): + for key, value in TAGS.items(): + if value == "GPSInfo": + break + gps_info = exif.get_ifd(key) + tags = { + "GPS " + GPSTAGS.get(key, key): value + for key, value in gps_info.items() + } + self.__tags.update(tags) + + def __find_xmp_key(self, key, dic): + for k, v in dic.items(): + if key == k: + return v + elif isinstance(v, dict): + val = self.__find_xmp_key(key, v) + if val: + return val + elif isinstance(v, list): + for x in v: + if isinstance(x, dict): + val = self.__find_xmp_key(key, x) + if val: + return val + return None + + def __do_xmp_keywords(self, xmp): try: - with open(filename, 'rb') as fh: - self.__tags = exifread.process_file(fh, details=False) - except OSError as e: - self.__logger.warning("Can't open file: \"%s\"", filename) - self.__logger.warning("Cause: %s", e) - #raise # the system should be able to withstand files being moved etc without crashing + # title + val = self.__find_xmp_key('Headline', xmp) + if val and isinstance(val, str) and len(val) > 0: + self.__tags['IPTC Object Name'] = val + # caption + try: + val = self.__find_xmp_key('description', xmp) + if val: + val = val['Alt']['li']['text'] + if val and isinstance(val, str) and len(val) > 0: + self.__tags['IPTC Caption/Abstract'] = val + except KeyError: + pass + # tags + try: + val = self.__find_xmp_key('subject', xmp) + if val: + val = val['Bag']['li'] + if val and isinstance(val, list) and len(val) > 0: + tags = '' + for tag in val: + tags += tag + "," + self.__tags['IPTC Keywords'] = tags + except KeyError: + pass except Exception as e: - self.__logger.warning("exifread doesn't manage well and gives AttributeError for heif files %s -> %s", - filename, e) - self.__do_iptc_keywords() + self.__logger.warning("xmp loading has failed: %s -> %s", self.__filename, e) def __do_iptc_keywords(self): try: @@ -59,11 +139,8 @@ def __get_if_exist(self, key): return None def __convert_to_degrees(self, value): - (deg, min, sec) = value.values - d = float(deg.num) / float(deg.den if deg.den > 0 else 1) #TODO better catching? - m = float(min.num) / float(min.den if min.den > 0 else 1) - s = float(sec.num) / float(sec.den if sec.den > 0 else 1) - return d + (m / 60.0) + (s / 3600.0) + (deg, min, sec) = value + return deg + (min / 60.0) + (sec / 3600.0) def get_location(self): gps = {"latitude": None, "longitude": None} @@ -78,12 +155,12 @@ def get_location(self): try: if gps_latitude and gps_latitude_ref and gps_longitude and gps_longitude_ref: lat = self.__convert_to_degrees(gps_latitude) - if len(gps_latitude_ref.values) > 0 and gps_latitude_ref.values[0] == 'S': + if len(gps_latitude_ref) > 0 and gps_latitude_ref[0] == 'S': # assume zero length string means N lat = 0 - lat gps["latitude"] = lat lon = self.__convert_to_degrees(gps_longitude) - if len(gps_longitude_ref.values) and gps_longitude_ref.values[0] == 'W': + if len(gps_longitude_ref) and gps_longitude_ref[0] == 'W': lon = 0 - lon gps["longitude"] = lon except Exception as e: @@ -94,7 +171,7 @@ def get_orientation(self): try: val = self.__get_if_exist('Image Orientation') if val is not None: - return int(val.values[0]) + return val else: return 1 except Exception as e: @@ -120,14 +197,14 @@ def get_exif(self, key): elif grp == "Image": newkey = "EXIF" + " " + tag val = self.__get_if_exist(newkey) - if val is not None: - if key == 'EXIF FNumber': - val = round(val.values[0].num / val.values[0].den, 1) - elif key in ['IPTC Keywords', 'IPTC Caption/Abstract', 'IPTC Object Name']: - return val - else: - val = val.printable - return val + if val: + if key == "EXIF ExposureTime": + val = str(Fraction(val)) + elif key == "EXIF FocalLength": + val = str(val) + elif key == "EXIF FNumber": + val = float(val) + return val except Exception as e: self.__logger.warning("get_exif failed on %s -> %s", self.__filename, e) return None @@ -141,25 +218,15 @@ def get_size(self): @staticmethod def get_image_object(fname): - ext = os.path.splitext(fname)[1].lower() - if ext in ('.heif','.heic'): - try: - import pyheif - - heif_file = pyheif.read(fname) - image = Image.frombytes(heif_file.mode, heif_file.size, heif_file.data, - "raw", heif_file.mode, heif_file.stride) - if image.mode not in ("RGB", "RGBA"): - image = image.convert("RGB") - return image - except: - logger = logging.getLogger("get_image_meta.GetImageMeta") - logger.warning("Failed attempt to convert %s \n** Have you installed pyheif? **", fname) - else: - try: - image = Image.open(fname) - if image.mode not in ("RGB", "RGBA"): # mat system needs RGB or more - image = image.convert("RGB") - except: # for whatever reason - image = None - return image \ No newline at end of file + ext = os.path.splitext(fname)[1].lower() + try: + image = Image.open(fname) + if image.mode not in ("RGB", "RGBA"): # mat system needs RGB or more + image = image.convert("RGB") + #raise # the system should be able to withstand files being moved etc without crashing + except Exception as e: + logger = logging.getLogger("get_image_meta.GetImageMeta") + logger.warning("Can't open file: \"%s\"", fname) + logger.warning("Cause: %s", e) + image = None + return image \ No newline at end of file diff --git a/picframe/interface_http.py b/picframe/interface_http.py index 39d6424..0b17505 100644 --- a/picframe/interface_http.py +++ b/picframe/interface_http.py @@ -18,19 +18,18 @@ def heif_to_jpg(fname): try: - import pyheif from PIL import Image + from pi_heif import register_heif_opener - heif_file = pyheif.read(fname) - image = Image.frombytes(heif_file.mode, heif_file.size, heif_file.data, - "raw", heif_file.mode, heif_file.stride) + register_heif_opener() + image = Image.open(fname) if image.mode not in ("RGB", "RGBA"): image = image.convert("RGB") image.save("/dev/shm/temp.jpg") # default 75% quality return "/dev/shm/temp.jpg" except: logger = logging.getLogger("interface_http.heif_to_jpg") - logger.warning("Failed attempt to convert %s \n** Have you installed pyheif? **", fname) + logger.warning("Failed attempt to convert %s \n** Have you installed pi_heif? **", fname) return "" # this will not render as a page and will generate error TODO serve specific page with explicit error class RequestHandler(BaseHTTPRequestHandler): diff --git a/picframe/model.py b/picframe/model.py index eba82a3..8b7677b 100644 --- a/picframe/model.py +++ b/picframe/model.py @@ -34,6 +34,7 @@ 'display_y': 0, 'display_w': None, 'display_h': None, + 'display_power': 0, 'use_glx': False, # default=False. Set to True on linux with xserver running 'test_key': 'test_value', 'mat_images': True, diff --git a/picframe/start.py b/picframe/start.py index fe7ab87..16fe533 100644 --- a/picframe/start.py +++ b/picframe/start.py @@ -114,17 +114,16 @@ def main(): print("\nChecking required packages......") required_packages=[ 'PIL', - 'exifread', 'pi3d', 'yaml', 'paho.mqtt', 'iptcinfo3', 'numpy', - 'ninepatch' + 'ninepatch', + 'pi_heif', + 'defusedxml' ] check_packages(required_packages) - print("\nChecking optional packages......") - check_packages(['pyheif']) return elif args.configfile: m = model.Model(args.configfile) diff --git a/picframe/viewer_display.py b/picframe/viewer_display.py index f8cd86e..bdb2daa 100644 --- a/picframe/viewer_display.py +++ b/picframe/viewer_display.py @@ -74,6 +74,7 @@ def __init__(self, config): self.__display_y = int(config['display_y']) self.__display_w = None if config['display_w'] is None else int(config['display_w']) self.__display_h = None if config['display_h'] is None else int(config['display_h']) + self.__display_power = int(config['display_power']) self.__use_glx = config['use_glx'] #self.__codepoints = config['codepoints'] self.__alpha = 0.0 # alpha - proportion front image to back @@ -103,15 +104,18 @@ def __init__(self, config): @property def display_is_on(self): - try: # vcgencmd only applies to raspberry pi - state = str(subprocess.check_output(["vcgencmd", "display_power"])) - if (state.find("display_power=1") != -1): - return True - else: - return False - except Exception as e: - self.__logger.debug("Display ON/OFF is vcgencmd, but an error occurred") - self.__logger.debug("Cause: %s", e) + if self.__display_power == 0: + try: # vcgencmd only applies to raspberry pi + state = str(subprocess.check_output(["vcgencmd", "display_power"])) + if (state.find("display_power=1") != -1): + return True + else: + return False + except Exception as e: + self.__logger.debug("Display ON/OFF is vcgencmd, but an error occurred") + self.__logger.debug("Cause: %s", e) + return True + elif self.__display_power == 1: try: # try xset on linux, DPMS has to be enabled output = subprocess.check_output(["xset" , "-display", ":0", "-q"]) if output.find(b'Monitor is On') != -1: @@ -121,19 +125,24 @@ def display_is_on(self): except Exception as e: self.__logger.debug("Display ON/OFF is X with dpms enabled, but an error occurred") self.__logger.debug("Cause: %s", e) - self.__logger.warning("Display ON/OFF is not supported for this platform.") - return True + return True + else: + self.__logger.warning("Unsupported setting for display_power=%d.", self.__display_power) + return True @display_is_on.setter def display_is_on(self, on_off): - try: # vcgencmd only applies to raspberry pi - if on_off == True: - subprocess.call(["vcgencmd", "display_power", "1"]) - else: - subprocess.call(["vcgencmd", "display_power", "0"]) - except Exception as e: - self.__logger.debug("Display ON/OFF is vcgencmd, but an error occured") - self.__logger.debug("Cause: %s", e) + self.__logger.debug("Switch display (display_power=%d).", self.__display_power) + if self.__display_power == 0: + try: # vcgencmd only applies to raspberry pi + if on_off == True: + subprocess.call(["vcgencmd", "display_power", "1"]) + else: + subprocess.call(["vcgencmd", "display_power", "0"]) + except Exception as e: + self.__logger.debug("Display ON/OFF is vcgencmd, but an error occured") + self.__logger.debug("Cause: %s", e) + elif self.__display_power == 1: try: # try xset on linux, DPMS has to be enabled if on_off == True: subprocess.call(["xset" , "-display", ":0", "dpms", "force", "on"]) @@ -142,7 +151,8 @@ def display_is_on(self, on_off): except Exception as e: self.__logger.debug("Display ON/OFF is xset via dpms, but an error occured") self.__logger.debug("Cause: %s", e) - self.__logger.warning("Display ON/OFF is not supported for this platform.") + else: + self.__logger.warning("Unsupported setting for display_power=%d.", self.__display_power) def set_show_text(self, txt_key=None, val="ON"): if txt_key is None: diff --git a/setup.py b/setup.py index 7ab3719..db8a2db 100644 --- a/setup.py +++ b/setup.py @@ -32,13 +32,14 @@ 'html/*', 'html/**/*']}, install_requires=[ 'Pillow>=9.0.0', - 'ExifRead', + 'defusedxml', 'pi3d>=2.49', 'PyYAML', 'paho-mqtt', 'IPTCInfo3', 'numpy', - 'ninepatch' + 'ninepatch', + 'pi_heif>=0.8.0' ], entry_points = { 'console_scripts': ['picframe=picframe.start:main'] diff --git a/test/images/sample1.heic b/test/images/sample1.heic new file mode 100644 index 0000000..00cc549 Binary files /dev/null and b/test/images/sample1.heic differ diff --git a/test/images/test3.HEIC b/test/images/test3.HEIC index 992732d..793e2cd 100644 Binary files a/test/images/test3.HEIC and b/test/images/test3.HEIC differ diff --git a/test/test_get_image_meta.py b/test/test_get_image_meta.py index f153ecf..aed982f 100644 --- a/test/test_get_image_meta.py +++ b/test/test_get_image_meta.py @@ -58,9 +58,9 @@ def test_exifs_jpg(): val = exifs.get_exif('EXIF ExposureTime') assert val == "1/30" val = exifs.get_exif('EXIF ISOSpeedRatings') - assert val == "6400" + assert val == 6400 val = exifs.get_exif('EXIF FocalLength') - assert val == "17" + assert val == "17.0" val = exifs.get_exif('EXIF DateTimeOriginal') assert val == "2020:01:30 20:01:28" val = exifs.get_exif('Image Model') @@ -89,7 +89,7 @@ def test_get_orientation(): exifs = GetImageMeta("test/images/test3.HEIC") orientation = exifs.get_orientation() - assert orientation == 6 + assert orientation == 1 except: pytest.fail("Unexpected exception") @@ -97,7 +97,7 @@ def test_exifs_heic(): try: exifs = GetImageMeta("test/images/test3.HEIC") orientation = exifs.get_orientation() - assert orientation == 6 + assert orientation == 1 width, height = exifs.get_size() assert height == 4032 @@ -117,10 +117,10 @@ def test_exifs_heic(): assert exposure_time == "1/5" iso = exifs.get_exif('EXIF ISOSpeedRatings') - assert iso == "100" + assert iso == 100 focal_length = exifs.get_exif('EXIF FocalLength') - assert focal_length == "399/100" + assert focal_length == "3.99" rating = exifs.get_exif('EXIF Rating') assert rating == None @@ -138,13 +138,13 @@ def test_exifs_heic(): #IPTC tags = exifs.get_exif('IPTC Keywords') - assert tags == None + assert tags == 'Stichwort1,Stichwort2,' title = exifs.get_exif('IPTC Object Name') - assert title == None + assert title == 'Das ist die Überschrift' caption = exifs.get_exif('IPTC Caption/Abstract') - assert caption == None + assert caption == 'Hier ist die Beschreibung' except: pytest.fail("Unexpected exception") \ No newline at end of file