finally got the hang of bw/colour image handling

This commit is contained in:
mrbwburns 2024-01-20 17:15:03 +01:00
parent c515da04f0
commit 6d660f48c3
3 changed files with 148 additions and 162 deletions

View File

@ -7,9 +7,10 @@ Copyright by aceinnolab
""" """
import logging import logging
import os import os
from typing import Literal
import PIL
import numpy import numpy
import PIL
import requests import requests
from PIL import Image from PIL import Image
@ -17,8 +18,7 @@ logger = logging.getLogger(__name__)
class Inkyimage: class Inkyimage:
"""Custom Imgae class written for commonly used image operations. """Custom Imgae class written for commonly used image operations."""
"""
def __init__(self, image=None): def __init__(self, image=None):
"""Initialize InkyImage module""" """Initialize InkyImage module"""
@ -27,9 +27,9 @@ class Inkyimage:
self.image = image self.image = image
# give an OK message # give an OK message
logger.info(f'{__name__} loaded') logger.info(f"{__name__} loaded")
def load(self, path:str) -> None: def load(self, path: str) -> None:
"""loads an image from a URL or filepath. """loads an image from a URL or filepath.
Args: Args:
@ -45,54 +45,54 @@ class Inkyimage:
""" """
# Try to open the image if it exists and is an image file # Try to open the image if it exists and is an image file
try: try:
if path.startswith('http'): if path.startswith("http"):
logger.info('loading image from URL') logger.info("loading image from URL")
image = Image.open(requests.get(path, stream=True).raw) image = Image.open(requests.get(path, stream=True).raw)
else: else:
logger.info('loading image from local path') logger.info("loading image from local path")
image = Image.open(path) image = Image.open(path)
except FileNotFoundError: except FileNotFoundError:
logger.error('No image file found', exc_info=True) logger.error("No image file found", exc_info=True)
raise Exception(f'Your file could not be found. Please check the filepath: {path}') raise Exception(f"Your file could not be found. Please check the filepath: {path}")
except OSError: except OSError:
logger.error('Invalid Image file provided', exc_info=True) logger.error("Invalid Image file provided", exc_info=True)
raise Exception('Please check if the path points to an image file.') raise Exception("Please check if the path points to an image file.")
logger.info(f'width: {image.width}, height: {image.height}') logger.info(f"width: {image.width}, height: {image.height}")
image.convert(mode='RGBA') # convert to a more suitable format image.convert(mode="RGBA") # convert to a more suitable format
self.image = image self.image = image
logger.info('loaded Image') logger.info("loaded Image")
def clear(self): def clear(self):
"""Removes currently saved image if present.""" """Removes currently saved image if present."""
if self.image: if self.image:
self.image = None self.image = None
logger.info('cleared previous image') logger.info("cleared previous image")
def _preview(self): def _preview(self):
"""Preview the image on gpicview (only works on Rapsbian with Desktop)""" """Preview the image on gpicview (only works on Rapsbian with Desktop)"""
if self._image_loaded(): if self._image_loaded():
path = '/home/pi/Desktop/' path = "/home/pi/Desktop/"
self.image.save(path + 'temp.png') self.image.save(path + "temp.png")
os.system("gpicview " + path + 'temp.png') os.system("gpicview " + path + "temp.png")
os.system('rm ' + path + 'temp.png') os.system("rm " + path + "temp.png")
@staticmethod @staticmethod
def preview(image): def preview(image):
"""Previews an image on gpicview (only works on Rapsbian with Desktop).""" """Previews an image on gpicview (only works on Rapsbian with Desktop)."""
path = '~/temp' path = "~/temp"
image.save(path + '/temp.png') image.save(path + "/temp.png")
os.system("gpicview " + path + '/temp.png') os.system("gpicview " + path + "/temp.png")
os.system('rm ' + path + '/temp.png') os.system("rm " + path + "/temp.png")
def _image_loaded(self): def _image_loaded(self):
"""returns True if image was loaded""" """returns True if image was loaded"""
if self.image: if self.image:
return True return True
else: else:
logger.error('image not loaded') logger.error("image not loaded")
return False return False
def flip(self, angle): def flip(self, angle):
@ -105,12 +105,12 @@ class Inkyimage:
image = self.image image = self.image
if not angle % 90 == 0: if not angle % 90 == 0:
logger.error('Angle must be a multiple of 90') logger.error("Angle must be a multiple of 90")
return return
image = image.rotate(angle, expand=True) image = image.rotate(angle, expand=True)
self.image = image self.image = image
logger.info(f'flipped image by {angle} degrees') logger.info(f"flipped image by {angle} degrees")
def autoflip(self, layout: str) -> None: def autoflip(self, layout: str) -> None:
"""flips the image automatically to the given layout. """flips the image automatically to the given layout.
@ -129,17 +129,17 @@ class Inkyimage:
if self._image_loaded(): if self._image_loaded():
image = self.image image = self.image
if layout == 'horizontal': if layout == "horizontal":
if image.height > image.width: if image.height > image.width:
logger.info('image width greater than image height, flipping') logger.info("image width greater than image height, flipping")
image = image.rotate(90, expand=True) image = image.rotate(90, expand=True)
elif layout == 'vertical': elif layout == "vertical":
if image.width > image.height: if image.width > image.height:
logger.info('image width greater than image height, flipping') logger.info("image width greater than image height, flipping")
image = image.rotate(90, expand=True) image = image.rotate(90, expand=True)
else: else:
logger.error('layout not supported') logger.error("layout not supported")
return return
self.image = image self.image = image
@ -153,26 +153,26 @@ class Inkyimage:
image = self.image image = self.image
if len(image.getbands()) == 4: if len(image.getbands()) == 4:
logger.info('removing alpha channel') logger.info("removing alpha channel")
bg = Image.new('RGBA', (image.width, image.height), 'white') bg = Image.new("RGBA", (image.width, image.height), "white")
im = Image.alpha_composite(bg, image) im = Image.alpha_composite(bg, image)
self.image.paste(im, (0, 0)) self.image.paste(im, (0, 0))
logger.info('removed transparency') logger.info("removed transparency")
def resize(self, width=None, height=None): def resize(self, width=None, height=None):
"""Resize an image to desired width or height""" """Resize an image to desired width or height"""
if self._image_loaded(): if self._image_loaded():
if not width and not height: if not width and not height:
logger.error('no height of width specified') logger.error("no height of width specified")
return return
image = self.image image = self.image
if width: if width:
initial_width = image.width initial_width = image.width
wpercent = (width / float(image.width)) wpercent = width / float(image.width)
hsize = int((float(image.height) * float(wpercent))) hsize = int((float(image.height) * float(wpercent)))
image = image.resize((width, hsize), Image.LANCZOS) image = image.resize((width, hsize), Image.LANCZOS)
logger.info(f"resized image from {initial_width} to {image.width}") logger.info(f"resized image from {initial_width} to {image.width}")
@ -180,7 +180,7 @@ class Inkyimage:
if height: if height:
initial_height = image.height initial_height = image.height
hpercent = (height / float(image.height)) hpercent = height / float(image.height)
wsize = int(float(image.width) * float(hpercent)) wsize = int(float(image.width) * float(hpercent))
image = image.resize((wsize, height), Image.LANCZOS) image = image.resize((wsize, height), Image.LANCZOS)
logger.info(f"resized image from {initial_height} to {image.height}") logger.info(f"resized image from {initial_height} to {image.height}")
@ -203,131 +203,129 @@ class Inkyimage:
def clear_white(img): def clear_white(img):
"""Replace all white pixels from image with transparent pixels""" """Replace all white pixels from image with transparent pixels"""
x = numpy.asarray(img.convert('RGBA')).copy() x = numpy.asarray(img.convert("RGBA")).copy()
x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8) x[:, :, 3] = (255 * (x[:, :, :3] != 255).any(axis=2)).astype(numpy.uint8)
return Image.fromarray(x) return Image.fromarray(x)
image2 = clear_white(image2) image2 = clear_white(image2)
image1.paste(image2, (0, 0), image2) image1.paste(image2, (0, 0), image2)
logger.info('merged given images into one') logger.info("merged given images into one")
return image1 return image1
def to_palette(self, palette, dither=True) -> (PIL.Image, PIL.Image):
"""Maps an image to a given colour palette.
Maps each pixel from the image to a colour from the palette. def image_to_palette(
image: Image, palette: Literal = ["bwr", "bwy", "bw", "16gray"], dither: bool = True
) -> (PIL.Image, PIL.Image):
"""Maps an image to a given colour palette.
Args: Maps each pixel from the image to a colour from the palette.
- palette: A supported token. (see below)
- dither:->bool. Use dithering? Set to `False` for solid colour fills.
Returns: Args:
- two images: one for the coloured band and one for the black band. - palette: A supported token. (see below)
- dither:->bool. Use dithering? Set to `False` for solid colour fills.
Raises: Returns:
- ValueError if palette token is not supported - two images: one for the coloured band and one for the black band.
Supported palette tokens: Raises:
- ValueError if palette token is not supported
>>> 'bwr' # black-white-red Supported palette tokens:
>>> 'bwy' # black-white-yellow
>>> 'bw' # black-white
>>> '16gray' # 16 shades of gray
"""
# Check if an image is loaded
if self._image_loaded():
image = self.image.convert('RGB')
else:
raise FileNotFoundError
if palette == 'bwr': >>> 'bwr' # black-white-red
# black-white-red palette >>> 'bwy' # black-white-yellow
pal = [255, 255, 255, 0, 0, 0, 255, 0, 0] >>> 'bw' # black-white
>>> '16gray' # 16 shades of gray
"""
elif palette == 'bwy': if palette == "bwr":
# black-white-yellow palette # black-white-red palette
pal = [255, 255, 255, 0, 0, 0, 255, 255, 0] pal = [255, 255, 255, 0, 0, 0, 255, 0, 0]
elif palette == 'bw': elif palette == "bwy":
pal = None # black-white-yellow palette
elif palette == '16gray': pal = [255, 255, 255, 0, 0, 0, 255, 255, 0]
pal = [x for x in range(0, 256, 16)] * 3
pal.sort()
else: elif palette == "bw":
logger.error('The given palette is unsupported.') pal = None
raise ValueError('The given palette is not supported.') elif palette == "16gray":
pal = [x for x in range(0, 256, 16)] * 3
pal.sort()
if pal: else:
# The palette needs to have 256 colors, for this, the black-colour logger.error("The given palette is unsupported.")
# is added until the raise ValueError("The given palette is not supported.")
colours = len(pal) // 3
# print(f'The palette has {colours} colours')
if 256 % colours != 0: if pal:
# print('Filling palette with black') # The palette needs to have 256 colors, for this, the black-colour
pal += (256 % colours) * [0, 0, 0] # is added until the
colours = len(pal) // 3
# print(f'The palette has {colours} colours')
# print(pal) if 256 % colours != 0:
colours = len(pal) // 3 # print('Filling palette with black')
# print(f'The palette now has {colours} colours') pal += (256 % colours) * [0, 0, 0]
# Create a dummy image to be used as a palette # print(pal)
palette_im = Image.new('P', (1, 1)) colours = len(pal) // 3
# print(f'The palette now has {colours} colours')
# Attach the created palette. The palette should have 256 colours # Create a dummy image to be used as a palette
# equivalent to 768 integers palette_im = Image.new("P", (1, 1))
palette_im.putpalette(pal * (256 // colours))
# Quantize the image to given palette # Attach the created palette. The palette should have 256 colours
quantized_im = image.quantize(palette=palette_im, dither=dither) # equivalent to 768 integers
quantized_im = quantized_im.convert('RGB') palette_im.putpalette(pal * (256 // colours))
# get rgb of the non-black-white colour from the palette # Quantize the image to given palette
rgb = [pal[x:x + 3] for x in range(0, len(pal), 3)] quantized_im = image.quantize(palette=palette_im, dither=dither)
rgb = [col for col in rgb if col != [0, 0, 0] and col != [255, 255, 255]][0] quantized_im = quantized_im.convert("RGB")
r_col, g_col, b_col = rgb
# print(f'r:{r_col} g:{g_col} b:{b_col}')
# Create an image buffer for black pixels # get rgb of the non-black-white colour from the palette
buffer1 = numpy.array(quantized_im) rgb = [pal[x : x + 3] for x in range(0, len(pal), 3)]
rgb = [col for col in rgb if col != [0, 0, 0] and col != [255, 255, 255]][0]
r_col, g_col, b_col = rgb
# print(f'r:{r_col} g:{g_col} b:{b_col}')
# Get RGB values of each pixel # Create an image buffer for black pixels
r, g, b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2] buffer1 = numpy.array(quantized_im)
# convert coloured pixels to white # Get RGB values of each pixel
buffer1[numpy.logical_and(r == r_col, g == g_col)] = [255, 255, 255] r, g, b = buffer1[:, :, 0], buffer1[:, :, 1], buffer1[:, :, 2]
# reconstruct image for black-band # convert coloured pixels to white
im_black = Image.fromarray(buffer1) buffer1[numpy.logical_and(r == r_col, g == g_col)] = [255, 255, 255]
# Create a buffer for coloured pixels # reconstruct image for black-band
buffer2 = numpy.array(quantized_im) im_black = Image.fromarray(buffer1)
# Get RGB values of each pixel # Create a buffer for coloured pixels
r, g, b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2] buffer2 = numpy.array(quantized_im)
# convert black pixels to white # Get RGB values of each pixel
buffer2[numpy.logical_and(r == 0, g == 0)] = [255, 255, 255] r, g, b = buffer2[:, :, 0], buffer2[:, :, 1], buffer2[:, :, 2]
# convert non-white pixels to black # convert black pixels to white
buffer2[numpy.logical_and(g == g_col, b == 0)] = [0, 0, 0] buffer2[numpy.logical_and(r == 0, g == 0)] = [255, 255, 255]
# reconstruct image for colour-band # convert non-white pixels to black
im_colour = Image.fromarray(buffer2) buffer2[numpy.logical_and(g == g_col, b == 0)] = [0, 0, 0]
# self.preview(im_black) # reconstruct image for colour-band
# self.preview(im_colour) im_colour = Image.fromarray(buffer2)
else: # self.preview(im_black)
im_black = image.convert('1', dither=dither) # self.preview(im_colour)
im_colour = Image.new(mode='1', size=im_black.size, color='white')
logger.info('mapped image to specified palette') else:
im_black = image.convert("1", dither=dither)
im_colour = Image.new(mode="1", size=im_black.size, color="white")
return im_black, im_colour logger.info("mapped image to specified palette")
return im_black, im_colour
if __name__ == '__main__': if __name__ == "__main__":
print(f'running {__name__} in standalone/debug mode') print(f"running {__name__} in standalone/debug mode")

View File

@ -24,6 +24,7 @@ from inkycal.custom.functions import fonts
from inkycal.custom.functions import internet_available from inkycal.custom.functions import internet_available
from inkycal.custom.functions import top_level from inkycal.custom.functions import top_level
from inkycal.custom.inkycal_exceptions import NetworkNotReachableError from inkycal.custom.inkycal_exceptions import NetworkNotReachableError
from inkycal.modules.inky_image import image_to_palette
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -631,12 +632,14 @@ class Fullweather(inkycal_module):
self.image = self.image.rotate(90, expand=True) self.image = self.image.rotate(90, expand=True)
# TODO: only for debugging, remove this: # TODO: only for debugging, remove this:
# self.image.save("./openweather_full.png") self.image.save("./openweather_full.png")
logger.info("Fullscreen weather forecast generated successfully.") logger.info("Fullscreen weather forecast generated successfully.")
# Convert images according to specified palette
im_black, im_colour = image_to_palette(image=self.image, palette="bwr", dither=True)
# Return the images ready for the display # Return the images ready for the display
# tbh, I have no idea why I need to return two separate images here return im_black, im_colour
return self.image, self.image
def get_font(self, style, size): def get_font(self, style, size):
# Returns the TrueType font object with the given characteristics # Returns the TrueType font object with the given characteristics

View File

@ -2,8 +2,8 @@
Inkycal Image Module Inkycal Image Module
Copyright by aceinnolab Copyright by aceinnolab
""" """
from inkycal.custom import * from inkycal.custom import *
from inkycal.modules.inky_image import image_to_palette
from inkycal.modules.inky_image import Inkyimage as Images from inkycal.modules.inky_image import Inkyimage as Images
from inkycal.modules.template import inkycal_module from inkycal.modules.template import inkycal_module
@ -11,36 +11,21 @@ logger = logging.getLogger(__name__)
class Inkyimage(inkycal_module): class Inkyimage(inkycal_module):
"""Displays an image from URL or local path """Displays an image from URL or local path"""
"""
name = "Inkycal Image - show an image from a URL or local path" name = "Inkycal Image - show an image from a URL or local path"
requires = { requires = {
"path": { "path": {
"label": "Path to a local folder, e.g. /home/pi/Desktop/images. " "label": "Path to a local folder, e.g. /home/pi/Desktop/images. "
"Only PNG and JPG/JPEG images are used for the slideshow." "Only PNG and JPG/JPEG images are used for the slideshow."
}, },
"palette": {"label": "Which palette should be used for converting images?", "options": ["bw", "bwr", "bwy"]},
"palette": {
"label": "Which palette should be used for converting images?",
"options": ["bw", "bwr", "bwy"]
}
} }
optional = { optional = {
"autoflip": {"label": "Should the image be flipped automatically?", "options": [True, False]},
"autoflip": { "orientation": {"label": "Please select the desired orientation", "options": ["vertical", "horizontal"]},
"label": "Should the image be flipped automatically?",
"options": [True, False]
},
"orientation": {
"label": "Please select the desired orientation",
"options": ["vertical", "horizontal"]
}
} }
def __init__(self, config): def __init__(self, config):
@ -48,24 +33,24 @@ class Inkyimage(inkycal_module):
super().__init__(config) super().__init__(config)
config = config['config'] config = config["config"]
# required parameters # required parameters
for param in self.requires: for param in self.requires:
if not param in config: if not param in config:
raise Exception(f'config is missing {param}') raise Exception(f"config is missing {param}")
# optional parameters # optional parameters
self.path = config['path'] self.path = config["path"]
self.palette = config['palette'] self.palette = config["palette"]
self.autoflip = config['autoflip'] self.autoflip = config["autoflip"]
self.orientation = config['orientation'] self.orientation = config["orientation"]
self.dither = True self.dither = True
if 'dither' in config and config["dither"] == False: if "dither" in config and config["dither"] == False:
self.dither = False self.dither = False
# give an OK message # give an OK message
print(f'{__name__} loaded') print(f"{__name__} loaded")
def generate_image(self): def generate_image(self):
"""Generate image for this module""" """Generate image for this module"""
@ -75,7 +60,7 @@ class Inkyimage(inkycal_module):
im_height = int(self.height - (2 * self.padding_top)) im_height = int(self.height - (2 * self.padding_top))
im_size = im_width, im_height im_size = im_width, im_height
logger.info(f'Image size: {im_size}') logger.info(f"Image size: {im_size}")
# initialize custom image class # initialize custom image class
im = Images() im = Images()
@ -94,7 +79,7 @@ class Inkyimage(inkycal_module):
im.resize(width=im_width, height=im_height) im.resize(width=im_width, height=im_height)
# convert images according to specified palette # convert images according to specified palette
im_black, im_colour = im.to_palette(self.palette, self.dither) im_black, im_colour = image_to_palette(image=im, palette=self.palette, dither=self.dither)
# with the images now send, clear the current image # with the images now send, clear the current image
im.clear() im.clear()
@ -103,5 +88,5 @@ class Inkyimage(inkycal_module):
return im_black, im_colour return im_black, im_colour
if __name__ == '__main__': if __name__ == "__main__":
print(f'running {__name__} in standalone/debug mode') print(f"running {__name__} in standalone/debug mode")