from PIL import Image, ImageDraw, ImageFont
from tkinter.font import Font
import math
import os
[docs]
class ImageProcessor:
"""
Handles the Image rendering and save operations.
"""
def __init__(self, app, canvas_gm, overlay_gm):
"""
Initializer for the ImageProcessor
Args:
app: Main app
canvas_gm: Base Canvas GraphicsProcessor
overlay_gm: Overlay Canvas GraphicProcessor
"""
self.app = app
self.canvas_gm = canvas_gm
self.overlay_gm = overlay_gm
self.current_image = None
self.canvas_image = None
self.overlay_layer = None
self.current_image_size = None
self.prev_image_size = None
try:
self.font = ImageFont.truetype(font=os.path.join("fonts", "RobotoMono-Medium.ttf"), size=30)
except:
self.font = ImageFont.load_default()
[docs]
def render_images(self, batch: bool):
"""
Renders the graphic annotations onto the images and saves the images in the specified folder.
Args:
batch (bool): True renders every image in queue, False renders the current image on display.
Returns:
bool: True on successful render of all images. Else False.
"""
# OverlayCanvas is the tkinter.Canvas object for the global overlay elements.
self.HIGHLIGHT_OPACITY = self.app.user_settings["highlight_opacity"]
self.OVERLAY_IMAGE_INDEX = -2
self.OVERLAY_GRAPHICS_INDEX = -1
self.FONT_PATH = "fonts"
self.data_dict = self.app.image_data
self.graphics_data = self.app.graphics_data
self.current_image_size = None
self.prev_image_size = None
include_blanks = self.app.include_blanks
overlay_enabled = self.app.render_overlay
trim_overlay = self.app.trim_overlay
render_sequence_code = self.app.render_sequence_code
sequence_code_position = self.app.sequence_code_render_position
self.anti_alias = self.app.anti_alias_output
jpeg_quality = self.app.jpeg_quality
png_compression = self.app.png_compression
output_path = self.app.output_path
first_image_processed = False
if self.anti_alias:
self.FILM_RESIZE = 2
else:
self.FILM_RESIZE = 1
if self.graphics_data[self.OVERLAY_GRAPHICS_INDEX]: # Overlay 2d drawings index.
has_2d_overlay = True
else:
has_2d_overlay = False
if self.graphics_data[self.OVERLAY_IMAGE_INDEX]: # Overlay image index
has_image_overlay = True
else:
has_image_overlay = False
# If nothing in the overlay canvas, set overlay to False.
if not has_2d_overlay and not has_image_overlay:
overlay_enabled = False
if batch: # If export mode is batch.
indices_with_queue = []
# Get the total images in queue
for index, data in self.data_dict.items():
if data.get("in_queue") == True:
indices_with_queue.append(index) # saving the indexes of the images in queue
total_images_in_queue = len(indices_with_queue)
# total_images_in_queue = sum(1 for index,data in self.data_dict.items() if data.get("in_queue") is True)
if not include_blanks:
total_blanks = 0
for key in indices_with_queue:
if not self.graphics_data[key]:
total_blanks += 1
total_images_in_queue = total_images_in_queue - total_blanks
total_range = (0, self.app.available_index + 1)
if total_images_in_queue == 0:
self.app.render_menu.update_progress_bar(progress=1, status=False)
return False
else: # Render only the current image.
total_images_in_queue = 1
total_range = (self.app.image_index, self.app.image_index + 1)
files_saved = 0 # For progress bar update.
# Iterating through each of the images and redrawing the graphic elements.
for image_index in range(*total_range):
try:
# If the image is queued or single image export.
if self.data_dict[image_index]["in_queue"] or not batch:
# If no 2d drawings, but include blanks is checked.
if not self.graphics_data[image_index] and (include_blanks or not batch):
self.final_base_image = Image.open(self.app.images[image_index]).convert(mode="RGBA")
elif self.graphics_data[image_index]: # if the current image has 2d drawings.
self.current_image = Image.open(self.app.images[image_index]).convert(mode="RGBA")
self.base_graphics_layer = Image.new("RGBA", (self.current_image.width * self.FILM_RESIZE,
self.current_image.height * self.FILM_RESIZE),
(0, 0, 0, 0))
self.canvas_draw = ImageDraw.Draw(self.base_graphics_layer)
# ================Render Base Canvas==========================
# The graphic elements are drawn on a transparent blank film layer before finally pasting over the base image.
for id, cache in self.app.graphics_data[image_index].items():
# cache is the single graphic object.
self.current_cache = cache
# Draws the 2d elements on the image.
self.plot_graphic_element(layer="base")
# Resize the enlarged image to Normal size.
if self.anti_alias:
self.resized_base_graphics_layer = self.base_graphics_layer.resize(self.current_image.size,
resample=Image.LANCZOS)
else: # not wasting time with useless resize
self.resized_base_graphics_layer = self.base_graphics_layer
# Final Annotated Base image
self.final_base_image = Image.alpha_composite(self.current_image,
self.resized_base_graphics_layer)
else:
continue # Skip the image.
self.current_image_size = self.final_base_image.size
# ================Render Overlay Canvas==========================
if overlay_enabled:
# if the previous image had the same resolution, reuse the currently made overlay.
if self.prev_image_size != self.current_image_size:
self.prepare_overlay_layer()
# Makes the transparent layer.
# Enlarge the image if anti_alias enabled.
if self.anti_alias:
self.overlay_layer = self.overlay_layer.resize(
(self.overlay_layer.width * self.FILM_RESIZE,
self.overlay_layer.height * self.FILM_RESIZE))
self.overlay_draw = ImageDraw.Draw(self.overlay_layer)
for id, cache in self.graphics_data[self.OVERLAY_GRAPHICS_INDEX].items():
# cache is the single graphic object.
self.current_cache = cache
# Draw overlay 2d items one by one.
self.plot_graphic_element(layer="overlay")
# Rescale overlay to image size.
if self.anti_alias:
self.overlay_layer = self.overlay_layer.resize(
(self.overlay_layer.width // self.FILM_RESIZE,
self.overlay_layer.height // self.FILM_RESIZE),
resample=Image.LANCZOS)
# Images are pasted in after all the drawings has been made on the overlay layer.
if has_image_overlay: # if current overlay canvas has image elements.
for id, image_cache in self.graphics_data[self.OVERLAY_IMAGE_INDEX].items():
self.current_cache = image_cache
# Draw overlay image items one by one.
self.insert_overlay_image_element()
else:
pass
self.background_layer = Image.new("RGBA", (self.overlay_layer.width,
self.overlay_layer.height), "white")
paste_position = ((self.overlay_layer.width - self.final_base_image.width) // 2,
(self.overlay_layer.height - self.final_base_image.height) // 2)
# pasting the base image on a 16:9 backdrop
self.background_layer.paste(self.final_base_image, paste_position)
self.final_image = Image.alpha_composite(self.background_layer, self.overlay_layer)
# -----------Trim the Final Image to the original image size------------------
if trim_overlay:
original_width, original_height = self.final_image.size
target_width, target_height = self.final_base_image.size
# Calculate the coordinates for cropping from the center
left = (original_width - target_width) // 2
upper = (original_height - target_height) // 2
right = left + target_width
lower = upper + target_height
# crop the image
self.final_image = self.final_image.crop((left, upper, right, lower))
else: # if no overlay save the base image.
self.final_image = self.final_base_image
# ============Imprint the Sequence code on the image=================
if render_sequence_code:
sequence_code = self.data_dict[image_index]['sequence_code']
if sequence_code:
sequence_code_image = self.generate_sequence_code_image(sequence_code=sequence_code)
if sequence_code_position == "ne": # top right
paste_anchor = (self.final_image.width - sequence_code_image.width, 0)
elif sequence_code_position == "sw": # bottom left
paste_anchor = (0, self.final_image.height - sequence_code_image.height)
elif sequence_code_position == "se": # bottom right
paste_anchor = (self.final_image.width - sequence_code_image.width,
self.final_image.height - sequence_code_image.height)
else: # nw top corner
paste_anchor = (0, 0)
self.final_image.paste(sequence_code_image, paste_anchor)
# ================Save the rendered image==========================
filename = os.path.basename(self.data_dict[image_index]['file'])
output_location = f"{output_path}/{filename}"
try:
self.final_image.save(output_location, quality=jpeg_quality, compress_level=png_compression)
except OSError: # incase image fails to save with alphas
self.final_image = self.final_image.convert("RGB")
self.final_image.save(output_location, quality=jpeg_quality, compress_level=png_compression)
finally:
files_saved += 1
progress = files_saved / total_images_in_queue # 0 to 1 range
self.prev_image_size = self.current_image_size
try:
self.app.render_menu.update_progress_bar(progress=progress, status=True)
except:
return False
except Exception: # If any one of the image fails to save.
return False
return True
[docs]
def plot_graphic_element(self, layer: str):
"""
Adjusts the values and calls the specified method to plot the graphic element to an image.
Args:
layer (str): If the overlay elements are being drawn then,"overlay". Else "base"
Returns:
None
"""
cache = self.current_cache
if layer != "overlay":
canvas = self.canvas_draw
if cache.tool == 8:
self.adjusted_coordinates = self.get_resized_coordinates(item="text")
self.adjusted_font_size = abs(cache.font_size * self.FILM_RESIZE)
else:
self.adjusted_coordinates = self.get_resized_coordinates()
self.adjusted_stroke_width = (round(cache.width) * self.FILM_RESIZE)
else:
canvas = self.overlay_draw
if cache.tool == 8:
self.adjusted_coordinates = self.get_values_for_overlay_layer_from_overlaycache(item="text")
self.adjusted_font_size = abs(self.get_values_for_overlay_layer_from_overlaycache(item="font"))
else:
self.adjusted_coordinates = self.get_values_for_overlay_layer_from_overlaycache()
self.adjusted_stroke_width = (round(self.get_values_for_overlay_layer_from_overlaycache(item="width")))
if cache.tool == 2: # Brush tool
self.plot_brush_stroke(canvas)
elif cache.tool == 4: # Line tool
self.plot_line(canvas)
elif cache.tool == 5: # rectangle tool
self.plot_rectangle(canvas)
elif cache.tool == 6: # rectangle tool
self.plot_oval(canvas)
elif cache.tool == 8: # Insert Text
self.insert_text(canvas)
[docs]
def prepare_overlay_layer(self):
"""
Prepares a transparent image in the 16:9 ratio for the graphic elements to be drawn onto.
Returns:
None
"""
annotated_img_width, annotated_img_height = self.final_base_image.size
canvas_width = 16 * annotated_img_height // 9
canvas_height = annotated_img_height
if canvas_width < annotated_img_width:
canvas_width = annotated_img_width
canvas_height = 9 * annotated_img_width // 16
base_width = canvas_width # *self.OVERLAY_RESIZE
base_height = canvas_height # *self.OVERLAY_RESIZE
# Makes a new transparent image .
self.overlay_layer = Image.new("RGBA", (base_width, base_height), (0, 0, 0, 0))
self.overlay_draw = ImageDraw.Draw(self.overlay_layer)
[docs]
def insert_overlay_image_element(self):
"""
Renders the overlay image element to the overlay cel layer.
Returns:
None
"""
transparent_layer = Image.new("RGBA", (self.overlay_layer.width, self.overlay_layer.height), (0, 0, 0, 0))
# self.overlay_draw = ImageDraw.Draw(transparent_layer)
image_to_paste = self.current_cache.image_object.convert("RGBA")
x, y = self.get_values_for_overlay_layer_from_overlaycache(item="image")
# Converting from overlay canvas size to true image size.
new_width, new_height = self.get_values_for_overlay_layer_from_overlaycache(coordinates=self.current_cache.size,
item="image")
resized_image = image_to_paste.resize((round(new_width), round(new_height)))
if (rotation_angle := self.current_cache.angle) not in (0, 360):
resized_image = resized_image.rotate(rotation_angle, expand=True, resample=Image.BICUBIC)
# Center anchoring the image.
x_offset = x - (resized_image.width // 2)
y_offset = y - (resized_image.height // 2)
# Uses the alpha from the imported image as a mask.
transparent_layer.paste(resized_image, (round(x_offset), round(y_offset)), )
self.overlay_layer = Image.alpha_composite(self.overlay_layer, transparent_layer)
[docs]
def plot_brush_stroke(self, canvas):
"""
Renders graphic elements plotted using the Brush tool onto the given canvas draw object.
Args:
canvas: PIL draw object.
Returns:
None
"""
if self.current_cache.stipple: # if Current line is a highlight, add transparency.
fill_color = self.get_highlight_color()
else:
fill_color = self.current_cache.fill_color
self.round_cap(fill_color=fill_color, canvas=canvas)
canvas.line(self.adjusted_coordinates, fill=fill_color, width=int(self.adjusted_stroke_width), joint="curve")
[docs]
def plot_line(self, canvas):
"""
Renders graphic elements plotted using the Line tool onto the given canvas draw object.
Args:
canvas: PIL draw object.
Returns:
None
"""
if self.current_cache.stipple: # Current line is a highlight
fill_color = self.get_highlight_color()
else:
fill_color = self.current_cache.fill_color
if self.current_cache.capstyle == "round":
# Caps the ends
self.round_cap(fill_color=fill_color, canvas=canvas)
canvas.line(self.adjusted_coordinates, fill=fill_color, width=self.adjusted_stroke_width)
[docs]
def plot_rectangle(self, canvas):
"""
Renders graphic elements plotted using the Rectangle tool onto the given canvas draw object.
Args:
canvas: PIL draw object.
Returns:
None
"""
fill_color = self.current_cache.fill_color
x1, y1 = self.adjusted_coordinates[0]
x2, y2 = self.adjusted_coordinates[1]
# making coordinates Pillow compatible
if x1 > x2:
x = x1
x1 = x2
x2 = x
if y1 > y2:
y = y1
y1 = y2
y2 = y
if self.current_cache.interior_fill_color: # rectangle is solid filled.
width = 0
if self.current_cache.stipple: # Current line is a highlight
fill_color = self.get_highlight_color()
canvas.rectangle((x1, y1, x2, y2), fill=fill_color)
else: # rectangle has only outline.
width = self.adjusted_stroke_width
# Outline in pillow works differently so pushing it to the center
canvas.rectangle((x1 - width / 2, y1 - width / 2, x2 + width / 2, y2 + width / 2), width=width,
outline=fill_color)
[docs]
def plot_oval(self, canvas):
"""
Renders graphic elements plotted using the Oval tool onto the given canvas draw object.
Args:
canvas: PIL draw object.
Returns:
None
"""
fill_color = self.current_cache.fill_color
x1, y1 = self.adjusted_coordinates[0]
x2, y2 = self.adjusted_coordinates[1]
# making coordinates Pillow compatible
if x1 > x2:
x = x1
x1 = x2
x2 = x
if y1 > y2:
y = y1
y1 = y2
y2 = y
if self.current_cache.interior_fill_color: # rectangle is solid filled.
width = 0
if self.current_cache.stipple: # Current line is a highlight
fill_color = self.get_highlight_color()
canvas.ellipse((x1, y1, x2, y2), fill=fill_color)
else: # rectangle has only outline.
width = self.adjusted_stroke_width
# Outline in pillow works differently so pushing it inside
canvas.ellipse((x1 - width / 2, y1 - width / 2, x2 + width / 2, y2 + width / 2), width=width,
outline=fill_color)
[docs]
def insert_text(self, canvas):
"""
Renders graphic elements plotted using the Text tool onto the given canvas draw object.
Args:
canvas: PIL draw object.
Returns:
None
"""
if self.current_cache.stipple: # Current line is a highlight
fill_color = self.get_highlight_color()
else:
fill_color = self.current_cache.fill_color
font_path = os.path.join(self.FONT_PATH, self.current_cache.font_file)
font = ImageFont.truetype(font_path, int(self.adjusted_font_size))
# Pillow does not use the same anchor,as tkinter and if using left descender("ld") ("sw") some fonts are misaligned.
# So finding the descent value to get the baseline value of the font. then using that baseline value as an anchor fixes the issue.
try:
tk_font = Font(family=self.current_cache.font_name,
size=-int(self.adjusted_font_size)) # -ve to match tkinter.
tk_font_info = tk_font.metrics()
descent = tk_font_info["descent"]
except Exception as e:
self.app.error_prompt.display_error_prompt(error_msg=f'failed to render,"{self.current_cache.text}"')
return
# Subtracting the descent value to get the baseline value.
canvas.text((self.adjusted_coordinates[0], self.adjusted_coordinates[1] - descent), self.current_cache.text,
font=font,
fill=fill_color, anchor="ls") # Left Baseline,
[docs]
def round_cap(self, fill_color, canvas):
"""
Uses the start and end coordinate of a segment to draw two circles to generate round tips.
Args:
fill_color: Hex color code.
canvas: PIL draw object.
Returns:
None
"""
start_point = self.adjusted_coordinates[0]
end_point = self.adjusted_coordinates[-1]
circle_radius = (math.ceil(self.adjusted_stroke_width / 2)) - 1
canvas.ellipse([start_point[0] - circle_radius, start_point[1] - circle_radius,
start_point[0] + circle_radius, start_point[1] + circle_radius], fill=fill_color)
canvas.ellipse([end_point[0] - circle_radius, end_point[1] - circle_radius,
end_point[0] + circle_radius, end_point[1] + circle_radius], fill=fill_color)
[docs]
def get_highlight_color(self):
"""
Adds 50% transparency to the current fill_color to emulate highlight color
Returns:
str: Hex color
"""
color_rgb = tuple(int(self.current_cache.fill_color[i:i + 2], 16) for i in (1, 3, 5))
opacity_value = int((self.HIGHLIGHT_OPACITY / 100) * 255)
fill_color = color_rgb + (opacity_value,)
return fill_color
[docs]
def get_resized_coordinates(self, item=None):
"""
Scales the coordinates to match the size of the transparent film layer in which the images are being drawn on.
Args:
item (str,optional): "text" in the item is text.
Returns:
(list|tuple): Scaled coordinates matching the enlarged film layer size.
"""
if item == "text":
try:
x, y = (coordinate * self.FILM_RESIZE for coordinate in self.current_cache.coordinates)
except ValueError:
x, y = (coordinate * self.FILM_RESIZE for coordinate in self.current_cache.coordinates[0])
return (x, y)
scaled_coordinates = [((x * self.FILM_RESIZE), (y * self.FILM_RESIZE)) for x, y in
self.current_cache.coordinates]
return scaled_coordinates
[docs]
def get_values_for_overlay_layer_from_overlaycache(self, coordinates=None, item=None):
"""
Resize element values to whatever size the current overlay film layer is at.(including scaled up for antialiasing)
Args:
coordinates (list|tuple): Coordinates to be scaled to match the current overlay size.
item (str,optional) : "font", "image", "text", "width"
Returns:
Scaled coordinates matching the current overlay film size.
"""
if coordinates == None:
coordinates = self.current_cache.coordinates
else:
coordinates = coordinates
# convert cords from 1920 to image size
# xlarge image since antialias
new_width, new_height = self.overlay_layer.width, self.overlay_layer.height
old_width, old_height = self.overlay_gm.OVERLAY_WIDTH, self.overlay_gm.OVERLAY_HEIGHT
scale_x = new_width / old_width
scale_y = new_height / old_height
scale_factor = max(scale_x, scale_y)
if item == "text" or item == "image":
try:
x, y = (coordinate * scale_factor for coordinate in coordinates)
except ValueError:
x, y = (coordinate * scale_factor for coordinate in coordinates[0])
return (x, y)
elif item == "font":
new_font_size = self.current_cache.font_size * scale_factor
return int(new_font_size)
elif item == "width":
new_width = self.current_cache.width * scale_factor
return new_width
scaled_coordinates = [((x * scale_factor), (y * scale_factor))
for x, y in coordinates]
return scaled_coordinates
[docs]
def generate_sequence_code_image(self, sequence_code):
"""
Generates an image from the sequence code.
Returns:
An RGB Image with the sequence code
"""
final_image_width, final_image_height = self.final_image.size
backdrop_width = int((10 / 100) * final_image_width)
backdrop_height = int((35 / 100) * backdrop_width)
sequence_code_img = Image.new("RGB", (backdrop_width, backdrop_height), color="black")
drawobj = ImageDraw.Draw(sequence_code_img)
font_size = int(backdrop_height / 2)
self.font = ImageFont.truetype(font=os.path.join("fonts", "RobotoMono-Medium.ttf"), size=font_size)
text_width = drawobj.textlength(sequence_code, font=self.font)
text_height = font_size
text_position = (int(backdrop_width - text_width) / 2), (int(backdrop_height - text_height) / 2.3)
drawobj.text((text_position), sequence_code, fill=(255, 255, 255), font=self.font)
return sequence_code_img