Enhancing core features

This commit is contained in:
Mr Finchum 2024-12-27 20:55:20 +00:00
parent ba505ab382
commit e287f35240
8 changed files with 185 additions and 102 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
local_files/
debug.*
debug_log/
.ropeproject/
__pycache__/

View file

@ -1,6 +1,18 @@
# Changelog
## 0.1.x
### 0.1.1
- **Add Original to add Timestamp to Images**
- Introduced an option to add the original timestamp to images. Some programs use timestamps rather than file names to determine order, also enables a timeline-like organization for images.
- **Improved Font Handling**
- Instead of terminating the process when a font is not found, the program now skips the operation gracefully.
- **Input Validation**
- Added checks for input types, including strings, floats, and integers, to enhance robustness.
- **Save Function Optimization**
- Optimized the save function for cleaner code, partially utilizing ChatGPT-generated suggestions.
- **Code Formatting**
- Improved code structure and formatting for better readability and maintainability.
### 0.1.0: Core Features Added
- **Images are modified through all selected options without saving, reducing quality degradation and saving local storage.**
- **All core features are available:**
@ -17,7 +29,6 @@
- At the start of the program, the user is asked to save default values, such as JPG quality, resize options, and more. This way, the settings don't have to be entered at every start. Upon starting, the user is prompted to confirm whether they want to keep the current settings from the settings file.
- Options for changing EXIF data are saved in exif_options.yaml. Here, you can enter all the models, lenses, etc., you would like to select within the program.
## 0.0.x
### 0.0.3: Enhanced Functionality - now useable
- **New Image Modification Functions:**

View file

