diff --git a/.gitignore b/.gitignore index 078bde0..2a946db 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ *.pyc .venv node_modules +fonts/*.ttf +*.jpg +*.jpeg +*.png +AGENTS.md \ No newline at end of file diff --git a/LICENSE b/LICENSE index fdddb29..b72d2d2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,9 @@ -This is free and unencumbered software released into the public domain. +The MIT License (MIT) -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright 2024 STEVEQUINN -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -For more information, please refer to +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index a27b211..d22ea02 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,24 @@ Exif data can also be extracted and added to the border if the mood strikes. A colour palette can be added to the border as well. +## Installation + +```bash +git clone https://github.com/stevequinn/photoborder +``` + +```bash +cd photoborder +``` + +```bash +pip install -r requirements.txt +``` + ## Usage ```bash -usage: python main.py [-h] [-e] [-p] [-t{s,m,l,p,i}] filename +usage: python main.py [-h] [-e] [-p] [-f] [-fb] [-t{s,m,l,p,i}] filename Add a border and exif data to a jpg or png photo @@ -17,12 +31,17 @@ positional arguments: filename options: - -h, --help show this help message and exit - -e, --exif print photo exif data on the border - -p, --palette Add colour palette to the photo border - -t, --border_type Border Type: p for polaroid, s for small, m for medium, l for large, i for instagram (default: s) - --include File patterns to include (default: *.jpg *.jpeg *.png, *.JPG, *.JPEG, *.PNG) - --exclude File patterns to exclude (default: *_border*) + -h, --help Show this help message and exit + -e, --exif Print photo exif data on the border + -p, --palette Add colour palette to the photo border + -t, --border_type Border Type: p for polaroid, s for small, m for medium, l for large, i for instagram (default: s) + -f, --font Font Typeface to use (default: Roboto-Regular.ttf) + -fv, --fontvariant Font style variant to use (default: 0) + -fb, --fontbold Bold Font Typeface to use (default: Roboto-Medium.ttf) + -fbv, --fontboldvariant Bold Font style variant to use (default: 0) + --include File patterns to include (default: *.jpg *.jpeg *.png, *.JPG, *.JPEG, *.PNG) + --exclude File patterns to exclude (default: *_border*) + Made for fun and to solve a little problem. ``` @@ -30,6 +49,34 @@ Made for fun and to solve a little problem. > Note: This is a hacked together little script. Use at your own peril... +## osx_services + +Adds quick actions to you OSX menu for quick deployment of tool. + +## Fonts + +The repo comes with [Roboto](https://fonts.google.com/specimen/Roboto) (Regular, Medium & Bold). + +```photoborder/fonts``` + +Should you wish to use another font you should add it to the ```fonts``` directory and use the appropriate arguments + +## Testing + +There are some very simple tests available in the `tests/` directory. + +You can run these with: + +```bash +pytest -s ./tests +``` + +For specific test modules just do the same for the file like so: + +```bash +pytest -s ./tests/test_text.py +``` + ## Examples ![alt text](doc/images/20241108_20241108DSCF0043_border-p_exif_palette.jpeg) diff --git a/border.py b/border.py index 2d27be8..a0a8944 100644 --- a/border.py +++ b/border.py @@ -4,8 +4,12 @@ import math from enum import Enum from dataclasses import dataclass +import glob +import numpy as np from PIL import Image +from typing import Optional import text as tm +import os class BorderType(Enum): POLAROID = 'p' @@ -123,33 +127,279 @@ def draw_border(img: Image, border: Border) -> Image: return canvas -def draw_exif(img: Image, exif: dict, border: Border) -> Image: +def draw_exif(img: Image, exif: dict, border: Border, font: tuple[str, int], boldfont: tuple[str, int], oneline: bool = False, twoline: bool = False, has_palette: bool = False, use_film_image: bool = False) -> Image: centered = border.border_type in (BorderType.POLAROID, BorderType.LARGE, BorderType.INSTAGRAM) - multiplier = 0.2 if centered else 0.5 - font_size = tm.get_optimal_font_size("Test string", border.bottom * multiplier) - heading_font_size = tm.get_optimal_font_size("Test string", border.bottom * (multiplier + 0.02)) - font = tm.create_font(font_size) - heading_font = tm.create_bold_font(heading_font_size) - - # Vertical align text in bottom border based on total font block height. - if centered: - # 3 Lines of text. 1 heading, two normal. Minus heading margins. A bit sketchy but it aligns fine. - total_font_height = heading_font.size + (2 * font.size) - (heading_font.size / 2) + + # If oneline flag is set, force single-line layout for centered border types + if oneline and centered: + centered = False + + # If twoline flag is set, use two-line left-aligned layout for centered border types + if twoline and border.border_type in (BorderType.POLAROID, BorderType.LARGE, BorderType.INSTAGRAM): + # Two-line left-aligned layout + # Line 1 (bold): Camera Make Model | Lens Make Model + # Line 2 (regular): Focal Length · f/Stop · ISO · Shutter Speed + multiplier = 0.20 + font_size = tm.get_optimal_font_size("Test", border.bottom * multiplier, font[0], index=font[1]) + heading_font_size = tm.get_optimal_font_size("Test", border.bottom * (multiplier + 0.04), boldfont[0], index=boldfont[1]) + max_font_from_border = max(1, int(border.bottom * 0.18)) + font_size = min(font_size, max_font_from_border) + heading_font_size = min(heading_font_size, max_font_from_border) + font_obj = tm.create_font(font_size, fontpath=font[0], index=font[1]) + heading_font = tm.create_font(heading_font_size, fontpath=boldfont[0], index=boldfont[1]) + + # Build line 1: Camera Make Model | Lens (all bold) + # Use shortened lens name if available, otherwise fall back to full name + lens_name = str(exif.get('LensModelShort', exif.get('LensModel', ''))) + line1_text = f"{exif['Make']} {exif['Model']} | {lens_name}" + + # Build line 2: Settings + settings_parts = [ + str(exif['FocalLength']), + str(exif['FNumber']), + str(exif['ISOSpeedRatings']), + str(exif['ExposureTime']) + ] + line2_text = " · ".join(settings_parts) + + # Film simulation handling + film_sim = str(exif.get('FilmSimulation', '')) + film_text = film_sim if (film_sim and not use_film_image) else '' + if film_text: + line2_text += f" | {film_text}" + + # Calculate vertical positioning - add significant space from bottom edge of photo + # Position text lower in the border for more breathing room + line_spacing = font_obj.size * 0.8 # Reduced spacing between lines for tighter layout + breathing_room = max(16, int(border.bottom * 0.20)) # Extra padding from photo edge + y_start = img.height - border.bottom + breathing_room + + # X position: aligned with left edge of photo with padding + x = border.left + max(10, int(font_obj.size * 0.5)) + + # Draw line 1 (bold) + y = y_start + text_img, (x_end, y) = tm.draw_text_on_image(img, line1_text, (x, y), centered=False, font=heading_font, fill=(100, 100, 100)) + + # Draw line 2 (regular) + y = y_start + heading_font.size + line_spacing + text_img, (x_end, y) = tm.draw_text_on_image(text_img, line2_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) + + return text_img + + elif centered: + # For polaroid, large, and instagram: 3 lines of text, centered, stacked vertically + # Reduced multiplier for more reasonable text sizing + multiplier = 0.20 + font_size = tm.get_optimal_font_size("Test", border.bottom * multiplier, font[0], index=font[1]) + heading_font_size = tm.get_optimal_font_size("Test", border.bottom * (multiplier + 0.04), boldfont[0], index=boldfont[1]) + # Cap font sizes to a reasonable fraction of the bottom border to avoid runaway sizes + # Use a tighter cap to avoid huge text when borders are large + max_font_from_border = max(1, int(border.bottom * 0.18)) + font_size = min(font_size, max_font_from_border) + heading_font_size = min(heading_font_size, max_font_from_border) + font_obj = tm.create_font(font_size, fontpath=font[0], index=font[1]) + heading_font = tm.create_font(heading_font_size, fontpath=boldfont[0], index=boldfont[1]) + + # 3 Lines of text. 1 heading, two normal. Minus heading margins. A bit sketchy but it aligns fine. + total_font_height = heading_font.size + (2 * font_obj.size) - (heading_font.size / 2) + # Add extra spacing from the top of the border area for breathing room y = img.height - border.bottom + \ - (border.bottom / 2) - (total_font_height / 2) + (border.bottom / 2) - (total_font_height / 2) + (border.bottom * 0.05) + x = border.left + + text = f"{exif['Make']} {exif['Model']}" + text_img, (x, y) = tm.draw_text_on_image(img, text, (x,y), centered, heading_font, fill=(100, 100, 100)) + + # Use shortened lens name + lens_name = str(exif.get('LensModelShort', exif.get('LensModel', ''))) + text_img, (x, y) = tm.draw_text_on_image(text_img, lens_name, (x,y), centered, font_obj, fill=(128, 128, 128)) + + # Build settings line and optional film simulation; draw the film-sim image inline + settings_parts = [ + str(exif['FocalLength']), + str(exif['FNumber']), + str(exif['ISOSpeedRatings']), + str(exif['ExposureTime']) + ] + + # Join settings with dots (base text, film sim drawn separately) + settings_base_text = " · ".join(settings_parts) + + # Film simulation: text may be suppressed if a film image will be used instead + film_sim = str(exif.get('FilmSimulation', '')) + film_text = film_sim if (film_sim and not use_film_image) else '' + + # Calculate widths so we can center the whole block + from PIL import ImageDraw + draw = ImageDraw.Draw(img) + settings_base_width = draw.textlength(settings_base_text, font=font_obj) + # Use pipe separator only when film text is chosen; no pipe when using film image + pipe_text = " | " if film_text else "" + pipe_width = draw.textlength(pipe_text, font=font_obj) if pipe_text else 0 + film_text_width = draw.textlength(film_text, font=font_obj) if film_text else 0 + + # Center only text widths here; film image will be positioned at the right with the palette + total_width = settings_base_width + (pipe_width + film_text_width if film_sim else 0) + + # Center the block horizontally + x = (img.width - total_width) / 2 + + # Draw settings base centered horizontally by our computed x + text_img, (x, y) = tm.draw_text_on_image(img, settings_base_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) + + if film_sim: + # draw pipe separator then film simulation text only if not using the film image + text_img, (x, _) = tm.draw_text_on_image(text_img, pipe_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) + if film_text: + text_img, (x, y) = tm.draw_text_on_image(text_img, film_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) else: - # y = img.height - (border.bottom / 2) - (heading_font.size / 3) - y = img.height - (border.bottom / 2) + (heading_font.size / 3) + # For small and medium: Single line of text at bottom, centered horizontally + # Reduced multiplier for more reasonable text sizing + multiplier = 0.22 + font_size = tm.get_optimal_font_size("Test", border.bottom * multiplier, font[0], index=font[1]) + heading_font_size = tm.get_optimal_font_size("Test", border.bottom * (multiplier + 0.04), boldfont[0], index=boldfont[1]) + # Cap font sizes to a reasonable fraction of the bottom border to avoid runaway sizes + # Use a tighter cap to avoid huge text when borders are large + max_font_from_border = max(1, int(border.bottom * 0.18)) + font_size = min(font_size, max_font_from_border) + heading_font_size = min(heading_font_size, max_font_from_border) + font_obj = tm.create_font(font_size, fontpath=font[0], index=font[1]) + heading_font = tm.create_font(heading_font_size, fontpath=boldfont[0], index=boldfont[1]) + + # Build the single line with mixed fonts: we'll draw bold camera, then regular rest + camera_text = f"{exif['Make']} {exif['Model']}" + # Use shortened lens name + lens_text = str(exif.get('LensModelShort', exif.get('LensModel', ''))) + + # Build settings text and optional film simulation (we'll draw film image inline) + settings_parts = [ + str(exif['FocalLength']), + str(exif['FNumber']), + str(exif['ISOSpeedRatings']), + str(exif['ExposureTime']) + ] + + # Join settings with dots (base text, film sim drawn separately) + settings_base_text = " · ".join(settings_parts) + + # Film simulation: don't display text, only the image + film_sim = str(exif.get('FilmSimulation', '')) + film_text = '' # Always empty - we only show the film sim image, not text + + # Calculate total width for centering (using heading font for camera, regular for rest) + from PIL import ImageDraw + draw = ImageDraw.Draw(img) + camera_width = draw.textlength(camera_text, font=heading_font) + separator_width = draw.textlength(" · ", font=font_obj) + lens_width = draw.textlength(lens_text, font=font_obj) + settings_base_width = draw.textlength(settings_base_text, font=font_obj) + + # No film sim text - only the image will be shown + total_width = ( + camera_width + separator_width + lens_width + separator_width + settings_base_width + ) + + # When using single-line layout with film-sim or palette, left-align the text + # so the palette/film image have breathing room on the right. + left_align = oneline and (has_palette or bool(film_sim)) + if left_align: + # small left padding to match typical text inset + x = border.left + int(font_obj.size * 0.5) + else: + # If palette is present, adjust text position to leave room on the right + # Palette width is approximately border.bottom / 3 * number_of_colors (up to 5 colors) + # We'll reserve space for the palette on the right side + if has_palette: + palette_reserved_width = border.bottom # Approximate palette width plus padding + available_width = img.width - palette_reserved_width - border.left + x = border.left + (available_width - total_width) / 2 + else: + # Center the text block horizontally + x = (img.width - total_width) / 2 - x = border.left + # If the computed total width is wider than available width, scale down fonts + # to fit (only for single-line centered layout where centering is used). + try: + # determine available horizontal space for the text block + if has_palette: + avail = available_width + else: + avail = img.width - text = f"{exif['Make']} {exif['Model']}" - text_img, (x, y) = tm.draw_text_on_image(img, text, (x,y), centered, heading_font, fill=(100, 100, 100)) + if total_width > avail and total_width > 0: + scale = avail / total_width + # compute new font sizes scaled by the ratio + new_font_size = max(1, int(font_obj.size * scale)) + new_heading_size = max(1, int(heading_font.size * scale)) + # recreate fonts with new sizes + font_obj = tm.create_font(new_font_size, fontpath=font[0], index=font[1]) + heading_font = tm.create_font(new_heading_size, fontpath=boldfont[0], index=boldfont[1]) + # recompute widths with new fonts + camera_width = draw.textlength(camera_text, font=heading_font) + separator_width = draw.textlength(" · ", font=font_obj) + lens_width = draw.textlength(lens_text, font=font_obj) + settings_base_width = draw.textlength(settings_base_text, font=font_obj) + total_width = ( + camera_width + separator_width + lens_width + separator_width + settings_base_width + ) + # re-center with adjusted widths + if has_palette: + x = border.left + (available_width - total_width) / 2 + else: + x = (img.width - total_width) / 2 + except Exception: + # if any error occurs, fall back to previously computed x + pass + + # Position text with same spacing as twoline layout + # Use same breathing room calculation for consistency + breathing_room = max(16, int(border.bottom * 0.20)) + y = img.height - border.bottom + breathing_room + + # Draw camera (bold) + text_img, (x, _) = tm.draw_text_on_image(img, camera_text, (x, y), centered=False, font=heading_font, fill=(100, 100, 100)) + + # Draw first separator + text_img, (x, _) = tm.draw_text_on_image(text_img, " · ", (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) + + # Draw lens + text_img, (x, _) = tm.draw_text_on_image(text_img, lens_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) + + # Draw second separator + text_img, (x, _) = tm.draw_text_on_image(text_img, " · ", (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) - text = f"{exif['LensMake']} {exif['LensModel']}" - text_img, (x, y) = tm.draw_text_on_image(text_img, text, (x,y), centered, font, fill=(128, 128, 128)) + # Draw settings base + text_img, (x, _) = tm.draw_text_on_image(text_img, settings_base_text, (x, y), centered=False, font=font_obj, fill=(128, 128, 128)) - text = f"{exif['FocalLength']} {exif['FNumber']} {exif['ISOSpeedRatings']} {exif['ExposureTime']}" - text_img, (x, y) = tm.draw_text_on_image(text_img, text, (x,y), centered, font, fill=(128, 128, 128)) + # Film simulation text is not displayed - only the image is shown return text_img + +# Load film simulation images +def get_film_simulation_image(film_sim: str) -> Optional[Image.Image]: + """ + Find and load the film simulation image file that matches the given film simulation name. + + Args: + film_sim (str): Film simulation name (e.g., "Reala Ace", "Nostalgic Neg") + + Returns: + Optional[Image.Image]: The matched film simulation image, or None if not found + """ + film_sim_images = {} + sim_image_files = glob.glob('fuji-sims/*.png') + for filepath in sim_image_files: + sim_name = os.path.splitext(os.path.basename(filepath))[0] + film_sim_images[sim_name] = Image.open(filepath) + + # Normalize the target film sim name for matching + # Convert to lowercase and replace spaces with underscores + normalized_target = film_sim.lower().replace(' ', '_') + + for image_key in film_sim_images: + normalized_key = image_key.lower() + # Check for exact match or substring match + if normalized_target == normalized_key or normalized_target in normalized_key: + return film_sim_images[image_key] + return None diff --git a/exif.py b/exif.py index 7f8eaee..5495762 100644 --- a/exif.py +++ b/exif.py @@ -1,11 +1,15 @@ """ Photo Exif extraction functions """ +import subprocess +import logging from dataclasses import dataclass from fractions import Fraction from PIL import Image from PIL.ExifTags import TAGS +logger = logging.getLogger(__name__) + def format_shutter_speed(shutter_speed: str) -> str: """ Convert a decimal value to a fraction display. @@ -67,11 +71,98 @@ def __str__(self) -> str: return fmt_data -def get_exif(img: Image) -> dict: +def shorten_lens_name(lens_model: str, lens_make: str = '') -> str: + """ + Shorten lens model name by removing redundant manufacturer info and common prefixes. + + Args: + lens_model (str): Full lens model name + lens_make (str): Lens manufacturer name (optional) + + Returns: + str: Shortened lens model name + """ + if not lens_model or lens_model == '': + return lens_model + + # Remove lens make from the beginning if it's redundant + if lens_make and lens_model.startswith(lens_make): + lens_model = lens_model[len(lens_make):].strip() + + # Common prefixes to remove + prefixes_to_remove = [ + 'FUJINON', + 'FUJIFILM', + 'XF', + 'XC', + 'NIKKOR', + 'FE', + 'E', + ] + + for prefix in prefixes_to_remove: + if lens_model.startswith(prefix + ' '): + lens_model = lens_model[len(prefix):].strip() + break + + # Remove common suffixes that add clutter + suffixes_to_remove = [ + ' R LM WR', + ' OIS', + ' WR', + ' LM', + ' R', + ] + + for suffix in suffixes_to_remove: + if lens_model.endswith(suffix): + lens_model = lens_model[:-len(suffix)].strip() + + return lens_model + +def get_film_simulation(image_path: str) -> str: + """ + Extract Fuji film simulation mode from image using exiftool. + Normalizes the film simulation name by replacing underscores with spaces + and converting to title case for consistent matching. + + Args: + image_path (str): Path to the image file + + Returns: + str: Normalized film simulation name (e.g., "Reala Ace", "Nostalgic Neg") or empty string + """ + try: + result = subprocess.run( + ['exiftool', '-FilmMode', '-s', '-s', '-s', image_path], + capture_output=True, + text=True, + timeout=5 + ) + film_mode = result.stdout.strip() + if film_mode: + # Normalize: replace underscores with spaces and convert to title case + film_mode = film_mode.replace('_', ' ').title() + return film_mode if film_mode else '' + except FileNotFoundError: + logger.warning('exiftool not found. Film simulation extraction requires exiftool to be installed. ' + 'Install it with: brew install exiftool (macOS) or apt-get install exiftool (Linux)') + return '' + except subprocess.TimeoutExpired: + logger.warning(f'exiftool timed out while processing {image_path}') + return '' + except Exception as e: + logger.debug(f'Error extracting film simulation: {e}') + return '' + + +def get_exif(img: Image, image_path: str = None, include_film_sim: bool = False) -> dict: """Load the exif data from an image. Args: img (Image): Pillow image object. + image_path (str, optional): Path to image file (needed for film simulation extraction) + include_film_sim (bool): Whether to extract film simulation data (requires exiftool) Returns: dict: dictionary with exif data @@ -82,10 +173,12 @@ def get_exif(img: Image) -> dict: 'Model': '', 'LensMake': '', 'LensModel': '', + 'LensModelShort': '', 'FNumber': '', 'FocalLength': '', 'ISOSpeedRatings': '', - 'ExposureTime': '' + 'ExposureTime': '', + 'FilmSimulation': '' } if exif_data: @@ -104,6 +197,18 @@ def get_exif(img: Image) -> dict: exif_dict[tag] = ExifItem(tag, data) + # Extract film simulation if requested and path provided + if include_film_sim and image_path: + film_sim = get_film_simulation(image_path) + exif_dict['FilmSimulation'] = ExifItem('FilmSimulation', film_sim) + + # Create shortened lens model name + lens_model = str(exif_dict.get('LensModel', '')) + lens_make = str(exif_dict.get('LensMake', '')) + if lens_model: + shortened = shorten_lens_name(lens_model, lens_make) + exif_dict['LensModelShort'] = ExifItem('LensModelShort', shortened) + # Print the EXIF data dictionary # print(exif_dict) diff --git a/fonts/Avenir.ttc b/fonts/Avenir.ttc deleted file mode 100644 index 5861625..0000000 Binary files a/fonts/Avenir.ttc and /dev/null differ diff --git a/main.py b/main.py index 39d655b..e01c1d9 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,24 @@ """ -Add a border to the image named in the first parameter. -A new image with {filename}_bordered will be generated. -TODO: Read up on sorting images by appearance https://github.com/Visual-Computing/LAS_FLAS/blob/main/README.md -""" + Add a border to the image named in the first parameter. + A new image with {filename}_bordered will be generated. + TODO: Read up on sorting images by appearance https://github.com/Visual-Computing/LAS_FLAS/blob/main/README.md + """ + import os import argparse -from PIL import Image +import logging +from PIL import Image, ImageOps from exif import get_exif from filemanager import should_include_file, get_directory_files from palette import load_image_color_palette, overlay_palette -from border import BorderType, create_border, draw_border, draw_exif +from border import BorderType, create_border, draw_border, draw_exif, get_film_simulation_image +from text import validate_font + +# Enable logging +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO, datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) def parse_arguments(): parser = argparse.ArgumentParser( @@ -17,13 +26,13 @@ def parse_arguments(): description='Add a border and exif data to jpg or png photos', epilog='Made for fun and to solve a little problem.' ) - parser.add_argument('path', help='File or directory path') + parser.add_argument('path', + help='File or directory path') parser.add_argument('-e', '--exif', action='store_true', default=False, help='Print photo exif data on the border') parser.add_argument('-p', '--palette', action='store_true', default=False, help='Add colour palette to the photo border') - parser.add_argument('-t', '--border_type', type=BorderType, choices=list(BorderType), - default=BorderType.SMALL, + parser.add_argument('-t', '--border_type', type=BorderType, choices=list(BorderType), default=BorderType.SMALL, help='Border Type: p for polaroid, s for small, m for medium, l for large, i for instagram') parser.add_argument('-r', '--recursive', action='store_true', default=False, help='Process directories recursively') @@ -31,9 +40,31 @@ def parse_arguments(): help='File patterns to include (default: *.jpg *.jpeg *.png, *.JPG, *.JPEG, *.PNG') parser.add_argument('--exclude', nargs='+', default=["*_border*"], help='File patterns to exclude (default: *_border*)') + parser.add_argument('-f', '--font', default='Roboto-Regular.ttf', + help='Font file in fonts directory') + parser.add_argument('-fv', '--fontvariant', default=0, type=int, + help='Font style variant index') + parser.add_argument('-fb', '--fontbold', default='Roboto-Medium.ttf', + help='Bold font file in fonts directory') + parser.add_argument('-fbv', '--fontboldvariant', default=0, type=int, + help='Bold font style variant index') + parser.add_argument('--oneline', action='store_true', default=False, + help='Use single-line text layout for large, polaroid, and instagram borders') + parser.add_argument('--twoline', action='store_true', default=False, + help='Use two-line left-aligned layout for large, polaroid, and instagram borders') + parser.add_argument('-s', action='store_true', default=False, + help='Include Fuji film simulation in EXIF data (requires exiftool)') + parser.add_argument('--filmsim-scale', type=float, default=0.5, + help='Scale factor for film-sim image relative to bottom border height (default: 0.9)') + parser.add_argument('--palette-tolerance', type=int, default=32, + help='Color grouping tolerance for palette extraction (default: 32, higher = fewer colors)') return parser.parse_args() -def process_image(path: str, add_exif: bool, add_palette: bool, border_type: BorderType) -> str: + +def process_image(path: str, add_exif: bool, add_palette: bool, border_type: BorderType, + font: tuple[str, int], boldfont: tuple[str, int], oneline: bool = False, + twoline: bool = False, include_film_sim: bool = False, film_sim_scale: float = 0.5, + palette_tolerance: int = 32) -> str: """ Add a border to an image Supported image types ['jpg', 'jpeg', 'png']. @@ -43,7 +74,12 @@ def process_image(path: str, add_exif: bool, add_palette: bool, border_type: Bor add_palette (bool): Add colour palette information to the border. Currently only supported on Polaroid border types. border_type (BorderType): The type of border to add to the photo. - + font: tuple[str, int]: (fontName, fontVariantIndex) + boldfont: tuple[str, int]: (fontName, fontVariantIndex) + oneline: bool: Use single-line text layout for large/polaroid/instagram borders + twoline: bool: Use two-line left-aligned layout for large/polaroid/instagram borders + include_film_sim: bool: Include Fuji film simulation data (requires exiftool) + film_sim_scale: float: Scale factor for the film-sim image relative to the bottom border height. """ filetypes = ['jpg', 'jpeg', 'png'] path_dot_parts = path.split('.') @@ -51,32 +87,146 @@ def process_image(path: str, add_exif: bool, add_palette: bool, border_type: Bor filename = ".".join(path_dot_parts[:-1]) if not ext or ext.lower() not in filetypes: - print(f'ERROR: image must be one of {filetypes}') + logger.error(f'Image must be one of {filetypes}') return exif = None img = Image.open(path) + + # Extract EXIF data before transposing (transpose creates a new image without _getexif method) + if add_exif: + exif = get_exif(img, image_path=path, include_film_sim=include_film_sim) + + # Apply EXIF orientation to ensure portrait images are correctly oriented + img = ImageOps.exif_transpose(img) border = create_border(img.width, img.height, border_type) img_with_border = draw_border(img, border) save_as = f'{filename}_border-{border.border_type}' + # Precompute film-sim image sizing and position so palette placement can avoid overlap. + film_img = None + film_w = film_h = film_x = None + if include_film_sim and exif: + film_sim = str(exif.get('FilmSimulation', '')) + if film_sim: + film_img = get_film_simulation_image(film_sim) + if film_img: + # compute target dimensions (do not resize yet) + film_target_h = max(1, round(border.bottom * film_sim_scale)) + orig_w, orig_h = film_img.size + film_w = max(1, int(orig_w * (film_target_h / orig_h))) + film_h = film_target_h + # right-edge alignment coordinate for the film image (relative to canvas) + photo_right = border.left + img.width + film_x = photo_right - film_w + if add_exif: - exif = get_exif(img) if exif: - img_with_border = draw_exif(img_with_border, exif, border) + moduledir = os.path.dirname(os.path.abspath(__file__)) + fontdir = os.path.join(moduledir, "fonts") + font_path = os.path.join(fontdir, font[0]) + bold_font_path = os.path.join(fontdir, boldfont[0]) + + # Exit early if a problem exists with the fonts + error_messages = [err for f in [(font_path, font[1]), (bold_font_path, boldfont[1])] + if (err := validate_font(fontpath=f[0], index=f[1]))] + if len(error_messages) > 0: + raise ValueError(error_messages) + + # We'll compute film image usage below and pass that into draw_exif + # (film image sizing/position will be precomputed just after this block) + use_film_image = True if (film_img and include_film_sim) else False + img_with_border = draw_exif(img_with_border, exif, border, (font_path, font[1]), (bold_font_path, boldfont[1]), oneline, twoline, add_palette, use_film_image) save_as = f'{save_as}_exif' if add_palette: palette_size = round(border.bottom / 3) - color_palette = load_image_color_palette(img, palette_size) + color_palette = load_image_color_palette(img, palette_size, tolerance=palette_tolerance) # Position palette on right side of bottom border palette_x = img_with_border.width - border.right - color_palette.width - palette_y = img_with_border.height - round(border.bottom / 2) - round(color_palette.height / 2) + + # Vertical positioning: align bottom edge with film sim bottom edge if present + if film_img and film_h is not None: + # Calculate film sim Y position to determine its bottom edge + if twoline or oneline: + breathing_room = max(16, int(border.bottom * 0.20)) + text_baseline_y = img_with_border.height - border.bottom + breathing_room + if twoline: + multiplier = 0.20 + else: # oneline + multiplier = 0.22 + estimated_heading_font_size = int(border.bottom * (multiplier + 0.04)) + max_font_from_border = int(border.bottom * 0.18) + estimated_heading_font_size = min(estimated_heading_font_size, max_font_from_border) + visual_text_top = text_baseline_y - int(estimated_heading_font_size * 0.75) + film_sim_y = visual_text_top + else: + # Default: center in bottom border + film_sim_y = img_with_border.height - round(border.bottom / 2) - round(film_h / 2) + + # Align palette bottom edge with film sim bottom edge + film_sim_bottom = film_sim_y + film_h + palette_y = film_sim_bottom - color_palette.height + elif twoline or oneline: + # For twoline/oneline without film sim, align with text baseline + breathing_room = max(16, int(border.bottom * 0.20)) + palette_y = img_with_border.height - border.bottom + breathing_room + else: + # Default: center in bottom border + palette_y = img_with_border.height - round(border.bottom / 2) - round(color_palette.height / 2) + + # Shift palette to the left of the film-sim image (if present) or align to photo right edge + padding = max(4, round(border.bottom * 0.12)) + photo_right = border.left + img.width + if film_img and film_x is not None: + # place palette so its right edge is film_x - padding + desired_palette_x = film_x - padding - color_palette.width + else: + # align palette right edge with photo right edge + desired_palette_x = photo_right - color_palette.width + # Cap desired position so palette stays inside the image canvas and not negative + max_palette_x = img_with_border.width - color_palette.width + palette_x = min(max(desired_palette_x, 0), max_palette_x) img_with_border = overlay_palette(img=img_with_border, color_palette=color_palette, offset=(palette_x, palette_y)) save_as = f'{save_as}_palette' + # Paste film-sim image inline with the palette area (or at the right if no palette) + if include_film_sim and exif and film_img: + # use precomputed film_w, film_h, film_x + resized = film_img.resize((film_w, film_h), resample=Image.LANCZOS) + + # Vertical placement + if twoline or oneline: + # For twoline and oneline layouts, align film sim top edge with visual top of first text line + # Text is drawn with anchor "ls" (left-baseline), so baseline is at y_start + # The visual top of text is approximately baseline - 0.75 * font_size + breathing_room = max(16, int(border.bottom * 0.20)) + text_baseline_y = img_with_border.height - border.bottom + breathing_room + # Estimate font size as a fraction of border (using same logic as in border.py) + if twoline: + multiplier = 0.20 + else: # oneline + multiplier = 0.22 + estimated_heading_font_size = int(border.bottom * (multiplier + 0.04)) + max_font_from_border = int(border.bottom * 0.18) + estimated_heading_font_size = min(estimated_heading_font_size, max_font_from_border) + # Visual top of text is approximately baseline - 0.75 * font_size + visual_text_top = text_baseline_y - int(estimated_heading_font_size * 0.75) + film_y = visual_text_top + elif add_palette and 'color_palette' in locals(): + # Align with palette vertically if present + film_y = palette_y + (color_palette.height - film_h) // 2 + else: + # Default: center in bottom border + film_y = img_with_border.height - round(border.bottom / 2) - round(film_h / 2) + + try: + img_with_border.paste(resized, (int(film_x), int(film_y)), resized) + except Exception: + img_with_border.paste(resized, (int(film_x), int(film_y))) + # There are two parts to JPEG quality. The first is the quality setting. # # JPEG also uses chroma subsampling, assuming that color hue changes are @@ -93,7 +243,7 @@ def process_image(path: str, add_exif: bool, add_palette: bool, border_type: Bor # # ref: https://stackoverflow.com/a/19303889 save_path = f'{save_as}.{ext}' - exifdata = img.getexif() #extracts original EXIF data from Image.open(path) + exifdata = img.getexif() # extracts original EXIF data from Image.open(path) img_with_border.save(save_path, exif=exifdata, subsampling=0, quality=95) # Clean up @@ -102,6 +252,7 @@ def process_image(path: str, add_exif: bool, add_palette: bool, border_type: Bor return save_path + def main(): args = parse_arguments() paths = [] @@ -113,14 +264,20 @@ def main(): if should_include_file(args.path, args.include, args.exclude): paths.append(args.path) else: - print(f'Skipping {args.path} as it does not match the include/exclude patterns') + logger.info(f'Skipping {args.path} as it does not match the include/exclude patterns') else: - print(f'Error: {args.path} is not a valid file or directory') + logger.error(f'{args.path} is not a valid file or directory') for path in paths: - print(f'Adding border to {path}') - save_path = process_image(path, args.exif, args.palette, args.border_type) - print(f'Saved as {save_path}') + logger.info(f'Adding border to {path}') + save_path = process_image(path=path, add_exif=args.exif, add_palette=args.palette, border_type=args.border_type, + font=(args.font, args.fontvariant) , boldfont=(args.fontbold, args.fontboldvariant), + oneline=args.oneline, twoline=args.twoline, include_film_sim=args.s, film_sim_scale=args.filmsim_scale, + palette_tolerance=args.palette_tolerance) + logger.info(f'Saved as {save_path}') if __name__ == "__main__": - main() + try: + main() + except ValueError as e: + logger.error(e) diff --git a/palette.py b/palette.py index bf0e97d..d3660dd 100644 --- a/palette.py +++ b/palette.py @@ -5,12 +5,74 @@ import extcolors from PIL import Image, ImageDraw -def extract_colors(img): - # tolerance = 32 - tolerance = 32 - limit = 5 - colors, pixel_count = extcolors.extract_from_image(img, tolerance, limit) +def is_near_white(color, threshold=240): + """ + Check if a color is near-white based on RGB values. + + Args: + color: RGB tuple (r, g, b) + threshold: RGB threshold value (default 240 - moderate filtering) + + Returns: + bool: True if color is near-white + """ + r, g, b = color[0], color[1], color[2] + return r > threshold and g > threshold and b > threshold + + +def filter_near_white_colors(colors, threshold=240, min_colors=5): + """ + Filter out near-white colors from the palette. + + Args: + colors: List of (color, count) tuples from extcolors + threshold: RGB threshold for near-white filtering (default 240) + min_colors: Minimum number of colors to keep (default 5) + + Returns: + List of filtered colors, ensuring at least min_colors are kept + """ + # First, try to filter near-white colors + filtered = [c for c in colors if not is_near_white(c[0], threshold)] + + # If we have enough colors after filtering, return them + if len(filtered) >= min_colors: + return filtered[:min_colors] + + # Otherwise, keep filtered colors and add back some original colors + # to reach min_colors, preferring darker colors + remaining_needed = min_colors - len(filtered) + near_whites = [c for c in colors if is_near_white(c[0], threshold)] + + # Sort near-whites by average RGB (darker first) + near_whites_sorted = sorted(near_whites, key=lambda c: sum(c[0][:3]) / 3) + + # Add back the darkest near-white colors to reach minimum + filtered.extend(near_whites_sorted[:remaining_needed]) + + return filtered[:min_colors] + +def extract_colors(img, tolerance=32, filter_whites=True): + """ + Extract dominant colors from an image. + + Args: + img: PIL Image object + tolerance: Color grouping tolerance (default 32) + filter_whites: Whether to filter near-white colors (default True) + + Returns: + List of (color, count) tuples + """ + limit = 12 # Extract more initially to account for filtering + colors, pixel_count = extcolors.extract_from_image(img, tolerance, limit) + + if filter_whites: + colors = filter_near_white_colors(colors, threshold=240, min_colors=5) + else: + colors = colors[:5] # Limit to 5 if not filtering + return colors @@ -49,8 +111,18 @@ def overlay_palette(img: Image, color_palette: Image, offset): return img -def load_image_color_palette(img, size): - colors = extract_colors(img) +def load_image_color_palette(img, size, tolerance=32): + """ + Load color palette from an image. + + Args: + img: PIL Image object + size: Size of each color square in pixels + tolerance: Color grouping tolerance (default 32) + + Returns: + PIL Image containing the color palette + """ + colors = extract_colors(img, tolerance=tolerance, filter_whites=True) color_palette = render_color_platte(colors, size) - # img = overlay_palette(img, color_palette) return color_palette diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exif.py b/tests/test_exif.py index fd75386..5efe10f 100644 --- a/tests/test_exif.py +++ b/tests/test_exif.py @@ -5,5 +5,3 @@ def test_ExifItem(): assert str(itm) == '23mm' itm = ExifItem('UnknownItem', ' Bleh ') assert str(itm) == 'Bleh' - - diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..3f2f836 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,30 @@ +import os +import pytest +from PIL import ImageFont +from text import create_font, load_font_variants + + + +def test_font_index(): + fontname = 'Roboto-Regular.ttf' + moduledir = os.path.dirname(os.path.abspath(__file__)) + fontdir = os.path.join(moduledir, "../fonts") + font_path = os.path.join(fontdir, fontname) + index = 20 + variants = [] + + try: + font = create_font(size=12, fontpath=font_path, index=index) + except Exception: + variants = load_font_variants(fontpath=font_path) + print(f'Error loading {fontname} font variant {index}. Available variants: {variants}') + assert len(variants) is not 0 + + for variant in variants: + try: + font = create_font(size=12, fontpath=font_path, index=variant[0]) + print(f'{variant[1]} loaded') + except Exception as exc: + pytest.fail(f'Unexpected eception raised: {exc}') + + diff --git a/text.py b/text.py index 9c1f0a4..ae1352b 100644 --- a/text.py +++ b/text.py @@ -2,44 +2,70 @@ Text on image functions """ import os +from typing import List, TypeVar from PIL import Image, ImageDraw, ImageFont -MODULEDIR = os.path.dirname(os.path.abspath(__file__)) -FONTDIR = os.path.join(MODULEDIR, "fonts") -# FONTNAME = f"{FONTDIR}Avenir.ttc" -# BOLDFONTNAME = f"{FONTDIR}Roboto-Bold.ttf" -# FONTINDEX = 8 -FONTNAME = os.path.join(FONTDIR, "Roboto-Regular.ttf") -BOLDFONTNAME = os.path.join(FONTDIR, "Roboto-Medium.ttf") -FONTINDEX = 0 +T = TypeVar('T') +def load_font_variants(fontpath: str) -> List[T]: + """Try loading the different font variant indices. -def create_font(size: int, fontname=FONTNAME) -> ImageFont.FreeTypeFont: - """Create the font object + Args: + fontpath (str): Path to the font file + + Returns: + [(index, {'family': str, 'style': str})]: A list containing the available font variants + """ + variants = [] + index = 0 + + while True: + try: + font = ImageFont.truetype(fontpath, size=12, index=index) + info = { + 'family': font.getname()[0], + 'style': font.getname()[1] + } + variants.append((index, info)) + index += 1 + except Exception: + break + + return variants + +def validate_font(fontpath: str, index: int) -> bool: + """Validate if the font exists and contains a variant index Args: - size (int): Pixel size + fontpath (str): Path to the font file + index (int): The font variant index. Returns: - ImageFont.FreeTypeFont: The created font + str: None if font exists, otherwise an error message. """ - font = ImageFont.truetype(fontname, size) - return font + if not os.path.isfile(fontpath): + return f'Font {fontpath} does not exist.' + font_variants = load_font_variants(fontpath) + if not any(variant[0] == index for variant in font_variants): + return f'Font {fontpath} does not contain a variant with index: {index}. Available variants: {font_variants}' -def create_bold_font(size: int, fontname=BOLDFONTNAME) -> ImageFont.FreeTypeFont: - """Create the bold font object + return None + +def create_font(size: int, fontpath: str, index: int = 0) -> ImageFont.FreeTypeFont: + """Create the font object Args: size (int): Pixel size + fontpath (str): Path to the font file + index (int): The font variant index. Defaults to 0. Returns: ImageFont.FreeTypeFont: The created font """ - font = ImageFont.truetype(fontname, size, index=FONTINDEX) + font = ImageFont.truetype(fontpath, size, index) return font - def draw_text_on_image(img: Image, text: str, xy: tuple, centered: bool, font: ImageFont.FreeTypeFont, fill: tuple = (100, 100, 100)) -> Image: """Draw text on an image @@ -50,7 +76,7 @@ def draw_text_on_image(img: Image, text: str, xy: tuple, centered: bool, xy (tuple): The xy position of the starting point centered (bool): Center the text relative to the entire image font (ImageFont.FreeTypeFont): The font to use. See create_font and create_bold_font. - fill (tuple, optional): The font colour. Defaults to black (100, 100, 100). + fill (tuple, optional): The font color. Defaults to black (100, 100, 100). Returns: Image: The image with the text drawn on it. @@ -69,7 +95,7 @@ def draw_text_on_image(img: Image, text: str, xy: tuple, centered: bool, w = draw.textlength(text, font=font) if centered: - # Centre the starting x pos + # Center the starting x pos x = (img.width - w) / 2 # Draw the actual text on the image. @@ -81,18 +107,21 @@ def draw_text_on_image(img: Image, text: str, xy: tuple, centered: bool, return img, (next_x, next_y) -def get_optimal_font_size(text, target_height, max_font_size=100, min_font_size=1): +def get_optimal_font_size(text, target_height, fontpath, index, max_font_size=100, min_font_size=1): """ Calculate the optimal font size based on a target height Args: text (str): Sample text to draw target_height (int): The target height + fontpath: (str): The path of the font to determine the size for. + index (int): The font variant index. max_font_size (int, optional): Max font size to return. Defaults to 100. min_font_size (int, optional): Min font size to return. Defaults to 1. """ def check_size(font_size): - font = ImageFont.truetype(FONTNAME, font_size) + font = ImageFont.truetype(fontpath, font_size, index) + font.size = font_size _, _, _, text_height = font.getbbox(text) return text_height <= target_height @@ -105,4 +134,4 @@ def check_size(font_size): else: high = mid - 1 - return high # The largest font size that fits + return high # The largest font size that fits