diff --git a/api/woocommerce_api.py b/api/woocommerce_api.py index 7ba4a2e..aea126b 100644 --- a/api/woocommerce_api.py +++ b/api/woocommerce_api.py @@ -12,7 +12,8 @@ from cryptography.fernet import Fernet import requests from woocommerce import API from utils.image_processing import ImageProcessor - +from config.encrypt_config import ConfigEncryptor +from utils.file_operations import FileProcessor CREDENTIALS_FILE = "credentials.json" # Hardcoded key (replace with your generated key) @@ -37,11 +38,8 @@ def save_credentials(url, consumer_key, consumer_secret, username, password): "username": username, "password": password, } - credentials_str = json.dumps(credentials) - fernet = Fernet(KEY) - encrypted = fernet.encrypt(credentials_str.encode()) - with open("config.enc", "wb") as file: - file.write(encrypted) + + ConfigEncryptor(KEY).save_credentials(credentials) def load_credentials(): @@ -230,7 +228,7 @@ def update_product(image_ids, product_id): f"Failed to update product with ID {product_id}. Error: {response.text}") -def process_product_images(product_id, name_template, canvas_width, canvas_height): +def process_product_images( options): """ Process images for a WooCommerce product by resizing and uploading them. @@ -240,7 +238,9 @@ def process_product_images(product_id, name_template, canvas_width, canvas_heigh canvas_width (int): The width of the canvas for resizing images. canvas_height (int): The height of the canvas for resizing images. """ - print(name_template) + product_id = options.get("product_id") + if not product_id: + return image_paths, product = get_product(product_id) if not image_paths: return @@ -252,15 +252,11 @@ def process_product_images(product_id, name_template, canvas_width, canvas_heigh new_list = [] for image_id, file_path in image_paths.items(): - output_path = generate_output_path( - temp_output_directory, - file_path, - name_template, - product, - canvas_width, - canvas_height, - ) - resize_image(file_path, output_path, "") + file = FileProcessor() + img = ImageProcessor() + output_path = file.generate_output_path(temp_output_directory, file_path, options, product) + + img.resize_image(file_path, output_path, options) new_id = upload_image(output_path) if new_id: old_list.append(image_id) @@ -293,14 +289,13 @@ def generate_output_path( sku = product.get("sku", "") slug = product.get("name", "") title = product.get("slug", "") - pprint.pprint(product) new_filename = template.format( name=name, sku=sku, width=width, height=height, slug=slug, title=title ) return os.path.join(temp_output_directory, new_filename + ext) -def process_all_products(name_template, canvas_width, canvas_height): +def process_all_products(options): """ Process images for all WooCommerce products by resizing and uploading them. @@ -321,8 +316,9 @@ def process_all_products(name_template, canvas_width, canvas_height): break for product in products: + options["product_id"] = product["id"] process_product_images( - product["id"], name_template, canvas_width, canvas_height + options ) page += 1 diff --git a/config/decrypt_config.py b/config/decrypt_config.py index b637c00..ca92539 100644 --- a/config/decrypt_config.py +++ b/config/decrypt_config.py @@ -58,6 +58,5 @@ if __name__ == "__main__": decryptor = ConfigDecryptor(DECRYPTION_KEY) try: config = decryptor.decrypt() - print(config) except FileNotFoundError as e: print(e) diff --git a/config/encrypt_config.py b/config/encrypt_config.py index 84d1be7..7a56889 100644 --- a/config/encrypt_config.py +++ b/config/encrypt_config.py @@ -1,53 +1,77 @@ -""" -Module for encrypting configuration files using Fernet symmetric encryption. -""" - from cryptography.fernet import Fernet +import json class ConfigEncryptor: - """ - Class to handle encryption of configuration data. - """ - - def __init__(self): - """ - Initialize the ConfigEncryptor with a generated encryption key. - """ - self.key = Fernet.generate_key() + def __init__(self, key, filename="config.enc"): + self.key = key + self.filename = filename + self.fernet = Fernet(self.key) def encrypt_config(self, data): - """ - Encrypt the configuration data and save it to 'config.enc'. - - Args: - data (str): The configuration data to be encrypted. - """ - fernet = Fernet(self.key) - encrypted = fernet.encrypt(data.encode()) - with open("config.enc", "wb") as encrypted_file: - encrypted_file.write(encrypted) + json_data = json.dumps(data) + encrypted_data = self.fernet.encrypt(json_data.encode()) + with open(self.filename, "wb") as encrypted_file: + encrypted_file.write(encrypted_data) def get_key(self): - """ - Get the generated encryption key. - - Returns: - str: The generated encryption key as a string. - """ return self.key.decode() + def save_credentials(self, credentials): + config = self.load_config() + if not config: + config = {"credentials": {}, "options": {}} + config["credentials"] = credentials + self.encrypt_config(config) + + def save_options(self, options): + config = self.load_config() + if not config: + config = {"credentials": {}, "options": {}} + # Ensure options only contains serializable data + serializable_options = {k: v for k, v in options.items() if self.is_json_serializable(v)} + config["options"] = serializable_options + self.encrypt_config(config) + + def load_config(self): + try: + with open(self.filename, "rb") as encrypted_file: + encrypted_data = encrypted_file.read() + decrypted_data = self.fernet.decrypt(encrypted_data).decode() + return json.loads(decrypted_data) + except FileNotFoundError: + return None + + @staticmethod + def is_json_serializable(value): + try: + json.dumps(value) + return True + except (TypeError, OverflowError): + return False + + +# Define your key here +# Replace with your actual key +key = b"u4xTBY5Ns4WYdLvqMjEr138mpMmDEhhqTszKCcDy2cI=" if __name__ == "__main__": - CONFIG_DATA = """ - { - "url": "https://yourstore.com", - "consumer_key": "ck_yourconsumerkey", - "consumer_secret": "cs_yoursecret", - "username": "yourusername", - "password": "yourpassword" + config_data = { + "credentials": { + "url": "https://yourstore.com", + "consumer_key": "ck_yourconsumerkey", + "consumer_secret": "cs_yoursecret", + "username": "yourusername", + "password": "yourpassword" + }, + "options": { + "canvas_width": 900, + "canvas_height": 900, + "template": "{slug}_{sku}_{width}x{height}", + "delete_images": False, + "background_color": "#FFFFFF" + } } - """ - encryptor = ConfigEncryptor() - print(f"Encryption key: {encryptor.get_key()}") - encryptor.encrypt_config(CONFIG_DATA) + encryptor = ConfigEncryptor(key) + encryptor.encrypt_config(config_data) + diff --git a/main.py b/main.py index b84372d..85d63d8 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ Main module for the Image Processor application. import tkinter as tk from tkinter import ttk +import customtkinter from ui.log_window import LogWindow from ui.local_processing_tab import LocalProcessingTab from ui.settings_tab import SettingsTab @@ -24,7 +25,7 @@ class ImageProcessorApp: """ self.root = root self.root.title("Image Processor") - self.root.geometry("700x400") + self.root.geometry("500x450") self.tab_parent = ttk.Notebook(self.root) self.log_window = None @@ -64,6 +65,6 @@ if __name__ == "__main__": except FileNotFoundError as e: print(f"File not found: {e}") - root = tk.Tk() + root = customtkinter.CTk() app = ImageProcessorApp(root) app.run() diff --git a/ui/local_processing_tab.py b/ui/local_processing_tab.py index 6f8baa1..cb9fdbe 100644 --- a/ui/local_processing_tab.py +++ b/ui/local_processing_tab.py @@ -14,7 +14,7 @@ from utils.image_processing import ImageProcessor from api.woocommerce_api import process_product_images, process_all_products from ui.options_window import OptionsWindow from pprint import pformat, pprint - +from config.encrypt_config import ConfigEncryptor class LocalProcessingTab: """ @@ -30,11 +30,13 @@ class LocalProcessingTab: text (str): The text to display on the tab. log (function): The function to log messages. """ + key = b"u4xTBY5Ns4WYdLvqMjEr138mpMmDEhhqTszKCcDy2cI=" + self.log = log self.tab = ttk.Frame(tab_parent) self.root = self.tab.winfo_toplevel() # Store the root window reference tab_parent.add(self.tab, text=text) - + self.config = ConfigEncryptor(key) self.log_window = None self.canvas_width = 900 @@ -45,7 +47,7 @@ class LocalProcessingTab: self.background_color = "#000000" self.image_format = "AUTO" self.image_size = "contain" - + self.load_config() self.source_type = StringVar(value="local") self.checkbox_var = BooleanVar(value=False) self.file = FileProcessor() @@ -55,6 +57,21 @@ class LocalProcessingTab: self.setup_ui() self.update_options() + def load_config(self): + + config = self.config.load_config() + if config: + if options := config.get("options"): + self.canvas_width = options.get("canvas_width", 900) + self.canvas_height = options.get("canvas_height", 900) + self.template = options.get("template", "{slug}_{sku}_{width}x{height}") + self.delete_images = options.get("delete_images", False) + self.transparent = options.get("transparent", True) + self.background_color = options.get("background_color", "#000000") + self.image_format = options.get("image_format", "AUTO") + self.image_size = options.get("image_size", "contain") + + def create_log_window(self): """ Create and display the log window. @@ -127,7 +144,7 @@ class LocalProcessingTab: self.options_button = ttk.Button( self.tab, text="Options", command=self.open_options_window ) - self.options_button.grid(row=4, column=0, padx=5, pady=5, sticky="w") + self.options_button.grid(row=2, column=3, columnspan=2, padx=5, pady=5, sticky="w") self.button_start = Button( self.tab, text="Start Processing", command=self.start_processing @@ -251,7 +268,6 @@ class LocalProcessingTab: """ Apply the canvas size settings and update previews. """ - pprint(self.image_size) self.image.set_image_size(self.image_size) def apply_background_color(self): @@ -261,7 +277,7 @@ class LocalProcessingTab: self.image.set_background_color(self.background_color) - def get_options(self): + def get_options(self) -> dict: """ Get the current processing options. @@ -321,7 +337,7 @@ class LocalProcessingTab: "image_format": { "type": "dropdown", "label": "Image Format:", - "options": ["AUTO", "JPEG", "PNG", "GIF"], + "options": ["AUTO", "JPEG", "PNG", "GIF", "DZI"], "default": self.image_format }, "image_size": { @@ -351,10 +367,12 @@ class LocalProcessingTab: self.delete_images = options["delete_images"] self.background_color = options["background_color"] self.image_size = options["image_size"] - + self.image_format = options["image_format"] self.apply_canvas_size() self.apply_background_color() self.apply_image_size() + key = b"u4xTBY5Ns4WYdLvqMjEr138mpMmDEhhqTszKCcDy2cI=" + self.config.save_options(self.get_options()) self.update_previews() def pprint_log_message(self, obj): @@ -375,31 +393,18 @@ class LocalProcessingTab: source = self.source_type.get() options = self.get_options() - # Log the current settings - self.pprint_log_message(options) - if source == "local": threading.Thread( - target=self.file.process_directory_with_logging, args=( - options,) + target=self.file.process_directory_with_logging, args=(options,) ).start() elif source == "product": threading.Thread( target=process_product_images, - args=( - options["product_id"], - options["canvas_width"], - options["canvas_height"], - options["log_message"], - ), + args=(options,) ).start() elif source == "all_products": threading.Thread( target=process_all_products, - args=( - options["canvas_width"], - options["canvas_height"], - options["log_message"], - ), + args=(options,) ).start() self.update_previews() diff --git a/ui/options_window.py b/ui/options_window.py index 836cea7..4a9831b 100644 --- a/ui/options_window.py +++ b/ui/options_window.py @@ -5,7 +5,7 @@ class OptionsWindow(tk.Toplevel): def __init__(self, parent, apply_callback, current_options): super().__init__(parent) self.title("Options") - self.geometry("400x500") + self.geometry("500x500") self.apply_callback = apply_callback self.options = current_options @@ -149,7 +149,7 @@ class OptionsWindow(tk.Toplevel): default (str): The default color. """ if default == "transparent": - default = "#00000000" + default = "#ffffff" var = tk.BooleanVar(value=True) else: var = tk.BooleanVar(value=False) diff --git a/ui/settings_tab.py b/ui/settings_tab.py index 57d2318..72468c3 100644 --- a/ui/settings_tab.py +++ b/ui/settings_tab.py @@ -2,6 +2,7 @@ import tkinter as tk from tkinter import ttk from api.woocommerce_api import save_credentials, load_credentials + class SettingsTab: def __init__(self, tab_parent, text): self.tab = ttk.Frame(tab_parent) diff --git a/utils/deepzoom.py b/utils/deepzoom.py new file mode 100644 index 0000000..5fffcfd --- /dev/null +++ b/utils/deepzoom.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python3 + +# +# Deep Zoom Tools +# +# Copyright (c) 2008-2019, Daniel Gasienica +# Copyright (c) 2008-2011, OpenZoom +# Copyright (c) 2010, Boris Bluntschli +# Copyright (c) 2008, Kapil Thangavelu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of OpenZoom nor the names of its contributors may be used +# to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + +import io +import math +import optparse +import os +import shutil +from urllib.parse import urlparse +import sys +import time +import urllib.request +import warnings +import xml.dom.minidom + +import PIL.Image + +from collections import deque + + +NS_DEEPZOOM = "http://schemas.microsoft.com/deepzoom/2008" + +DEFAULT_RESIZE_FILTER = PIL.Image.ANTIALIAS +DEFAULT_IMAGE_FORMAT = "jpg" + +RESIZE_FILTERS = { + "cubic": PIL.Image.CUBIC, + "bilinear": PIL.Image.BILINEAR, + "bicubic": PIL.Image.BICUBIC, + "nearest": PIL.Image.NEAREST, + "antialias": PIL.Image.ANTIALIAS, +} + +IMAGE_FORMATS = { + "jpg": "jpg", + "png": "png", +} + + + + +class DeepZoomImageDescriptor(object): + def __init__( + self, width=None, height=None, tile_size=254, tile_overlap=1, tile_format="jpg" + ): + self.width = width + self.height = height + self.tile_size = tile_size + self.tile_overlap = tile_overlap + self.tile_format = tile_format + self._num_levels = None + + def open(self, source): + """Intialize descriptor from an existing descriptor file.""" + doc = xml.dom.minidom.parse(safe_open(source)) + image = doc.getElementsByTagName("Image")[0] + size = doc.getElementsByTagName("Size")[0] + self.width = int(size.getAttribute("Width")) + self.height = int(size.getAttribute("Height")) + self.tile_size = int(image.getAttribute("TileSize")) + self.tile_overlap = int(image.getAttribute("Overlap")) + self.tile_format = image.getAttribute("Format") + + def save(self, destination): + """Save descriptor file.""" + file = open(destination, "wb") + doc = xml.dom.minidom.Document() + image = doc.createElementNS(NS_DEEPZOOM, "Image") + image.setAttribute("xmlns", NS_DEEPZOOM) + image.setAttribute("TileSize", str(self.tile_size)) + image.setAttribute("Overlap", str(self.tile_overlap)) + image.setAttribute("Format", str(self.tile_format)) + size = doc.createElementNS(NS_DEEPZOOM, "Size") + size.setAttribute("Width", str(self.width)) + size.setAttribute("Height", str(self.height)) + image.appendChild(size) + doc.appendChild(image) + descriptor = doc.toxml(encoding="UTF-8") + file.write(descriptor) + file.close() + + @classmethod + def remove(self, filename): + """Remove descriptor file (DZI) and tiles folder.""" + _remove(filename) + + @property + def num_levels(self): + """Number of levels in the pyramid.""" + if self._num_levels is None: + max_dimension = max(self.width, self.height) + self._num_levels = int(math.ceil(math.log(max_dimension, 2))) + 1 + return self._num_levels + + def get_scale(self, level): + """Scale of a pyramid level.""" + assert 0 <= level and level < self.num_levels, "Invalid pyramid level" + max_level = self.num_levels - 1 + return math.pow(0.5, max_level - level) + + def get_dimensions(self, level): + """Dimensions of level (width, height)""" + assert 0 <= level and level < self.num_levels, "Invalid pyramid level" + scale = self.get_scale(level) + width = int(math.ceil(self.width * scale)) + height = int(math.ceil(self.height * scale)) + return (width, height) + + def get_num_tiles(self, level): + """Number of tiles (columns, rows)""" + assert 0 <= level and level < self.num_levels, "Invalid pyramid level" + w, h = self.get_dimensions(level) + return ( + int(math.ceil(float(w) / self.tile_size)), + int(math.ceil(float(h) / self.tile_size)), + ) + + def get_tile_bounds(self, level, column, row): + """Bounding box of the tile (x1, y1, x2, y2)""" + assert 0 <= level and level < self.num_levels, "Invalid pyramid level" + offset_x = 0 if column == 0 else self.tile_overlap + offset_y = 0 if row == 0 else self.tile_overlap + x = (column * self.tile_size) - offset_x + y = (row * self.tile_size) - offset_y + level_width, level_height = self.get_dimensions(level) + w = self.tile_size + (1 if column == 0 else 2) * self.tile_overlap + h = self.tile_size + (1 if row == 0 else 2) * self.tile_overlap + w = min(w, level_width - x) + h = min(h, level_height - y) + return (x, y, x + w, y + h) + + +class DeepZoomCollection(object): + def __init__( + self, + filename, + image_quality=0.8, + max_level=7, + tile_size=256, + tile_format="jpg", + tile_background_color="#000000", + items=[], + ): + self.source = filename + self.image_quality = image_quality + self.tile_size = tile_size + self.max_level = max_level + self.tile_format = tile_format + self.tile_background_color = tile_background_color + self.items = deque(items) + self.next_item_id = len(self.items) + # XML + self.doc = xml.dom.minidom.Document() + collection = self.doc.createElementNS(NS_DEEPZOOM, "Collection") + collection.setAttribute("xmlns", NS_DEEPZOOM) + collection.setAttribute("MaxLevel", str(self.max_level)) + collection.setAttribute("TileSize", str(self.tile_size)) + collection.setAttribute("Format", str(self.tile_format)) + collection.setAttribute("Quality", str(self.image_quality)) + # TODO: Append items passed in as argument + items = self.doc.createElementNS(NS_DEEPZOOM, "Items") + collection.appendChild(items) + collection.setAttribute("NextItemId", str(self.next_item_id)) + self.doc.appendChild(collection) + + @classmethod + def from_file(self, filename): + """Open collection descriptor.""" + doc = xml.dom.minidom.parse(safe_open(filename)) + collection = doc.getElementsByTagName("Collection")[0] + image_quality = float(collection.getAttribute("Quality")) + max_level = int(collection.getAttribute("MaxLevel")) + tile_size = int(collection.getAttribute("TileSize")) + tile_format = collection.getAttribute("Format") + items = [ + DeepZoomCollectionItem.from_xml(item) + for item in doc.getElementsByTagName("I") + ] + + collection = DeepZoomCollection( + filename, + image_quality=image_quality, + max_level=max_level, + tile_size=tile_size, + tile_format=tile_format, + items=items, + ) + return collection + + @classmethod + def remove(self, filename): + """Remove collection file (DZC) and tiles folder.""" + _remove(filename) + + def append(self, source): + descriptor = DeepZoomImageDescriptor() + descriptor.open(source) + item = DeepZoomCollectionItem( + source, descriptor.width, descriptor.height, id=self.next_item_id + ) + self.items.append(item) + self.next_item_id += 1 + + def save(self, pretty_print_xml=False): + """Save collection descriptor.""" + collection = self.doc.getElementsByTagName("Collection")[0] + items = self.doc.getElementsByTagName("Items")[0] + while len(self.items) > 0: + item = self.items.popleft() + i = self.doc.createElementNS(NS_DEEPZOOM, "I") + i.setAttribute("Id", str(item.id)) + i.setAttribute("N", str(item.id)) + i.setAttribute("Source", item.source) + # Size + size = self.doc.createElementNS(NS_DEEPZOOM, "Size") + size.setAttribute("Width", str(item.width)) + size.setAttribute("Height", str(item.height)) + i.appendChild(size) + items.appendChild(i) + self._append_image(item.source, item.id) + collection.setAttribute("NextItemId", str(self.next_item_id)) + with open(self.source, "wb") as f: + if pretty_print_xml: + xml = self.doc.toprettyxml(encoding="UTF-8") + else: + xml = self.doc.toxml(encoding="UTF-8") + f.write(xml) + + def _append_image(self, path, i): + descriptor = DeepZoomImageDescriptor() + descriptor.open(path) + files_path = _get_or_create_path(_get_files_path(self.source)) + for level in reversed(range(self.max_level + 1)): + level_path = _get_or_create_path("%s/%s" % (files_path, level)) + level_size = 2 ** level + images_per_tile = int(math.floor(self.tile_size / level_size)) + column, row = self.get_tile_position(i, level, self.tile_size) + tile_path = "%s/%s_%s.%s" % (level_path, column, row, self.tile_format) + if not os.path.exists(tile_path): + tile_image = PIL.Image.new( + "RGB", (self.tile_size, self.tile_size), self.tile_background_color + ) + if self.tile_format == "jpg": + jpeg_quality = int(self.image_quality * 100) + tile_image.save(tile_path, "JPEG", quality=jpeg_quality) + else: + tile_image.save(tile_path) + tile_image = PIL.Image.open(tile_path) + source_path = "%s/%s/%s_%s.%s" % ( + _get_files_path(path), + level, + 0, + 0, + descriptor.tile_format, + ) + # Local + if os.path.exists(source_path): + try: + source_image = PIL.Image.open(safe_open(source_path)) + except IOError: + warnings.warn("Skipped invalid level: %s" % source_path) + continue + # Remote + else: + if level == self.max_level: + try: + source_image = PIL.Image.open(safe_open(source_path)) + except IOError: + warnings.warn("Skipped invalid image: %s" % source_path) + return + # Expected width & height of the tile + e_w, e_h = descriptor.get_dimensions(level) + # Actual width & height of the tile + w, h = source_image.size + # Correct tile because of IIP bug where low-level tiles have + # wrong dimensions (they are too large) + if w != e_w or h != e_h: + # Resize incorrect tile to correct size + source_image = source_image.resize( + (e_w, e_h), PIL.Image.ANTIALIAS + ) + # Store new dimensions + w, h = e_w, e_h + else: + w = int(math.ceil(w * 0.5)) + h = int(math.ceil(h * 0.5)) + source_image.thumbnail((w, h), PIL.Image.ANTIALIAS) + column, row = self.get_position(i) + x = (column % images_per_tile) * level_size + y = (row % images_per_tile) * level_size + tile_image.paste(source_image, (x, y)) + tile_image.save(tile_path) + + def get_position(self, z_order): + """Returns position (column, row) from given Z-order (Morton number.)""" + column = 0 + row = 0 + for i in range(0, 32, 2): + offset = i // 2 + # column + column_offset = i + column_mask = 1 << column_offset + column_value = (z_order & column_mask) >> column_offset + column |= column_value << offset + # row + row_offset = i + 1 + row_mask = 1 << row_offset + row_value = (z_order & row_mask) >> row_offset + row |= row_value << offset + return int(column), int(row) + + def get_z_order(self, column, row): + """Returns the Z-order (Morton number) from given position.""" + z_order = 0 + for i in range(32): + z_order |= (column & 1 << i) << i | (row & 1 << i) << (i + 1) + return z_order + + def get_tile_position(self, z_order, level, tile_size): + level_size = 2 ** level + x, y = self.get_position(z_order) + return ( + int(math.floor((x * level_size) / tile_size)), + int(math.floor((y * level_size) / tile_size)), + ) + + +class DeepZoomCollectionItem(object): + def __init__(self, source, width, height, id=0): + self.id = id + self.source = source + self.width = width + self.height = height + + @classmethod + def from_xml(cls, xml): + id = int(xml.getAttribute("Id")) + source = xml.getAttribute("Source") + size = xml.getElementsByTagName("Size")[0] + width = int(size.getAttribute("Width")) + height = int(size.getAttribute("Height")) + return DeepZoomCollectionItem(source, width, height, id) + + +class ImageCreator(object): + """Creates Deep Zoom images.""" + + def __init__( + self, + tile_size=254, + tile_overlap=1, + tile_format="jpg", + image_quality=0.8, + resize_filter=None, + copy_metadata=False, + ): + self.tile_size = int(tile_size) + self.tile_format = tile_format + self.tile_overlap = _clamp(int(tile_overlap), 0, 10) + self.image_quality = _clamp(image_quality, 0, 1.0) + if not tile_format in IMAGE_FORMATS: + self.tile_format = DEFAULT_IMAGE_FORMAT + self.resize_filter = resize_filter + self.copy_metadata = copy_metadata + + def get_image(self, level): + """Returns the bitmap image at the given level.""" + assert ( + 0 <= level and level < self.descriptor.num_levels + ), "Invalid pyramid level" + width, height = self.descriptor.get_dimensions(level) + # don't transform to what we already have + if self.descriptor.width == width and self.descriptor.height == height: + return self.image + if (self.resize_filter is None) or (self.resize_filter not in RESIZE_FILTERS): + return self.image.resize((width, height), PIL.Image.ANTIALIAS) + return self.image.resize((width, height), RESIZE_FILTERS[self.resize_filter]) + + def tiles(self, level): + """Iterator for all tiles in the given level. Returns (column, row) of a tile.""" + columns, rows = self.descriptor.get_num_tiles(level) + for column in range(columns): + for row in range(rows): + yield (column, row) + + def create(self, source, destination): + """Creates Deep Zoom image from source file and saves it to destination.""" + if isinstance(source, PIL.Image.Image): + self.image = source + else: + self.image = PIL.Image.open((source)) + width, height = self.image.size + self.descriptor = DeepZoomImageDescriptor( + width=width, + height=height, + tile_size=self.tile_size, + tile_overlap=self.tile_overlap, + tile_format=self.tile_format, + ) + # Create tiles + image_files = _get_or_create_path(_get_files_path(destination)) + for level in range(self.descriptor.num_levels): + level_dir = _get_or_create_path(os.path.join(image_files, str(level))) + level_image = self.get_image(level) + for (column, row) in self.tiles(level): + bounds = self.descriptor.get_tile_bounds(level, column, row) + tile = level_image.crop(bounds) + format = self.descriptor.tile_format + tile_path = os.path.join(level_dir, "%s_%s.%s" % (column, row, format)) + if self.descriptor.tile_format == "jpg": + jpeg_quality = int(self.image_quality * 100) + tile.save(tile_path, "JPEG", quality=jpeg_quality) + else: + tile.save(tile_path) + # Create descriptor + self.descriptor.save(destination) + + +class CollectionCreator(object): + """Creates Deep Zoom collections.""" + + def __init__( + self, + image_quality=0.8, + tile_size=256, + max_level=7, + tile_format="jpg", + copy_metadata=False, + tile_background_color="#000000", + ): + self.image_quality = image_quality + self.tile_size = tile_size + self.max_level = max_level + self.tile_format = tile_format + self.tile_background_color = tile_background_color + # TODO + self.copy_metadata = copy_metadata + + def create(self, images, destination): + """Creates a Deep Zoom collection from a list of images.""" + collection = DeepZoomCollection( + destination, + image_quality=self.image_quality, + max_level=self.max_level, + tile_size=self.tile_size, + tile_format=self.tile_format, + tile_background_color=self.tile_background_color, + ) + for image in images: + collection.append(image) + collection.save() + + +class DZI: + + def __init__(self, input, output, options) -> None: + # Normalize the paths to ensure consistency + image_path = os.path.normpath(input) + output_path = os.path.normpath(output) + log = options.get("log_message") + log(image_path) + log(output_path) + # Create Deep Zoom Image creator with weird parameters + creator = ImageCreator( + tile_size=254, + tile_overlap=1, + tile_format="png", + image_quality=1, + ) + + # Create Deep Zoom image pyramid from source + creator.create(image_path, output_path) + + +################################################################################ + + +def retry(attempts, backoff=2): + """Retries a function or method until it returns or + the number of attempts has been reached.""" + + if backoff <= 1: + raise ValueError("backoff must be greater than 1") + + attempts = int(math.floor(attempts)) + if attempts < 0: + raise ValueError("attempts must be 0 or greater") + + def deco_retry(f): + def f_retry(*args, **kwargs): + last_exception = None + for _ in range(attempts): + try: + return f(*args, **kwargs) + except Exception as exception: + last_exception = exception + time.sleep(backoff ** (attempts + 1)) + raise last_exception + + return f_retry + + return deco_retry + + +def _get_or_create_path(path): + if not os.path.exists(path): + os.makedirs(path) + return path + + +def _clamp(val, min, max): + if val < min: + return min + elif val > max: + return max + return val + + +def _get_files_path(path): + return os.path.splitext(path)[0] + "_files" + + +def _remove(path): + os.remove(path) + tiles_path = _get_files_path(path) + shutil.rmtree(tiles_path) + + +@retry(3) +def safe_open(path): + # `urllib` in Python 2 supported both local paths as well as URLs. To + # continue this in Python 3, we manually add `file://` prefix if `path` is + # not a URL. This change is isolated to this function as we want the output + # XML to still have the original input paths instead of absolute paths: + has_scheme = bool(urlparse(path).scheme) + normalized_path = ("file://%s" % os.path.abspath(path)) if not has_scheme else path + return io.BytesIO(path) + + +################################################################################ + + +def main(): + parser = optparse.OptionParser(usage="Usage: %prog [options] filename") + + parser.add_option( + "-d", + "--destination", + dest="destination", + help="Set the destination of the output.", + ) + parser.add_option( + "-s", + "--tile_size", + dest="tile_size", + type="int", + default=254, + help="Size of the tiles. Default: 254", + ) + parser.add_option( + "-f", + "--tile_format", + dest="tile_format", + default=DEFAULT_IMAGE_FORMAT, + help="Image format of the tiles (jpg or png). Default: jpg", + ) + parser.add_option( + "-o", + "--tile_overlap", + dest="tile_overlap", + type="int", + default=1, + help="Overlap of the tiles in pixels (0-10). Default: 1", + ) + parser.add_option( + "-q", + "--image_quality", + dest="image_quality", + type="float", + default=0.8, + help="Quality of the image output (0-1). Default: 0.8", + ) + parser.add_option( + "-r", + "--resize_filter", + dest="resize_filter", + default=DEFAULT_RESIZE_FILTER, + help="Type of filter for resizing (bicubic, nearest, bilinear, antialias (best). Default: antialias", + ) + + (options, args) = parser.parse_args() + + if not args: + parser.print_help() + sys.exit(1) + + source = args[0] + + if not options.destination: + if os.path.exists(source): + options.destination = os.path.splitext(source)[0] + ".dzi" + else: + options.destination = os.path.splitext(os.path.basename(source))[0] + ".dzi" + if options.resize_filter and options.resize_filter in RESIZE_FILTERS: + options.resize_filter = RESIZE_FILTERS[options.resize_filter] + + creator = ImageCreator( + tile_size=options.tile_size, + tile_format=options.tile_format, + image_quality=options.image_quality, + resize_filter=options.resize_filter, + ) + creator.create(source, options.destination) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/file_operations.py b/utils/file_operations.py index 59178a6..c40e270 100644 --- a/utils/file_operations.py +++ b/utils/file_operations.py @@ -1,8 +1,9 @@ import os import shutil from tkinter import filedialog, messagebox -from utils.image_processing import ImageProcessor from pprint import pprint +from utils.deepzoom import DZI + class FileProcessor: """ @@ -133,9 +134,11 @@ class FileProcessor: options (dict): Processing options. log (function): The log function to use. """ + from utils.image_processing import ImageProcessor image = ImageProcessor() image.set_background_color(options.get("background_color", "transparent")) image.set_image_size(options.get("image_size", "contain")) + format = options.get("image_format") for file_path in image_paths: # output_path = os.path.join( # output_directory, os.path.relpath( @@ -144,9 +147,12 @@ class FileProcessor: output_path = self.generate_output_path(output_directory, file_path, options) os.makedirs(os.path.dirname(output_path), exist_ok=True) self.log_message(f"Running: {file_path}", log) - image.resize_image( - file_path, output_path, options - ) + if format == "DZI": + DZI(file_path, output_path, options) + else: + image.resize_image( + file_path, output_path, options + ) if os.path.exists(file_path) and options.get("delete_images", False): self.log_message(f"Removing: {file_path}", log) @@ -173,5 +179,14 @@ class FileProcessor: new_filename = options.get('template', '{name}').format( name=name, sku=sku, width=width, height=height, slug=slug, title=title ) - pprint(new_filename) - return os.path.join(output_directory, new_filename + ext) + imgf = options.get("image_format", "AUTO") + if imgf == "AUTO": + return os.path.join(output_directory, new_filename + ext) + elif imgf == "GIF": + return os.path.join(output_directory, new_filename + ".gif") + elif imgf == "PNG": + return os.path.join(output_directory, new_filename + ".png") + elif imgf == "JPEG": + return os.path.join(output_directory, new_filename + ".jpg") + elif imgf == "DZI": + return os.path.join(output_directory, new_filename + ".dzi") diff --git a/utils/image_processing.py b/utils/image_processing.py index d45204c..b2e3581 100644 --- a/utils/image_processing.py +++ b/utils/image_processing.py @@ -2,7 +2,6 @@ import os from wand.image import Image from wand.color import Color - class ImageProcessor: def __init__(self, canvas_width=900, canvas_height=900, background_color="transparent", image_size="fit"): """ @@ -12,6 +11,7 @@ class ImageProcessor: self.canvas_height = canvas_height self.background_color = Color(background_color) self.image_size = image_size + def set_canvas_size(self, width, height): """ @@ -88,8 +88,7 @@ class ImageProcessor: aspect_ratio_img = img.width / img.height aspect_ratio_canvas = self.canvas_width / self.canvas_height - print(f"Image aspect ratio: {aspect_ratio_img}, Canvas aspect ratio: {aspect_ratio_canvas}") - print(f"Cover resized image size: {img.width}x{img.height}") + def _contain(self, img): @@ -98,13 +97,10 @@ class ImageProcessor: """ aspect_ratio_img = img.width / img.height aspect_ratio_canvas = self.canvas_width / self.canvas_height - print(f"Image aspect ratio: {aspect_ratio_img}, Canvas aspect ratio: {aspect_ratio_canvas}") - if aspect_ratio_img > aspect_ratio_canvas: img.transform(resize=f"{self.canvas_width}x") else: img.transform(resize=f"x{self.canvas_height}") - print(f"Cover resized image size: {img.width}x{img.height}") # def _fit(self, img): # """