before custom
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -58,6 +58,5 @@ if __name__ == "__main__":
|
||||
decryptor = ConfigDecryptor(DECRYPTION_KEY)
|
||||
try:
|
||||
config = decryptor.decrypt()
|
||||
print(config)
|
||||
except FileNotFoundError as e:
|
||||
print(e)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
5
main.py
5
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
651
utils/deepzoom.py
Normal file
651
utils/deepzoom.py
Normal file
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
#
|
||||
# Deep Zoom Tools
|
||||
#
|
||||
# Copyright (c) 2008-2019, Daniel Gasienica <daniel@gasienica.ch>
|
||||
# Copyright (c) 2008-2011, OpenZoom <http://openzoom.org>
|
||||
# Copyright (c) 2010, Boris Bluntschli <boris@bluntschli.ch>
|
||||
# Copyright (c) 2008, Kapil Thangavelu <kapil.foss@gmail.com>
|
||||
# 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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
"""
|
||||
@@ -13,6 +12,7 @@ class ImageProcessor:
|
||||
self.background_color = Color(background_color)
|
||||
self.image_size = image_size
|
||||
|
||||
|
||||
def set_canvas_size(self, width, height):
|
||||
"""
|
||||
Set the canvas size.
|
||||
@@ -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):
|
||||
# """
|
||||
|
||||
Reference in New Issue
Block a user