Compare commits

...

3 Commits

Author SHA1 Message Date
cc0ee4dde1 dialogs 2024-06-17 23:55:32 +02:00
296d1ef7d4 image management 2024-06-17 23:55:25 +02:00
44f4a9a4bc hcl dialog 2024-06-17 20:42:01 +02:00
11 changed files with 456 additions and 129 deletions

View File

@ -0,0 +1,43 @@
from PyQt6.QtWidgets import QFileDialog, QWidget, QToolBar, QVBoxLayout, QPushButton
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import pyqtSignal
import numpy as np
from dialogs import DIALOGS
DIALOG_PROPERTY = "dialog"
class DialogsPanel(QWidget):
result_ready = pyqtSignal(np.ndarray)
def __init__(self, image_manager):
super().__init__()
self.mgr = image_manager
self.dialog_buttons = []
layout = QVBoxLayout()
self.setLayout(layout)
for DIALOG in DIALOGS:
btn = QPushButton(DIALOG.dialog_name(), self)
btn.setProperty(DIALOG_PROPERTY, DIALOG)
btn.clicked.connect(self.open_dialog)
self.dialog_buttons.append(btn)
layout.addWidget(btn)
def open_dialog(self):
dialog_factory = self.sender().property(DIALOG_PROPERTY)
self.dialog = dialog_factory(self.mgr.image())
self.dialog.result_ready().connect(lambda img : self.result_ready.emit(img))
self.dialog.accepted.connect(self.on_accepted)
self.dialog.rejected.connect(self.on_rejected)
self.dialog.exec()
def on_accepted(self):
self.mgr.update(self.dialog.last_processed)
self.dialog = None
def on_rejected(self):
self.mgr.refresh()

View File

@ -4,7 +4,7 @@ from PyQt6.QtCore import Qt
class ImageCanvas(QGraphicsView): class ImageCanvas(QGraphicsView):
_pixmapItem: QGraphicsPixmapItem _pixmapItem: QGraphicsPixmapItem
empty = True
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -30,6 +30,16 @@ class ImageCanvas(QGraphicsView):
def updatePixmap(self, image: QImage): def updatePixmap(self, image: QImage):
pixmap = QPixmap.fromImage(image) pixmap = QPixmap.fromImage(image)
self._pixmapItem.setPixmap(pixmap) self._pixmapItem.setPixmap(pixmap)
if self.empty:
self.reset()
self.empty = False
def clear(self):
self.scene.removeItem(self._pixmapItem)
self._pixmapItem = QGraphicsPixmapItem()
self.scene.addItem(self._pixmapItem)
self.empty = True
def reset(self): def reset(self):
self.resetTransform() # Reset the zoom level to default self.resetTransform() # Reset the zoom level to default

View File

@ -0,0 +1,73 @@
from PyQt6.QtWidgets import QFileDialog, QWidget, QToolBar, QHBoxLayout, QPushButton
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import pyqtSignal
import numpy as np
from ImageManager import ImageManager
class ImageManagePanel(QWidget):
on_update = pyqtSignal(np.ndarray)
on_close = pyqtSignal()
def __init__(self):
super().__init__()
self.mgr = ImageManager(self._on_update)
layout = QHBoxLayout()
self.setLayout(layout)
self.open_button = QPushButton(QIcon.fromTheme("document-open"), "Open", self)
layout.addWidget(self.open_button)
self.open_button.clicked.connect(self._open_image)
self.save_button = QPushButton(QIcon("save.png"), "Save", self)
layout.addWidget(self.save_button)
self.save_button.clicked.connect(self._save_image)
self.close_button = QPushButton(QIcon("close.png"), "Close", self)
layout.addWidget(self.close_button)
self.close_button.clicked.connect(self._close_image)
self.undo_button = QPushButton(QIcon("undo.png"), "Undo", self)
layout.addWidget(self.undo_button)
self.undo_button.clicked.connect(self._undo)
self.redo_button = QPushButton(QIcon("redo.png"), "Redo", self)
layout.addWidget(self.redo_button)
self.redo_button.clicked.connect(self._redo)
self._enable_buttons()
def _redo(self):
self.mgr.redo()
self._enable_buttons()
def _undo(self):
self.mgr.undo()
self._enable_buttons()
def _on_update(self, image):
self.on_update.emit(image)
self._enable_buttons()
def _enable_buttons(self):
self.undo_button.setEnabled(self.mgr.can_undo())
self.redo_button.setEnabled(self.mgr.can_redo())
def _close_image(self):
self.mgr.close()
self.on_close.emit()
def _open_image(self):
file_name, _ = QFileDialog.getOpenFileName(self, "Open Image File", "", "Image Files (*.jpg *.jpeg *.png)")
if file_name:
self.mgr.open(file_name)
def _save_image(self):
file_name, _ = QFileDialog.getSaveFileName(self, "Save Image File", "", "Image Files (*.jpg *.png)")
if file_name:
self.mgr.save(file_name)

