diff --git a/jezyki-skryptowe/image-editor/editor/.gitignore b/jezyki-skryptowe/image-editor/editor/.gitignore new file mode 100644 index 0000000..2643bb5 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/.gitignore @@ -0,0 +1,5 @@ +*.jpg +*.png +*.JPEG + +__pycache__ \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/DialogsPanel.py b/jezyki-skryptowe/image-editor/editor/DialogsPanel.py new file mode 100644 index 0000000..a52a911 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/DialogsPanel.py @@ -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() diff --git a/jezyki-skryptowe/image-editor/editor/ImageCanvas.py b/jezyki-skryptowe/image-editor/editor/ImageCanvas.py new file mode 100644 index 0000000..c5cb4d9 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/ImageCanvas.py @@ -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 diff --git a/jezyki-skryptowe/image-editor/editor/ImageEditor.py b/jezyki-skryptowe/image-editor/editor/ImageEditor.py new file mode 100644 index 0000000..b9ecece --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/ImageEditor.py @@ -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() diff --git a/jezyki-skryptowe/image-editor/editor/ImageManagePanel.py b/jezyki-skryptowe/image-editor/editor/ImageManagePanel.py new file mode 100644 index 0000000..a0f494a --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/ImageManagePanel.py @@ -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)}") diff --git a/jezyki-skryptowe/image-editor/editor/ImageManager.py b/jezyki-skryptowe/image-editor/editor/ImageManager.py new file mode 100644 index 0000000..83d10c6 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/ImageManager.py @@ -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 \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/ImageProcessingWorker.py b/jezyki-skryptowe/image-editor/editor/ImageProcessingWorker.py new file mode 100644 index 0000000..54fd2c7 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/ImageProcessingWorker.py @@ -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) diff --git a/jezyki-skryptowe/image-editor/editor/__init__.py b/jezyki-skryptowe/image-editor/editor/__init__.py new file mode 100755 index 0000000..d6712a9 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/__init__.py @@ -0,0 +1 @@ +from .image_editor import ImageEditor \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/BrightnessContrastDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/BrightnessContrastDialog.py new file mode 100644 index 0000000..bf3b207 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/BrightnessContrastDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/ColorBalanceDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/ColorBalanceDialog.py new file mode 100644 index 0000000..56f0318 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/ColorBalanceDialog.py @@ -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" diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/FlipDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/FlipDialog.py new file mode 100644 index 0000000..af51104 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/FlipDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/HCLDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/HCLDialog.py new file mode 100644 index 0000000..58e3187 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/HCLDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/ImageParameterDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/ImageParameterDialog.py new file mode 100644 index 0000000..8f93297 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/ImageParameterDialog.py @@ -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 \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/ResizeDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/ResizeDialog.py new file mode 100644 index 0000000..57b633b --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/ResizeDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/RotationDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/RotationDialog.py new file mode 100644 index 0000000..77ee8c9 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/RotationDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/SaturationDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/SaturationDialog.py new file mode 100644 index 0000000..5d2af01 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/SaturationDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/TemperatureDialog.py b/jezyki-skryptowe/image-editor/editor/dialogs/TemperatureDialog.py new file mode 100644 index 0000000..74f03f0 --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/TemperatureDialog.py @@ -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" \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/editor/dialogs/__init__.py b/jezyki-skryptowe/image-editor/editor/dialogs/__init__.py new file mode 100644 index 0000000..b33c37c --- /dev/null +++ b/jezyki-skryptowe/image-editor/editor/dialogs/__init__.py @@ -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 +] \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/setup.py b/jezyki-skryptowe/image-editor/setup.py new file mode 100644 index 0000000..085bf04 --- /dev/null +++ b/jezyki-skryptowe/image-editor/setup.py @@ -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', +) \ No newline at end of file diff --git a/jezyki-skryptowe/image-editor/shell.nix b/jezyki-skryptowe/image-editor/shell.nix new file mode 100644 index 0000000..6c7eab1 --- /dev/null +++ b/jezyki-skryptowe/image-editor/shell.nix @@ -0,0 +1,10 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = [ + pkgs.python311Packages.numpy + pkgs.python311Packages.pyqt6 + pkgs.python311Packages.opencv4 + ]; + +} \ No newline at end of file