Compare commits

..

6 Commits

Author SHA1 Message Date
4d6631b03e Merge pull request 'image-editor' (#1) from image-editor into main
Reviewed-on: #1
2024-06-18 03:49:23 +02:00
382e5bfd9e add all dialogs and add setup 2024-06-18 03:47:47 +02:00
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
2f3f618f41 started working on image editor 2024-06-16 22:46:30 +02:00
20 changed files with 1048 additions and 0 deletions

View File

@ -0,0 +1,5 @@
*.jpg
*.png
*.JPEG
__pycache__

View File

@ -0,0 +1,56 @@
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QGroupBox
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.image_mgr = image_manager
self.image_mgr.on_close.connect(lambda : self.buttons_set_enabled(False))
self.image_mgr.on_open.connect(lambda : self.buttons_set_enabled(True))
self.dialog_buttons = []
main_layout = QVBoxLayout()
self.setLayout(main_layout)
operations_group_box = QGroupBox("Operations")
layout = QVBoxLayout()
layout.setContentsMargins(75,25,75,25)
for DIALOG in DIALOGS:
btn = QPushButton(DIALOG.dialog_name(), self)
btn.setProperty(DIALOG_PROPERTY, DIALOG)
btn.clicked.connect(self.open_dialog)
btn.setEnabled(False)
self.dialog_buttons.append(btn)
layout.addWidget(btn)
operations_group_box.setLayout(layout)
main_layout.addWidget(operations_group_box)
def buttons_set_enabled(self, state):
for btn in self.dialog_buttons:
btn.setEnabled(state)
def open_dialog(self):
dialog_factory = self.sender().property(DIALOG_PROPERTY)
self.dialog = dialog_factory(self.image_mgr.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.image_mgr.mgr.update(self.dialog.last_processed)
self.dialog = None
def on_rejected(self):
self.image_mgr.mgr.refresh()

View File

@ -0,0 +1,46 @@
from PyQt6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
from PyQt6.QtGui import QPixmap, QWheelEvent, QPainter, QImage
from PyQt6.QtCore import Qt
class ImageCanvas(QGraphicsView):
_pixmapItem: QGraphicsPixmapItem
empty = True
def __init__(self):
super().__init__()
self.scene = QGraphicsScene()
self.setScene(self.scene)
self._pixmapItem = QGraphicsPixmapItem()
self.scene.addItem(self._pixmapItem)
self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
self.setDragMode(self.DragMode.ScrollHandDrag)
self.setTransformationAnchor(self.ViewportAnchor.AnchorUnderMouse)
def wheelEvent(self, event):
zoom_in_factor = 1.25
zoom_out_factor = 1.0 / zoom_in_factor
if event.angleDelta().y() > 0:
self.scale(zoom_in_factor, zoom_in_factor)
else:
self.scale(zoom_out_factor, zoom_out_factor)
def updatePixmap(self, image):
pixmap = QPixmap.fromImage(image)
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):
self.resetTransform() # Reset the zoom level to default
self.fitInView(self._pixmapItem, Qt.AspectRatioMode.KeepAspectRatio) # Fit the image to the view

View File

@ -0,0 +1,68 @@
from PyQt6.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QApplication
import sys
from ImageCanvas import ImageCanvas
from ImageManagePanel import ImageManagePanel
from DialogsPanel import DialogsPanel
class ImageEditor(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Image Editor")
self.setGeometry(100, 100, 800, 600)
self.canvas = ImageCanvas()
self.img_manager = ImageManagePanel()
self.img_manager.on_update.connect(self.display_image)
self.img_manager.on_close.connect(lambda : self.canvas.clear())
self.dialogs_panel = DialogsPanel(self.img_manager)
self.dialogs_panel.result_ready.connect(self.display_image)
main_layout = QHBoxLayout()
side_panel = QVBoxLayout()
side_panel.addWidget(self.img_manager, 1)
side_panel.addWidget(self.dialogs_panel, 9)
preview_panel = QVBoxLayout()
preview_panel.addWidget(self.canvas)
main_layout.addLayout(side_panel,2)
main_layout.addLayout(preview_panel, 3)
self.setLayout(main_layout)
self.setAcceptDrops(True)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
files = [url.toLocalFile() for url in event.mimeData().urls()]
if files:
self.img_manager.open(files[0])
def display_image(self, image):
height, width, channel = image.shape
bytes_per_line = 3 * width
self.canvas.updatePixmap(QImage(image.data, width, height, bytes_per_line, QImage.Format.Format_RGB888))
def main():
app = QApplication(sys.argv)
editor = ImageEditor()
editor.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@ -0,0 +1,87 @@
from PyQt6.QtWidgets import QFileDialog, QWidget, QHBoxLayout, QPushButton, QMessageBox, QGroupBox
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()
on_open = pyqtSignal()
def __init__(self):
super().__init__()
self.mgr = ImageManager(self._on_update)
main_layout = QHBoxLayout()
self.setLayout(main_layout)
file_group_box = QGroupBox("File")
layout = QHBoxLayout()
self.open_button = QPushButton("Open", self)
layout.addWidget(self.open_button)
self.open_button.clicked.connect(self._open_image)
self.save_button = QPushButton("Save", self)
layout.addWidget(self.save_button)
self.save_button.clicked.connect(self._save_image)
self.close_button = QPushButton("Close", self)
layout.addWidget(self.close_button)
self.close_button.clicked.connect(self._close_image)
self.undo_button = QPushButton("Undo", self)
layout.addWidget(self.undo_button)
self.undo_button.clicked.connect(self._undo)
self.redo_button = QPushButton("Redo", self)
layout.addWidget(self.redo_button)
self.redo_button.clicked.connect(self._redo)
file_group_box.setLayout(layout)
main_layout.addWidget(file_group_box)
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 (*.png *.jpg *.jpeg *.bmp)")
self.open(file_name)
def _save_image(self):
try:
file_name, _ = QFileDialog.getSaveFileName(self, "Save Image File", "", "Image Files (*.png *.jpg *.jpeg *.bmp)")
if file_name:
self.mgr.save(file_name)
except Exception as e:
QMessageBox.critical(self, "Error Saving File", f"An error occurred while saving the file:\n{str(e)}")
def open(self, file_name):
try:
if file_name:
self.mgr.open(file_name)
self.on_open.emit()
except Exception as e:
QMessageBox.critical(self, "Error Opening File", f"An error occurred while opening the file:\n{str(e)}")

View File

@ -0,0 +1,60 @@
from collections import deque
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

@ -0,0 +1,74 @@
import numpy as np
import cv2
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QThread
from queue import Queue
class ImageProcessingWorker(QThread):
result_ready = pyqtSignal(np.ndarray)
update_values = pyqtSignal(dict)
def __init__(self, image, process_function):
super().__init__()
self.image = image
self.process_function = process_function
self.values_queue = Queue(maxsize=1)
@pyqtSlot(dict)
def process_image(self, values):
self.queue(values)
def queue(self, value):
if self.values_queue.full():
try:
self.values_queue.get_nowait()
except Queue.Empty:
pass
self.values_queue.put(value)
def run(self):
while True:
values = self.values_queue.get()
if values is None: # A way to exit the thread
break
img = self.image.copy()
processed_image = self.process_function(img, values)
postprocessed_image = self.postprocess(processed_image)
self.result_ready.emit(postprocessed_image)
def postprocess(self, processed_image):
return processed_image
def stop(self):
self.queue(None)
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

@ -0,0 +1 @@
from .image_editor import ImageEditor

View File

@ -0,0 +1,54 @@
import numpy as np
import cv2
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QSlider
from PyQt6.QtCore import Qt
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
class BrightnessContrastDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.RGBImageProcessingWorker)
self.setWindowTitle("Brightness/Contrast Adjustment")
self.layout = QVBoxLayout()
self.brightness_slider = QSlider(Qt.Orientation.Horizontal)
self.brightness_slider.setRange(-127, 127)
self.contrast_slider = QSlider(Qt.Orientation.Horizontal)
self.contrast_slider.setRange(-127, 127)
self.label1 = QLabel("Brightness: 0")
self.label2 = QLabel("Contrast: 0")
self.brightness_slider.valueChanged.connect(lambda value: self.update(self.label1, value, "Brightness"))
self.contrast_slider.valueChanged.connect(lambda value: self.update(self.label2, value, "Contrast"))
self.layout.addWidget(self.label1)
self.layout.addWidget(self.brightness_slider)
self.layout.addWidget(self.label2)
self.layout.addWidget(self.contrast_slider)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update(self, label, value, slider_name):
label.setText(f"{slider_name}: {value}")
self.send_to_process({
'brightness': self.brightness_slider.value(),
'contrast': self.contrast_slider.value(),
})
def process_image(self, image, values):
brightness = values.get('brightness', 0.0) + 1
contrast = values.get('contrast', 0.0) + 1
c = (259 * (contrast + 255)) / (255 * (259 - contrast))
scaled = c * (image - 128) + 128 + brightness
return np.clip(scaled, 0, 255)
@classmethod
def dialog_name(cls):
return "Brightness-Contrast"

View File

@ -0,0 +1,75 @@
import numpy as np
import cv2
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QSlider, QHBoxLayout, QGroupBox
from PyQt6.QtCore import Qt
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
class ColorBalanceDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.RGBImageProcessingWorker)
self.setWindowTitle("Color Balance Adjustment")
self.layout = QVBoxLayout()
self.sliders = {}
self.labels = {}
self.channel_names = ['Red', 'Green', 'Blue']
self.tone_names = ['Shadows', 'Midtones', 'Highlights']
for tone in self.tone_names:
tone_box = QGroupBox(tone)
tone_layout = QVBoxLayout()
for channel in self.channel_names:
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(-100, 100)
slider.setValue(0)
label = QLabel(f"{channel}: 0")
slider.valueChanged.connect(lambda value, lbl=label, ch=channel, tn=tone: self.update(lbl, value, ch, tn))
self.sliders[(channel, tone)] = slider
self.labels[(channel, tone)] = label
tone_layout.addWidget(label)
tone_layout.addWidget(slider)
tone_box.setLayout(tone_layout)
self.layout.addWidget(tone_box)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update(self, label, value, channel, tone):
label.setText(f"{channel}: {value}")
self.send_to_process(self.collect_values())
def collect_values(self):
values = {}
for channel in self.channel_names:
for tone in self.tone_names:
values[(channel, tone)] = self.sliders[(channel, tone)].value()
return values
def process_image(self, image, values):
img = image / 255.0
luminance = 0.299 * image[..., 0] + 0.587 * image[..., 1] + 0.114 * image[..., 2]
shadows_mask = luminance < 85
midtones_mask = (luminance >= 85) & (luminance <= 170)
highlights_mask = luminance > 170
for channel_idx, channel in enumerate(self.channel_names):
for tone, mask in zip(self.tone_names, [shadows_mask, midtones_mask, highlights_mask]):
adjustment = values[(channel, tone)]
if adjustment != 0:
factor = (259 * (adjustment + 255)) / (255 * (259 - adjustment))
img[..., channel_idx] = np.where(mask, img[..., channel_idx] * factor, img[..., channel_idx])
img = np.clip(img * 255, 0, 255)
return img
@classmethod
def dialog_name(cls):
return "Color Balance"