View File

@ -0,0 +1,61 @@
from collections import deque
import numpy as np
import cv2
class ImageManager():
def __init__(self, update_fn):
super().__init__()
self.update_fn = update_fn
self.close()
def undo(self):
if len(self._undo_deque) == 0:
return
prev = self._undo_deque.pop()
self._redo_deque.append(self._curr_image)
self._curr_image = prev
self.update_fn(self._curr_image)
def redo(self):
if len(self._redo_deque) == 0:
return
latter = self._redo_deque.pop()
self._undo_deque.append(self._curr_image)
self._curr_image = latter
self.update_fn(self._curr_image)
def update(self, new_image):
self._undo_deque.append(self._curr_image)
self._redo_deque = deque()
self._curr_image = new_image.copy()
self.update_fn(self._curr_image)
def open(self, file_name):
self.close()
self._curr_image = cv2.imread(file_name)
self._curr_image = cv2.cvtColor(self._curr_image, cv2.COLOR_BGR2RGB)
self.update_fn(self._curr_image)
def close(self):
self._undo_deque = deque()
self._redo_deque = deque()
self._curr_image = None
def save(self, file_name):
if self._curr_image is not None:
cv2.imwrite(file_name, cv2.cvtColor(self._curr_image, cv2.COLOR_RGB2BGR))
def refresh(self):
self.update_fn(self._curr_image)
def image(self):
return self._curr_image
def can_redo(self):
return len(self._redo_deque) > 0
def can_undo(self):
return len(self._undo_deque) > 0

View File

@ -1,33 +0,0 @@
import abc
from ImageProcessingWorker import ImageProcessingWorker
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QFileDialog, QSlider, QLineEdit, QDialog
from PyQt6.QtGui import QPixmap, QImage, QColor, QPainter, QPen
from PyQt6.QtCore import Qt, QPoint, QThread
class ImageParameterDialog(QDialog):
def __init__(self, hsv_image):
self._hsv_image = hsv_image
self.setup_worker()
super().__init__()
def setup_worker(self):
self.worker = ImageProcessingWorker(self._hsv_image, self.process_image)
self.update_values_signal = self.worker.update_values
self.update_values_signal.connect(self.worker.process_image)
self.worker.start()
# @abc.abstractmethod
def process_image(self,hsv_image, values):
pass
def result_ready(self):
return self.worker.result_ready
def send_to_process(self, values):
self.update_values_signal.emit(values)

View File

@ -35,11 +35,41 @@ class ImageProcessingWorker(QThread):
img = self.image.copy() img = self.image.copy()
processed_image = self.process_function(img, values) processed_image = self.process_function(img, values)
processed_image = processed_image.astype('uint8') postprocessed_image = self.postprocess(processed_image)
rgb_image = cv2.cvtColor(processed_image, cv2.COLOR_HSV2RGB)
self.result_ready.emit(rgb_image) self.result_ready.emit(postprocessed_image)
def postprocess(self, processed_image):
return processed_image
def stop(self): def stop(self):
self.queue(None) self.queue(None)
self.wait() self.wait()
class RGBImageProcessingWorker(ImageProcessingWorker):
def __init__(self, image, process_function):
img = image.astype('float32')
super().__init__(img, process_function)
def postprocess(self, processed_image):
return processed_image.astype('uint8')
class HSVImageProcessingWorker(ImageProcessingWorker):
def __init__(self, image, process_function):
img = cv2.cvtColor(image, cv2.COLOR_RGB2HSV).astype('float32')
super().__init__(img, process_function)
def postprocess(self, processed_image):
processed_image = processed_image.astype('uint8')
return cv2.cvtColor(processed_image, cv2.COLOR_HSV2RGB)
class HLSImageProcessingWorker(ImageProcessingWorker):
def __init__(self, image, process_function):
img = cv2.cvtColor(image, cv2.COLOR_RGB2HLS).astype('float32')
super().__init__(img, process_function)
def postprocess(self, processed_image):
processed_image = processed_image.astype('uint8')
return cv2.cvtColor(processed_image, cv2.COLOR_HLS2RGB)