@ -10,6 +10,14 @@ The primary focus is on building a terminal-based user interface (TUI). Initiall
**Please check** if a new branch is available and read the **changelog** to see the progress and current features of the program. The README might sometimes lag behind.
## **Current Status**
- While the program works and core features are available, there are currently no safety checks in place. For example, the program will write / save an image without verifying if a file with the same name already exists.
- Additionally, while EXIF data/metadata should be implemented correctly, there is a possibility of overlooked issues. In the worst case, a program might throw an error when handling EXIF data, though this has not occurred so far.
### Available Features:
- Initial basic TUI functionality using `simple_term_menu` (planned to switch to a different interface later).
- Core features, including image resizing, metadata management, and YAML configuration.
## Key Features
- Intuitive TUI for organizing and editing metadata and image properties.
@ -46,22 +54,12 @@ conda install -c conda-forge textual pyyaml piexif pillow simple-term-menu
```
## Development Approach
Compared to my previous project, [FTL Save Manager](https://gitlab.com/python_projects3802849/ftl-save-manager), this project emphasizes:
- **Enhanced Modularity**: Classes and components are organized into separate files, making the codebase more maintainable and scalable.
- **Improved Design Principles**: Focus on creating reusable and flexible code for future expansion.
- **Slower Code Pushes**: Updates and code releases will be less frequent but of higher quality, ensuring stability and adherence to best practices.
## Current Status
The project is in its early stages, and initial releases will focus on:
- Basic TUI functionality using `simple_term_menu`.
- Core features like image resizing, metadata management, and YAML configuration.
Stay tuned for updates and more features as development progresses!
# Use of LLMs
In the interest of transparency, I disclose that Generative AI (GAI) large language models (LLMs), including OpenAIs ChatGPT and Ollama models (e.g., OpenCoder and Qwen2.5-coder), have been used to assist in this project.
@ -77,12 +75,10 @@ mradermacher gguf Q4K-M Instruct version of infly/OpenCoder-1.5B
unsloth gguf Q4K_M Instruct version of both Qwen/QWEN2 1.5B and 3B
### References
1. **Huang, Siming, et al.**
*OpenCoder: The Open Cookbook for Top-Tier Code Large Language Models.*
2024. [PDF](https://arxiv.org/pdf/2411.04905)
2. **Hui, Binyuan, et al.**
*Qwen2.5-Coder Technical Report.*
*arXiv preprint arXiv:2409.12186*, 2024. [arXiv](https://arxiv.org/abs/2409.12186)
@ -90,27 +86,3 @@ unsloth gguf Q4K_M Instruct version of both Qwen/QWEN2 1.5B and 3B
3. **Yang, An, et al.**
*Qwen2 Technical Report.*
*arXiv preprint arXiv:2407.10671*, 2024. [arXiv](https://arxiv.org/abs/2407.10671)
#### Orignal latext cites:
@inproceedings{Huang2024OpenCoderTO,
title={OpenCoder: The Open Cookbook for Top-Tier Code Large Language Models},
author={Siming Huang and Tianhao Cheng and Jason Klein Liu and Jiaran Hao and Liuyihan Song and Yang Xu and J. Yang and J. H. Liu and Chenchen Zhang and Linzheng Chai and Ruifeng Yuan and Zhaoxiang Zhang and Jie Fu and Qian Liu and Ge Zhang and Zili Wang and Yuan Qi and Yinghui Xu and Wei Chu},
year={2024},
url={https://arxiv.org/pdf/2411.04905}
}
@article{hui2024qwen2,
title={Qwen2. 5-Coder Technical Report},
author={Hui, Binyuan and Yang, Jian and Cui, Zeyu and Yang, Jiaxi and Liu, Dayiheng and Zhang, Lei and Liu, Tianyu and Zhang, Jiajun and Yu, Bowen and Dang, Kai and others},
journal={arXiv preprint arXiv:2409.12186},
year={2024}
}
@article{qwen2,
title={Qwen2 Technical Report},
author={An Yang and Baosong Yang and Binyuan Hui and Bo Zheng and Bowen Yu and Chang Zhou and Chengpeng Li and Chengyuan Li and Dayiheng Liu and Fei Huang and Guanting Dong and Haoran Wei and Huan Lin and Jialong Tang and Jialin Wang and Jian Yang and Jianhong Tu and Jianwei Zhang and Jianxin Ma and Jin Xu and Jingren Zhou and Jinze Bai and Jinzheng He and Junyang Lin and Kai Dang and Keming Lu and Keqin Chen and Kexin Yang and Mei Li and Mingfeng Xue and Na Ni and Pei Zhang and Peng Wang and Ru Peng and Rui Men and Ruize Gao and Runji Lin and Shijie Wang and Shuai Bai and Sinan Tan and Tianhang Zhu and Tianhao Li and Tianyu Liu and Wenbin Ge and Xiaodong Deng and Xiaohuan Zhou and Xingzhang Ren and Xinyu Zhang and Xipin Wei and Xuancheng Ren and Yang Fan and Yang Yao and Yichang Zhang and Yu Wan and Yunfei Chu and Yuqiong Liu and Zeyu Cui and Zhenru Zhang and Zhihao Fan},
journal={arXiv preprint arXiv:2407.10671},
year={2024}
}

View file

@ -6,20 +6,21 @@ model:
lens:
- Nikon LENS SERIES E 50mm
- AF NIKKOR 35-70mm
iso:
iso: # Numeric values cause errors with simple_term_menu and must be quoted. This issue will be resolved with the future UI switch.
- "100"
- "200"
- "400"
- "800"
- "1000"
- "1600"
- "3200"
image_description:
- ILFORD DELTA 3200
- ILFORD ILFOCOLOR
- LomoChrome Turquoise
user_comment:
- "Scanner.NORITSU-KOKI"
- "Scanner.NA"
- Scanner.NORITSU-KOKI
- Scanner.NA
artist:
- Mr. Finchum
copyright_info:

View file

@ -1,5 +1,6 @@
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
import piexif
import time
class ImageProcessor:
"""Functions using pillow are in here."""
@ -41,13 +42,13 @@ class ImageProcessor:
drawer = ImageDraw.Draw(image)
imagewidth, imageheight = image.size
margin = (imageheight / 100 ) * 2 # margin dynamic, 2% of image size
font_size = imagewidth / font_size_scale # Scaling the font size
try:
try: # Try loading front, if notaviable return unmodified image
font = ImageFont.truetype("OpenDyslexic3-Regular.ttf", font_size)
except:
print("Error loading font for watermark, exiting...")
exit()
print("Error loading font for watermark, please ensure font is installed...\n")
time.sleep(0.3)
return image
c, w, textwidth, textheight, = drawer.textbbox(xy = (0, 0), text = text, font = font) # Getting text size, only need the last two values
x = imagewidth - textwidth - margin
@ -56,26 +57,34 @@ class ImageProcessor:
return image
def save_image(self, image, path, file_type, jpg_quality, png_compressing, _optimize, exif_data = None):
"""Saving images. Needs improvments."""
if exif_data != None:
if file_type == "jpg":
image.save(f"{path}.{file_type.lower()}", quality = jpg_quality, optimize = _optimize, exif = piexif.dump(exif_data))
elif file_type == "png":
image.save(f"{path}.{file_type.lower()}", compress_level = png_compressing, optimize = _optimize, exif = piexif.dump(exif_data))
else:
input(f"Type: {file_type} not yet aviable, enter to continue...")
else:
if file_type == "jpg":
image.save(f"{path}.{file_type.lower()}", quality = jpg_quality, optimize = _optimize)
elif file_type == "png":
image.save(f"{path}.{file_type.lower()}", compress_level = png_compressing, optimize = _optimize)
else:
input(f"Type: {file_type} not yet aviable, enter to continue...")
def save_image(self, image, path, file_type, jpg_quality, png_compressing, optimize, exif_data = None):
# partly optimized by chatGPT
"""
Save an image to the specified path with optional EXIF data and optimization.
"""
file_type = file_type.lower()
save_params = {"optimize": optimize}
# Add file-specific parameters
if file_type == "jpg":
save_params["quality"] = jpg_quality
elif file_type == "png":
save_params["compress_level"] = png_compressing
elif file_type not in ["webp", "jpg", "png"]:
input(f"Type: {file_type} is not supported. Press Enter to continue...")
return
# Add EXIF data if available
if exif_data is not None:
save_params["exif"] = piexif.dump(exif_data)
if file_type == "webp":
print("File format webp does not support all exif features, some information might get lost...\n")
time.sleep(0.1)
try:
image.save(f"{path}.{file_type}", **save_params)
except Exception as e:
print(f"Failed to save image: {e}")
class ExifHandler:
"""Function using piexif are here."""
def __init__(self):
pass
@ -84,7 +93,7 @@ class ExifHandler:
def build_exif_dict(self, user_data, imagesize):
"""Build a piexif-compatible EXIF dictionary from user data."""
# Mostly made by ChatGPT, some adjustment by Mr Finchum
# Mostly made by ChatGPT, some adjustment
zeroth_ifd = {
piexif.ImageIFD.Make: user_data["make"],
piexif.ImageIFD.Model: user_data["model"],
@ -96,10 +105,11 @@ class ExifHandler:
piexif.ImageIFD.YResolution: (72, 1),
}
exif_ifd = {
piexif.ExifIFD.DateTimeOriginal: user_data["date_time_original"],
piexif.ExifIFD.UserComment: user_data["user_comment"],
piexif.ExifIFD.ISOSpeedRatings: int(user_data["iso"]),
piexif.ExifIFD.PixelXDimension: imagesize[0],
piexif.ExifIFD.PixelYDimension: imagesize[1],
}
if "date_time_original" in user_data:
exif_ifd[piexif.ExifIFD.DateTimeOriginal] = user_data["date_time_original"].encode("utf-8")
return {"0th": zeroth_ifd, "Exif": exif_ifd}

147
main.py
View file

@ -1,6 +1,6 @@
import os
import re
from datetime import datetime
#from debug import my_debugging_tools # Removed for main push
from utility import Utilities
from image_handler import ImageProcessor, ExifHandler
from tui import SimpleTUI
@ -8,8 +8,7 @@ from tui import SimpleTUI
class Optima35:
# The layout of class Optima35 was originally made by ChatGPT, but major adjustments have been made. To remain transparent, I disclose this.
def __init__(self, settings_file, exif_options_file):
self.version = "0.1.0"
#self.debugger = my_debugging_tools() # Removed for main push
self.version = "0.1.1"
self.utilities = Utilities()
self.image_processor = ImageProcessor()
self.exif_handler = ExifHandler()
@ -27,13 +26,20 @@ class Optima35:
"watermark_text": None,
"modifications": [],
}
self.settings_to_save = ["resize_percentage", "jpg_quality", "png_compression", "web_optimize", "contrast_percentage", "brightness_percentage"]
self.settings_to_save = [
"resize_percentage",
"jpg_quality",
"png_compression",
"web_optimize",
"contrast_percentage",
"brightness_percentage"
]
self.exif_choices = self.utilities.read_yaml(exif_options_file)
self.setting_file = settings_file
def load_or_ask_settings(self):
"""Load settings from a YAML file or ask the user if not present or incomplete."""
# Partly ChatGPT
# Partially ChatGPT
if self.read_settings(self.settings_to_save):
for item in self.settings_to_save:
print(f"{item}: {self.settings[item]}")
@ -44,12 +50,12 @@ class Optima35:
print("No settings found...")
print("Asking for new settings...\n")
self.settings["resize_percentage"] = int(input("Default resize percentage: ").strip())
self.settings["contrast_percentage"] = int(input("Default contrast percentage: ").strip())
self.settings["brightness_percentage"] = int(input("Default brightness percentage: ").strip())
self.settings["jpg_quality"] = int(input("JPEG quality (1-100): ").strip())
self.settings["png_compression"] = int(input("PNG compression level (0-9): ").strip())
self.settings["web_optimize"] = self.tui.yes_no_menu("Optimize images for web?")
self.settings["resize_percentage"] = self.take_input_and_validate(question = "Default resize percentage (below 100 downscale, above upscale): ", accepted_type = int, min_value = 1, max_value = 200)
self.settings["contrast_percentage"] = self.take_input_and_validate(question = "Default contrast percentage (negative = decrease, positive = increase): ", accepted_type = int, min_value = -100, max_value = 100)
self.settings["brightness_percentage"] = self.take_input_and_validate(question = "Default brighness percentage (negative = decrease, positive = increase): ", accepted_type = int, min_value = -100, max_value = 100)
self.settings["jpg_quality"] = self.take_input_and_validate(question = "JPEG quality (1-100, 80 default): ", accepted_type = int, min_value = 1, max_value = 100)
self.settings["png_compression"] = self.take_input_and_validate(question = "PNG compression level (0-9, 6 default): ", accepted_type = int, min_value = 0, max_value = 9)
self.settings["web_optimize"] = self.tui.yes_no_menu("Optimize images i.e. compressing?")
self.write_settings(self.settings_to_save)
@ -57,7 +63,6 @@ class Optima35:
""""Write self.setting, but only specific values"""
keys = keys_to_save
filtered_settings = {key: self.settings[key] for key in keys if key in self.settings}
self.utilities.write_yaml(self.setting_file, filtered_settings)
print("New settings saved successfully.")
@ -70,7 +75,6 @@ class Optima35:
keys = keys_to_load
if os.path.exists(self.setting_file):
loaded_settings = self.utilities.read_yaml(self.setting_file)
for key in keys:
if key in loaded_settings:
self.settings[key] = loaded_settings[key]
@ -92,43 +96,107 @@ class Optima35:
user_data[field] = choise.encode("utf-8")
user_data["software"] = f"OPTIMA-35 {self.version}".encode("utf-8")
user_data["date_time_original"] = datetime.now().strftime("%Y:%m:%d %H:%M:%S").encode("utf-8")
new_date = self.get_date_input()
if new_date:
user_data["date_time_original"] = new_date
return user_data
def get_date_input(self):
# Partially chatGPT
while True:
date_input = input("Enter a date (yyyy-mm-dd): ")
if date_input == "":
return None # Skip if input is empty
try:
new_date = datetime.strptime(date_input, "%Y-%m-%d")
return new_date.strftime("%Y:%m:%d 00:00:00")
except ValueError:
print("Invalid date format. Please enter the date in yyyy-mm-dd format.")
def get_user_settings(self):
"""Get initial settings from the user."""
menu_options = ["Resize image", "Change EXIF", "Convert to grayscale", "Change contrast", "Change brightness", "Rename images", "Invert image order", "Add Watermark"] # new option can be added here.
self.settings["input_folder"] = input("Enter path of input folder: ").strip() # Add: check if folder exists
menu_options = [
"Resize image",
"Change EXIF",
"Convert to grayscale",
"Change contrast",
"Change brightness",
"Rename images",
"Invert image order",
"Add Watermark"
] # new option can be added here.
self.settings["input_folder"] = input("Enter path of input folder: ").strip() # Add: check if folder exists.
self.settings["output_folder"] = input("Enter path of output folder: ").strip()
self.settings["file_format"] = input("Enter export file format (e.g., jpg, png): ").strip() # Add: specific question depending on export file type, like jpg quality and png compression
self.settings["file_format"] = self.take_input_and_validate(question = "Enter export file format (jpg, png, webp): ", accepted_input = ["jpg", "png", "webp"], accepted_type = str)
self.settings["modifications"] = self.tui.multi_select_menu(
"Select what you want to do", menu_options
)
if "Change EXIF" not in self.settings["modifications"]:
self.settings["copy_exif"] = self.tui.yes_no_menu("Do you want to copy exif info from original file?")
if "Rename images" in self.settings["modifications"]:
self.settings["new_file_names"] = input("What should be the name for the new images? ")
self.settings["new_file_names"] = input("What should be the name for the new images? ") # Need
if "Invert image order" in self.settings["modifications"]:
self.settings["invert_image_order"] = True
if "Add Watermark" in self.settings["modifications"]:
self.settings["watermark_text"] = input("Enter text for watermark. ")
os.makedirs(self.settings["output_folder"], exist_ok = True)
def take_input_and_validate(self, question, accepted_input = None, accepted_type = str, min_value = None, max_value = None):
"""
Asks the user a question, validates the input, and ensures it matches the specified criteria.
Args:
question (str): The question to ask the user.
accepted_input (list): A list of acceptable inputs (optional for non-numeric types).
accepted_type (type): The expected type of input (e.g., str, int, float).
min_value (int/float): Minimum value for numeric inputs (optional).
max_value (int/float): Maximum value for numeric inputs (optional).
Returns:
The validated user input.
"""
# Main layout by chatGPT, but modified.
while True:
user_input = input(question).strip()
try:
# Convert input to the desired type
if accepted_type in [int, float]:
user_input = accepted_type(user_input)
# Validate range for numeric types
if (min_value is not None and user_input < min_value) or (max_value is not None and user_input > max_value):
print(f"Input must be between {min_value} and {max_value}.")
continue
elif accepted_type == str:
# No conversion needed for strings
user_input = str(user_input)
else:
raise ValueError(f"Unsupported type: {accepted_type}")
# Validate against accepted inputs if provided
if accepted_input is not None and user_input not in accepted_input:
print(f"Invalid input. Must be one of: {', '.join(map(str, accepted_input))}.")
continue
return user_input # Input is valid
except ValueError:
print(f"Invalid input. Must be of type {accepted_type.__name__}.")
def process_images(self):
"""Process images based on user settings."""
input_folder = self.settings["input_folder"]
output_folder = self.settings["output_folder"]
image_files = [
f for f in os.listdir(input_folder) if f.lower().endswith(('.png', '.jpg', '.jpeg'))
f for f in os.listdir(input_folder) if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp"))
]
if "Change EXIF" in self.settings["modifications"]:
selected_exif = self.collect_exif_data()
i = 1
for image_file in image_files:
input_path = os.path.join(input_folder, image_file)
if "Rename images" in self.settings["modifications"]:
@ -146,6 +214,8 @@ class Optima35:
image = processed_img, percent = self.settings["resize_percentage"], resample = True
)
elif mod == "Change EXIF" and selected_exif:
if "date_time_original" in selected_exif:
selected_exif = self.modify_timestamp_in_exif(selected_exif, image_name)
exif_data = self.exif_handler.build_exif_dict(selected_exif, self.image_processor.get_image_size(processed_img))
elif mod == "Convert to grayscale":
processed_img = self.image_processor.grayscale(processed_img)
@ -166,13 +236,17 @@ class Optima35:
# If an error happends it is because the picture does not have exif data
print("Copying EXIF data selected, but no EXIF data is available in the original image file.")
exif_data = None
else:
elif "Change EXIF" not in self.settings["modifications"]:
exif_data = None
self.image_processor.save_image(
image = processed_img, path = output_path, exif_data = exif_data,
file_type = self.settings["file_format"], jpg_quality = self.settings["jpg_quality"],
png_compressing = self.settings["png_compression"], _optimize = self.settings["web_optimize"]
image = processed_img,
path = output_path,
exif_data = exif_data,
file_type = self.settings["file_format"],
jpg_quality = self.settings["jpg_quality"],
png_compressing = self.settings["png_compression"],
optimize = self.settings["web_optimize"]
)
self.utilities.progress_bar(i, len(image_files))
i += 1
@ -180,15 +254,30 @@ class Optima35:
def name_images(self, base_name, current_image, total_images, invert):
""""Returns name, combination of base_name and ending number."""
total_digits = len(str(total_images))
if invert:
ending_number = total_images - (current_image - 1)
else:
ending_number = current_image
ending = f"{ending_number:0{total_digits}}"
return f"{base_name}_{ending}"
def modify_timestamp_in_exif(self, exif_data, filename):
""""Takes exif data and adjust time to fit ending of filename."""
try:
last_tree = filename[-3:len(filename)]
total_seconds = int(re.sub(r'\D+', '', last_tree))
minutes = total_seconds // 60
seconds = total_seconds % 60
time = datetime.strptime(exif_data["date_time_original"], "%Y:%m:%d %H:%M:%S") # change date time string back to an time object for modification
new_time = time.replace(hour=12, minute=minutes, second=seconds)
exif_data["date_time_original"] = new_time.strftime("%Y:%m:%d %H:%M:%S")
return exif_data
except ValueError:
print("Modifying date went wrong, exiting...")
exit()
def run(self):
"""Run the main program."""
self.load_or_ask_settings()
@ -197,5 +286,5 @@ class Optima35:
print("Done")
if __name__ == "__main__":
app = Optima35("local_files/settings.yaml", "exif_options.yaml")
app = Optima35("settings.yaml", "exif_options.yaml")
app.run()

16
tui.py
View file

@ -44,13 +44,15 @@ class SimpleTUI:
def yes_no_menu(self, message): # oh
menu_options = ["[y] yes", "[n] no"]
menu = TerminalMenu(menu_entries = menu_options,
title = f"{message}",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False)
menu = TerminalMenu(
menu_entries = menu_options,
title = f"{message}",
menu_cursor = "> ",
menu_cursor_style = ("fg_red", "bold"),
menu_highlight_style = ("bg_gray", "fg_black"),
cycle_cursor = True,
clear_screen = False
)
menu_entry_index = menu.show()
if menu_entry_index == 0:
return True

View file

@ -9,7 +9,6 @@ class Utilities:
with open(yaml_file, "r") as file:
data = yaml.safe_load(file)
return data
except (FileNotFoundError, PermissionError) as e:
print(f"Error loading settings file: {e}")
return
@ -18,7 +17,6 @@ class Utilities:
try:
with open(yaml_file, "w") as file:
yaml.dump(data, file)
except PermissionError as e:
print(f"Error saving setings: {e}")
@ -41,7 +39,6 @@ class Utilities:
progress = int((barsize / total) * current)
rest = barsize - progress
if rest <= 2: rest = 0
# Determine the number of digits in total
total_digits = len(str(total))
# Format current with leading zeros