View File

@ -0,0 +1,42 @@
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QCheckBox
import cv2
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
class FlipDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.ImageProcessingWorker)
self.setWindowTitle("Flip Image")
self.layout = QVBoxLayout()
self.horizontal_flip_checkbox = QCheckBox("Flip Horizontally")
self.horizontal_flip_checkbox.stateChanged.connect(self.update)
self.vertical_flip_checkbox = QCheckBox("Flip Vertically")
self.vertical_flip_checkbox.stateChanged.connect(self.update)
self.layout.addWidget(QLabel("Select flip options:"))
self.layout.addWidget(self.horizontal_flip_checkbox)
self.layout.addWidget(self.vertical_flip_checkbox)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update(self):
self.send_to_process({
'flip_horizontal': self.horizontal_flip_checkbox.isChecked(),
'flip_vertical': self.vertical_flip_checkbox.isChecked()
})
def process_image(self, image, values):
if values['flip_horizontal']:
image = cv2.flip(image, 1)
if values['flip_vertical']:
image = cv2.flip(image, 0)
return image
@classmethod
def dialog_name(cls):
return "Flip Image"

View File

@ -0,0 +1,71 @@
import numpy as np
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QSlider
from PyQt6.QtCore import Qt
import ImageProcessingWorker
from .ImageParameterDialog import ImageParameterDialog
class HCLDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.HLSImageProcessingWorker)
self.setWindowTitle("HCL Correction")
self.layout = QVBoxLayout()
self.hue_slider = QSlider(Qt.Orientation.Horizontal)
self.hue_slider.setRange(-180, 180)
self.chroma_slider = QSlider(Qt.Orientation.Horizontal)
self.chroma_slider.setRange(-100, 100)
self.lightness_slider = QSlider(Qt.Orientation.Horizontal)
self.lightness_slider.setRange(-100, 100)
self.label1 = QLabel("Hue: 0")
self.label2 = QLabel("Chroma: 0")
self.label3 = QLabel("Lightness: 0")
self.hue_slider.valueChanged.connect(lambda value: self.update(self.label1, value, "Hue"))
self.chroma_slider.valueChanged.connect(lambda value: self.update(self.label2, value, "Chroma"))
self.lightness_slider.valueChanged.connect(lambda value: self.update(self.label3, value, "Lightness"))
self.layout.addWidget(self.label1)
self.layout.addWidget(self.hue_slider)
self.layout.addWidget(self.label2)
self.layout.addWidget(self.chroma_slider)
self.layout.addWidget(self.label3)
self.layout.addWidget(self.lightness_slider)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update(self, label, value, slider_name):
label.setText(f"{slider_name}: {value}")
self.send_to_process({
'hue': self.hue_slider.value(),
'chroma': self.chroma_slider.value() / 100.0,
'lightness': self.lightness_slider.value() / 100.0
})
def process_image(self,image, values):
hue = values.get('hue', 0.0) / 2
chroma = values.get('chroma', 0.0)
lightness = values.get('lightness', 0.0)
image[..., 0] = (image[..., 0] + hue) % 180
image[..., 1] += (255 * lightness)
image[..., 1] = np.clip(image[..., 1], 0, 255)
image[..., 2] += (255 * chroma)
image[..., 2] = np.clip(image[..., 2], 0, 255)
return image
@classmethod
def dialog_name(cls):
return "Hue-Chroma-Lightness"