View File

@ -1,18 +1,20 @@
import abc import abc
from ImageProcessingWorker import ImageProcessingWorker import ImageProcessingWorker
import numpy as np import numpy as np
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QFileDialog, QSlider, QLineEdit, QDialog from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QFileDialog, QSlider, QLineEdit, QDialog
from PyQt6.QtGui import QPixmap, QImage, QColor, QPainter, QPen from PyQt6.QtGui import QPixmap, QImage, QColor, QPainter, QPen
from PyQt6.QtCore import Qt, QPoint, QThread from PyQt6.QtCore import Qt, QPoint, QThread
from ImageParameterDialog import ImageParameterDialog from .ImageParameterDialog import ImageParameterDialog
class HueDialog(ImageParameterDialog): class HCLDialog(ImageParameterDialog):
def __init__(self, hsv_image): def __init__(self, hsv_image):
super().__init__(hsv_image) super().__init__(hsv_image, ImageProcessingWorker.HLSImageProcessingWorker)
self.setWindowTitle("Hue Correction Dialog") self.setWindowTitle("HCL Correction")
self.layout = QVBoxLayout() self.layout = QVBoxLayout()
self.hue_slider = QSlider(Qt.Orientation.Horizontal) self.hue_slider = QSlider(Qt.Orientation.Horizontal)
@ -39,6 +41,7 @@ class HueDialog(ImageParameterDialog):
self.layout.addWidget(self.chroma_slider) self.layout.addWidget(self.chroma_slider)
self.layout.addWidget(self.label3) self.layout.addWidget(self.label3)
self.layout.addWidget(self.lightness_slider) self.layout.addWidget(self.lightness_slider)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout) self.setLayout(self.layout)
@ -54,15 +57,20 @@ class HueDialog(ImageParameterDialog):
def process_image(self,hsv_image, values): def process_image(self,hsv_image, values):
hue = values.get('hue', 0.0) / 2 hue = values.get('hue', 0.0) / 2
chroma = values.get('chroma', 0.0) + 1.0 chroma = values.get('chroma', 0.0)
lightness = values.get('lightness', 0.0) + 1.0 lightness = values.get('lightness', 0.0)
hsv_image[..., 0] = (hsv_image[..., 0] + hue) % 180 hsv_image[..., 0] = (hsv_image[..., 0] + hue) % 180
# Adjust chroma (saturation) hsv_image[..., 1] += (255 * lightness)
hsv_image[..., 1] = np.clip(hsv_image[..., 1] * chroma, 0, 255) hsv_image[..., 1] = np.clip(hsv_image[..., 1], 0, 255)
hsv_image[..., 2] += (255 * chroma)
hsv_image[..., 2] = np.clip(hsv_image[..., 2], 0, 255)
# Adjust lightness (value)
hsv_image[..., 2] = np.clip(hsv_image[..., 2] * lightness, 0, 255)
return hsv_image return hsv_image
@classmethod
def dialog_name(cls):
return "Hue-Chroma-Lightness"

View File

@ -0,0 +1,58 @@
import abc
from PyQt6.QtWidgets import QWidget, QDialog, QDialogButtonBox
from PyQt6.QtGui import QPixmap, QImage, QColor, QPainter, QPen
from PyQt6.QtCore import Qt, QPoint, QThread
class ImageParameterDialog(QDialog):
def __init__(self, image, worker_factory):
super().__init__()
self._image = image
self.last_processed = image
self.worker_factory = worker_factory
self.setup_worker()
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
def setup_worker(self):
self.worker = self.worker_factory(self._image, self.process_image)
self.worker.result_ready.connect(self._update_last_processed)
self.update_values_signal = self.worker.update_values
self.update_values_signal.connect(self.worker.process_image)
self.worker.start()
def _update_last_processed(self, new_image):
self.last_processed = new_image
def _on_accepted(self):
self.worker.stop()
self.accept()
# @abc.abstractmethod
def process_image(self,image, values):
pass
def result_ready(self):
return self.worker.result_ready
def send_to_process(self, values):
self.update_values_signal.emit(values)
def set_accept_enable(self, value):
self.button_box.button(QDialogButtonBox.StandardButton.Ok).setEnabled(value)
@classmethod
def dialog_name(cls):
return cls.__name__
@classmethod
def dialog_icon(cls):
return None

View File

@ -0,0 +1,137 @@
import ImageProcessingWorker
import numpy as np
import cv2
from PyQt6.QtWidgets import (QApplication, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QFileDialog, QSlider,
QLineEdit, QDialog, QCheckBox, QComboBox)
from PyQt6.QtGui import QPixmap, QImage, QColor, QPainter, QPen, QIntValidator
from PyQt6.QtCore import Qt, QPoint, QThread
from .ImageParameterDialog import ImageParameterDialog
INTERPOLATION_MAP = {
"Nearest": cv2.INTER_NEAREST,
"Linear": cv2.INTER_LINEAR,
"Area": cv2.INTER_AREA,
"Cubic": cv2.INTER_CUBIC,
"Lanczos4": cv2.INTER_LANCZOS4
}
class ResizeDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.ImageProcessingWorker)
self.original_height, self.original_width, _ = image.shape
self.setWindowTitle("Resizing")
self.layout = QVBoxLayout()
# Width input
self.width_label = QLabel("Width:")
self.width_field = QLineEdit()
self.width_field.setPlaceholderText("Enter width")
self.width_field.setText(str(self.original_width))
self.width_field.setValidator(QIntValidator(1, 10000))
self.width_field.textEdited.connect(self.width_changed)
# Height input
self.height_label = QLabel("Height:")
self.height_field = QLineEdit()
self.height_field.setPlaceholderText("Enter height")
self.height_field.setText(str(self.original_height))
self.height_field.setValidator(QIntValidator(1, 10000))
self.height_field.textEdited.connect(self.height_changed)
# Auto-scaling checkbox
self.auto_scale_checkbox = QCheckBox("Auto-scale")
self.auto_scale_checkbox.setChecked(True)
self.auto_scale_checkbox.stateChanged.connect(self.toggle_auto_scale)
# Interpolation method dropdown
self.interpolation_label = QLabel("Interpolation Method:")
self.interpolation_dropdown = QComboBox()
self.interpolation_dropdown.addItems(list(INTERPOLATION_MAP.keys()))
self.interpolation_dropdown.currentIndexChanged.connect(self.update)
# Layout for input fields
input_layout = QHBoxLayout()
input_layout.addWidget(self.width_label)
input_layout.addWidget(self.width_field)
input_layout.addWidget(self.height_label)
input_layout.addWidget(self.height_field)
self.layout.addLayout(input_layout)
self.layout.addWidget(self.auto_scale_checkbox)
self.layout.addWidget(self.interpolation_label)
self.layout.addWidget(self.interpolation_dropdown)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
self.toggle_auto_scale() # Initially disable the width and height fields
def toggle_auto_scale(self):
if self.auto_scale_checkbox.isChecked():
self.adjust_height()
def adjust_height(self):
if self.auto_scale_checkbox.isChecked():
try:
width = int(self.width_field.text())
height = int((width / self.original_width) * self.original_height)
self.height_field.setText(str(height))
except ValueError:
self.height_field.clear()
def adjust_width(self):
if self.auto_scale_checkbox.isChecked():
try:
height = int(self.height_field.text())
width = int((height / self.original_height) * self.original_width)
self.width_field.setText(str(width))
except ValueError:
self.width_field.clear()
def height_changed(self):
self.adjust_width()
self.update()
def width_changed(self):
self.adjust_height()
self.update()
def update(self):
width = self.width_field.text()
height = self.height_field.text()
if (not width or not height):
self.set_accept_enable(False)
return
self.send_to_process({
'width': int(width),
'height': int(height),
'auto_scale': self.auto_scale_checkbox.isChecked(),
'interpolation': self.interpolation_dropdown.currentText()
})
def process_image(self, image, values):
if values['width'] == 0 or values['height'] == 0:
return image
interpolation = INTERPOLATION_MAP[values['interpolation']]
try:
resized_image = cv2.resize(image, (values['width'], values['height']), interpolation=interpolation)
except:
self.set_accept_enable(False)
return image
self.set_accept_enable(True)
return resized_image
@classmethod
def dialog_name(cls):
return "Resize"

View File

@ -0,0 +1,8 @@
from .HCLDialog import HCLDialog
from .ResizeDialog import ResizeDialog
DIALOGS = [
HCLDialog,
ResizeDialog
]

View File