View File

@ -0,0 +1,53 @@
from PyQt6.QtWidgets import QDialog, QDialogButtonBox
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()
def process_image(self,image, values):
return image
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,131 @@
import numpy as np
import cv2
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, QCheckBox, QComboBox
from PyQt6.QtGui import QIntValidator
import ImageProcessingWorker
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()
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)
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)
self.auto_scale_checkbox = QCheckBox("Auto-scale")
self.auto_scale_checkbox.setChecked(True)
self.auto_scale_checkbox.stateChanged.connect(self.toggle_auto_scale)
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)
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()
def toggle_auto_scale(self):
if self.auto_scale_checkbox.isChecked():
self.adjust_height()
self.update()
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,87 @@
from PyQt6.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, QCheckBox, QSlider
from PyQt6.QtGui import QIntValidator
from PyQt6.QtCore import Qt
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
import numpy as np
import cv2
class RotationDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.ImageProcessingWorker)
self.setWindowTitle("Rotation")
self.layout = QVBoxLayout()
self.angle_label = QLabel("Angle:")
self.angle_field = QLineEdit()
self.angle_field.setText("0")
self.angle_field.setPlaceholderText("Enter rotation angle")
self.angle_field.setValidator(QIntValidator(-360, 360))
self.angle_field.textEdited.connect(self.angle_text_changed)
self.angle_slider = QSlider(Qt.Orientation.Horizontal)
self.angle_slider.setRange(-360, 360)
self.angle_slider.setTickInterval(90)
self.angle_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
self.angle_slider.valueChanged.connect(self.angle_slider_changed)
self.keep_size_checkbox = QCheckBox("Keep original size")
self.keep_size_checkbox.setChecked(True)
self.keep_size_checkbox.stateChanged.connect(self.update)
input_layout = QHBoxLayout()
input_layout.addWidget(self.angle_label)
input_layout.addWidget(self.angle_field)
self.layout.addLayout(input_layout)
self.layout.addWidget(self.angle_slider)
self.layout.addWidget(self.keep_size_checkbox)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def angle_text_changed(self, text):
if text:
angle = int(text)
self.angle_slider.setValue(angle)
self.update()
def angle_slider_changed(self, value):
self.angle_field.setText(str(value))
self.update()
def update(self):
angle = self.angle_field.text()
if not angle:
self.set_accept_enable(False)
return
self.send_to_process({
'angle': float(angle),
'keep_size': self.keep_size_checkbox.isChecked()
})
def process_image(self, image, values):
angle = values['angle']
keep_size = values['keep_size']
height, width = image.shape[:2]
center = (width // 2, height // 2)
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
if keep_size:
rotated_image = cv2.warpAffine(image, matrix, (width, height))
else:
cos = np.abs(matrix[0, 0])
sin = np.abs(matrix[0, 1])
new_width = int((height * sin) + (width * cos))
new_height = int((height * cos) + (width * sin))
matrix[0, 2] += (new_width / 2) - center[0]
matrix[1, 2] += (new_height / 2) - center[1]
rotated_image = cv2.warpAffine(image, matrix, (new_width, new_height))
self.set_accept_enable(True)
return rotated_image
@classmethod
def dialog_name(cls):
return "Rotate"

View File

@ -0,0 +1,40 @@
import numpy as np
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QSlider
from PyQt6.QtCore import Qt
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
class SaturationDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.HSVImageProcessingWorker)
self.setWindowTitle("Saturation Adjustment")
self.layout = QVBoxLayout()
self.saturation_slider = QSlider(Qt.Orientation.Horizontal)
self.saturation_slider.setRange(-100, 100)
self.label = QLabel("Saturation: 0")
self.saturation_slider.valueChanged.connect(lambda value: self.update(self.label, value))
self.layout.addWidget(self.label)
self.layout.addWidget(self.saturation_slider)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update(self, label, value):
label.setText(f"Saturation: {value}")
self.send_to_process({
'saturation': self.saturation_slider.value() / 100.0
})
def process_image(self, image, values):
saturation = values.get('saturation', 0.0)
image[..., 1] = np.clip(image[..., 1] * (1 + saturation), 0, 255)
return image
@classmethod
def dialog_name(cls):
return "Saturation"

View File

@ -0,0 +1,46 @@
from PyQt6.QtWidgets import QVBoxLayout, QLabel, QSlider
from PyQt6.QtCore import Qt
from .ImageParameterDialog import ImageParameterDialog
import ImageProcessingWorker
import numpy as np
class TemperatureAdjustmentDialog(ImageParameterDialog):
def __init__(self, image):
super().__init__(image, ImageProcessingWorker.RGBImageProcessingWorker)
self.setWindowTitle("Temperature Adjustment")
self.layout = QVBoxLayout()
self.temperature_slider = QSlider(Qt.Orientation.Horizontal)
self.temperature_slider.setRange(-500, 500)
self.temperature_slider.setValue(0)
self.temperature_slider.setTickInterval(10)
self.temperature_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
self.label = QLabel("Temperature: 0")
self.temperature_slider.valueChanged.connect(self.update_temperature)
self.layout.addWidget(self.label)
self.layout.addWidget(self.temperature_slider)
self.layout.addWidget(self.button_box)
self.setLayout(self.layout)
def update_temperature(self, value):
self.label.setText(f"Temperature: {value}")
self.send_to_process({'temperature': value})
def process_image(self, image, values):
temperature = values.get('temperature', 0)
increment = temperature * 0.1
image[..., 2] += increment * -1
image[..., 0] += increment
image = np.clip(image, 0, 255)
return image.astype(np.uint8)
@classmethod
def dialog_name(cls):
return "Temperature Adjustment"