@ -7,62 +7,34 @@ import numpy as np
from ImageCanvas import ImageCanvas from ImageCanvas import ImageCanvas
from ImageProcessingWorker import ImageProcessingWorker from ImageProcessingWorker import ImageProcessingWorker
from HueDialog import HueDialog from ImageManager import ImageManager
from ImageManagePanel import ImageManagePanel
def process_image_function(image, values): from DialogsPanel import DialogsPanel
saturation = values.get('saturation', 0.0)
contrast = values.get('contrast', 1.0)
brightness = values.get('brightness', 0)
# Adjust saturation
hsv_image[:, :, 1] += saturation * 255
hsv_image[:, :, 1] = np.clip(hsv_image[:, :, 1], 0, 255)
# Adjust brightness and contrast
hsv_image[:, :, 2] = np.clip(hsv_image[:, :, 2] * contrast + brightness, 0, 255)
return rgb_image
class ImageEditor(QWidget): class ImageEditor(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Image Editor") self.setWindowTitle("Image Editor")
self.setGeometry(100, 100, 800, 600) self.setGeometry(100, 100, 800, 600)
self.canvas = ImageCanvas() self.canvas = ImageCanvas()
# self.image_label = QLabel() self.img_manager = ImageManagePanel()
# self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.img_manager.on_update.connect(self.display_image)
self.img_manager.on_close.connect(lambda : self.canvas.clear())
self.open_button = QPushButton("Open Image") self.dialogs_panel = DialogsPanel(self.img_manager.mgr)
self.open_button.clicked.connect(self.open_image) self.dialogs_panel.result_ready.connect(self.display_image)
self.save_button = QPushButton("Save Image")
self.save_button.clicked.connect(self.save_image)
self.hcl_button = QPushButton("Hue-Chroma-Lightness")
self.hcl_button.clicked.connect(self.open_hcl)
self.resolution_label = QLabel("Resolution:")
self.width_input = QLineEdit()
self.height_input = QLineEdit()
self.aspect_ratio_label = QLabel("Aspect Ratio:")
self.aspect_ratio_input = QLineEdit()
main_layout = QHBoxLayout() main_layout = QHBoxLayout()
side_panel = QVBoxLayout() side_panel = QVBoxLayout()
side_panel.addWidget(self.open_button) side_panel.addWidget(self.img_manager, 1)
side_panel.addWidget(self.save_button) side_panel.addWidget(self.dialogs_panel, 9)
side_panel.addWidget(self.hcl_button)
preview_panel = QVBoxLayout() preview_panel = QVBoxLayout()
preview_panel.addWidget(self.canvas) preview_panel.addWidget(self.canvas)
@ -75,55 +47,15 @@ class ImageEditor(QWidget):
self.setLayout(main_layout) self.setLayout(main_layout)
def open_image(self):
file_name, _ = QFileDialog.getOpenFileName(self, "Open Image File", "", "Image Files (*.jpg *.jpeg *.png)")
if file_name:
self.image = cv2.imread(file_name)
self.image = cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB)
self.image_as_hsv = cv2.cvtColor(self.image, cv2.COLOR_RGB2HSV).astype('float32')
self.display_image(self.image, first_load=True)
def display_image(self, image, first_load = False): def display_image(self, image, first_load = False):
height, width, channel = image.shape height, width, channel = image.shape
bytes_per_line = 3 * width bytes_per_line = 3 * width
q_image = QPixmap.fromImage(QImage(image.data, width, height, bytes_per_line, QImage.Format.Format_RGB888))
self.canvas.updatePixmap(QImage(image.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)) self.canvas.updatePixmap(QImage(image.data, width, height, bytes_per_line, QImage.Format.Format_RGB888))
if first_load: if first_load:
self.canvas.reset() self.canvas.reset()
def save_image(self):
file_name, _ = QFileDialog.getSaveFileName(self, "Save Image File", "", "Image Files (*.jpg *.png)")
if file_name and hasattr(self, 'image'):
cv2.imwrite(file_name, cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR))
def open_hcl(self):
self.dialog = HueDialog(self.image_as_hsv)
self.dialog.result_ready().connect(self.display_image)
self.dialog.exec()
def update_image(self):
print(self.saturation_slider.value(), self.contrast_slider.value(), self.brightness_slider.value())
if self.image_as_hsv is not None:
if self.worker is None:
self.setup_worker()
values = {
'saturation': self.saturation_slider.value() / 100.0,
'contrast': self.contrast_slider.value() / 100.0,
'brightness': self.brightness_slider.value()
}
self.update_values_signal.emit(values) # Emit new values to the worker
if __name__ == "__main__": if __name__ == "__main__":