View File

@ -0,0 +1,19 @@
from .HCLDialog import HCLDialog
from .ResizeDialog import ResizeDialog
from .SaturationDialog import SaturationDialog
from .BrightnessContrastDialog import BrightnessContrastDialog
from .ColorBalanceDialog import ColorBalanceDialog
from .TemperatureDialog import TemperatureAdjustmentDialog
from .RotationDialog import RotationDialog
from .FlipDialog import FlipDialog
DIALOGS = [
ColorBalanceDialog,
TemperatureAdjustmentDialog,
HCLDialog,
SaturationDialog,
BrightnessContrastDialog,
ResizeDialog,
RotationDialog,
FlipDialog
]

View File

@ -0,0 +1,23 @@
from setuptools import setup, find_packages
setup(
name="image-editor",
version="1.0.0",
packages=find_packages(),
install_requires=[
"numpy",
"opencv-python",
"PyQt6"
],
entry_points={
'console_scripts': [
'run-image-editor=editor.ImageEditor:main',
],
},
classifiers=[
"Programming Language :: Python :: 3",
"License :: MIT License",
"Operating System :: OS Independent",
],
python_requires='>=3.6',
)

View File

@ -0,0 +1,10 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
pkgs.python311Packages.numpy
pkgs.python311Packages.pyqt6
pkgs.python311Packages.opencv4
];
}