shinestacker 0.4.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of shinestacker might be problematic. Click here for more details.

Files changed (59) hide show
  1. shinestacker/_version.py +1 -1
  2. shinestacker/algorithms/align.py +4 -12
  3. shinestacker/algorithms/balance.py +11 -9
  4. shinestacker/algorithms/depth_map.py +0 -30
  5. shinestacker/algorithms/utils.py +10 -0
  6. shinestacker/algorithms/vignetting.py +116 -70
  7. shinestacker/app/about_dialog.py +101 -12
  8. shinestacker/app/gui_utils.py +1 -1
  9. shinestacker/app/help_menu.py +1 -1
  10. shinestacker/app/main.py +2 -2
  11. shinestacker/app/project.py +2 -2
  12. shinestacker/config/constants.py +4 -1
  13. shinestacker/config/gui_constants.py +10 -9
  14. shinestacker/gui/action_config.py +5 -561
  15. shinestacker/gui/action_config_dialog.py +567 -0
  16. shinestacker/gui/base_form_dialog.py +18 -0
  17. shinestacker/gui/colors.py +5 -6
  18. shinestacker/gui/gui_logging.py +0 -1
  19. shinestacker/gui/gui_run.py +54 -106
  20. shinestacker/gui/ico/shinestacker.icns +0 -0
  21. shinestacker/gui/ico/shinestacker.ico +0 -0
  22. shinestacker/gui/ico/shinestacker.png +0 -0
  23. shinestacker/gui/ico/shinestacker.svg +60 -0
  24. shinestacker/gui/main_window.py +276 -367
  25. shinestacker/gui/menu_manager.py +236 -0
  26. shinestacker/gui/new_project.py +75 -20
  27. shinestacker/gui/project_converter.py +6 -6
  28. shinestacker/gui/project_editor.py +248 -165
  29. shinestacker/gui/project_model.py +2 -7
  30. shinestacker/gui/tab_widget.py +81 -0
  31. shinestacker/gui/time_progress_bar.py +95 -0
  32. shinestacker/retouch/base_filter.py +173 -40
  33. shinestacker/retouch/brush_preview.py +0 -10
  34. shinestacker/retouch/brush_tool.py +25 -11
  35. shinestacker/retouch/denoise_filter.py +5 -44
  36. shinestacker/retouch/display_manager.py +57 -20
  37. shinestacker/retouch/exif_data.py +10 -13
  38. shinestacker/retouch/file_loader.py +1 -1
  39. shinestacker/retouch/filter_manager.py +1 -4
  40. shinestacker/retouch/image_editor_ui.py +365 -49
  41. shinestacker/retouch/image_viewer.py +34 -11
  42. shinestacker/retouch/io_gui_handler.py +96 -43
  43. shinestacker/retouch/io_manager.py +23 -7
  44. shinestacker/retouch/layer_collection.py +2 -0
  45. shinestacker/retouch/shortcuts_help.py +12 -0
  46. shinestacker/retouch/unsharp_mask_filter.py +10 -10
  47. shinestacker/retouch/vignetting_filter.py +69 -0
  48. shinestacker/retouch/white_balance_filter.py +46 -14
  49. {shinestacker-0.4.0.dist-info → shinestacker-1.0.0.dist-info}/METADATA +14 -2
  50. shinestacker-1.0.0.dist-info/RECORD +90 -0
  51. shinestacker/app/app_config.py +0 -22
  52. shinestacker/gui/actions_window.py +0 -258
  53. shinestacker/retouch/image_editor.py +0 -201
  54. shinestacker/retouch/image_filters.py +0 -69
  55. shinestacker-0.4.0.dist-info/RECORD +0 -87
  56. {shinestacker-0.4.0.dist-info → shinestacker-1.0.0.dist-info}/WHEEL +0 -0
  57. {shinestacker-0.4.0.dist-info → shinestacker-1.0.0.dist-info}/entry_points.txt +0 -0
  58. {shinestacker-0.4.0.dist-info → shinestacker-1.0.0.dist-info}/licenses/LICENSE +0 -0
  59. {shinestacker-0.4.0.dist-info → shinestacker-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,42 +1,50 @@
1
- # pylint: disable=C0114, C0115, C0116, E0611, R0902, R0914, R0915
1
+ # pylint: disable=C0114, C0115, C0116, E0611, R0902, R0914, R0915, R0904
2
+ import numpy as np
2
3
  from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QFrame, QLabel,
3
- QListWidget, QSlider)
4
+ QListWidget, QSlider, QMainWindow, QMessageBox)
4
5
  from PySide6.QtGui import QShortcut, QKeySequence, QAction, QActionGroup
5
6
  from PySide6.QtCore import Qt
6
7
  from PySide6.QtGui import QGuiApplication
8
+ from .. config.constants import constants
7
9
  from .. config.gui_constants import gui_constants
8
- from .image_filters import ImageFilters
9
10
  from .image_viewer import ImageViewer
10
11
  from .shortcuts_help import ShortcutsHelp
11
12
  from .brush import Brush
12
-
13
-
14
- def brush_size_to_slider(size):
15
- if size <= gui_constants.BRUSH_SIZES['min']:
16
- return 0
17
- if size >= gui_constants.BRUSH_SIZES['max']:
18
- return gui_constants.BRUSH_SIZE_SLIDER_MAX
19
- normalized = ((size - gui_constants.BRUSH_SIZES['min']) /
20
- gui_constants.BRUSH_SIZES['max']) ** (1 / gui_constants.BRUSH_GAMMA)
21
- return int(normalized * gui_constants.BRUSH_SIZE_SLIDER_MAX)
22
-
23
-
24
- class ImageEditorUI(ImageFilters):
13
+ from .brush_tool import BrushTool
14
+ from .layer_collection import LayerCollectionHandler
15
+ from .undo_manager import UndoManager
16
+ from .layer_collection import LayerCollection
17
+ from .io_gui_handler import IOGuiHandler
18
+ from .display_manager import DisplayManager
19
+ from .filter_manager import FilterManager
20
+ from .denoise_filter import DenoiseFilter
21
+ from .unsharp_mask_filter import UnsharpMaskFilter
22
+ from .white_balance_filter import WhiteBalanceFilter
23
+ from .vignetting_filter import VignettingFilter
24
+
25
+
26
+ class ImageEditorUI(QMainWindow, LayerCollectionHandler):
25
27
  def __init__(self):
26
- super().__init__()
28
+ QMainWindow.__init__(self)
29
+ LayerCollectionHandler.__init__(self, LayerCollection())
30
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
31
+ self.undo_manager = UndoManager()
32
+ self.undo_action = None
33
+ self.redo_action = None
34
+ self.undo_manager.stack_changed.connect(self.update_undo_redo_actions)
35
+ self.io_gui_handler = None
36
+ self.display_manager = None
27
37
  self.brush = Brush()
28
- self.setup_ui()
29
- self.setup_menu()
30
- self.setup_shortcuts()
31
- self._dialog = None
32
-
33
- def setup_shortcuts(self):
34
- prev_layer = QShortcut(QKeySequence(Qt.Key_Up), self, context=Qt.ApplicationShortcut)
35
- prev_layer.activated.connect(self.prev_layer)
36
- next_layer = QShortcut(QKeySequence(Qt.Key_Down), self, context=Qt.ApplicationShortcut)
37
- next_layer.activated.connect(self.next_layer)
38
+ self.brush_tool = BrushTool()
39
+ self.modified = False
40
+ self.mask_layer = None
41
+ self.filter_manager = FilterManager(self)
42
+ self.filter_manager.register_filter("Denoise", DenoiseFilter)
43
+ self.filter_manager.register_filter("Unsharp Mask", UnsharpMaskFilter)
44
+ self.filter_manager.register_filter("White Balance", WhiteBalanceFilter)
45
+ self.filter_manager.register_filter("Vignetting Correction", VignettingFilter)
46
+ self.shortcuts_help_dialog = None
38
47
 
39
- def setup_ui(self):
40
48
  self.update_title()
41
49
  self.resize(1400, 900)
42
50
  center = QGuiApplication.primaryScreen().geometry().center()
@@ -69,6 +77,16 @@ class ImageEditorUI(ImageFilters):
69
77
 
70
78
  self.brush_size_slider = QSlider(Qt.Horizontal)
71
79
  self.brush_size_slider.setRange(0, gui_constants.BRUSH_SIZE_SLIDER_MAX)
80
+
81
+ def brush_size_to_slider(size):
82
+ if size <= gui_constants.BRUSH_SIZES['min']:
83
+ return 0
84
+ if size >= gui_constants.BRUSH_SIZES['max']:
85
+ return gui_constants.BRUSH_SIZE_SLIDER_MAX
86
+ normalized = ((size - gui_constants.BRUSH_SIZES['min']) /
87
+ gui_constants.BRUSH_SIZES['max']) ** (1 / gui_constants.BRUSH_GAMMA)
88
+ return int(normalized * gui_constants.BRUSH_SIZE_SLIDER_MAX)
89
+
72
90
  self.brush_size_slider.setValue(brush_size_to_slider(self.brush.size))
73
91
  brush_layout.addWidget(self.brush_size_slider)
74
92
 
@@ -125,18 +143,28 @@ class ImageEditorUI(ImageFilters):
125
143
  }
126
144
  """)
127
145
  master_label.setAlignment(Qt.AlignCenter)
128
- master_label.setFixedHeight(gui_constants.LABEL_HEIGHT)
146
+ master_label.setFixedHeight(gui_constants.UI_SIZES['label_height'])
129
147
  side_layout.addWidget(master_label)
130
148
  self.master_thumbnail_frame = QFrame()
149
+ self.master_thumbnail_frame.setObjectName("thumbnailContainer")
150
+ self.master_thumbnail_frame.setStyleSheet(
151
+ f"#thumbnailContainer{{ border: 2px solid {self.thumbnail_highlight}; }}")
131
152
  self.master_thumbnail_frame.setFrameShape(QFrame.StyledPanel)
132
153
  master_thumbnail_layout = QVBoxLayout(self.master_thumbnail_frame)
133
- master_thumbnail_layout.setContentsMargins(2, 2, 2, 2)
154
+ master_thumbnail_layout.setContentsMargins(8, 8, 8, 8)
134
155
  self.master_thumbnail_label = QLabel()
135
156
  self.master_thumbnail_label.setAlignment(Qt.AlignCenter)
136
- self.master_thumbnail_label.setFixedSize(
137
- gui_constants.THUMB_WIDTH, gui_constants.THUMB_HEIGHT)
157
+ self.master_thumbnail_label.setFixedWidth(
158
+ gui_constants.UI_SIZES['thumbnail_width'])
138
159
  self.master_thumbnail_label.mousePressEvent = \
139
160
  lambda e: self.display_manager.set_view_master()
161
+ self.master_thumbnail_label.setMouseTracking(True)
162
+
163
+ def label_clicked(event):
164
+ if event.button() == Qt.LeftButton:
165
+ self.toggle_view_master_individual()
166
+
167
+ self.master_thumbnail_label.mousePressEvent = label_clicked
140
168
  master_thumbnail_layout.addWidget(self.master_thumbnail_label)
141
169
  side_layout.addWidget(self.master_thumbnail_frame)
142
170
  side_layout.addSpacing(10)
@@ -152,7 +180,7 @@ class ImageEditorUI(ImageFilters):
152
180
  }
153
181
  """)
154
182
  layers_label.setAlignment(Qt.AlignCenter)
155
- layers_label.setFixedHeight(gui_constants.LABEL_HEIGHT)
183
+ layers_label.setFixedHeight(gui_constants.UI_SIZES['label_height'])
156
184
  side_layout.addWidget(layers_label)
157
185
  self.thumbnail_list = QListWidget()
158
186
  self.thumbnail_list.setFocusPolicy(Qt.StrongFocus)
@@ -202,20 +230,44 @@ class ImageEditorUI(ImageFilters):
202
230
  layout.addWidget(control_panel, 0)
203
231
  layout.setContentsMargins(0, 0, 0, 0)
204
232
  layout.setSpacing(2)
205
- super().setup_ui()
233
+ self.display_manager = DisplayManager(
234
+ self.layer_collection, self.image_viewer,
235
+ self.master_thumbnail_label, self.thumbnail_list, parent=self)
236
+ self.io_gui_handler = IOGuiHandler(self.layer_collection, self.undo_manager, parent=self)
237
+ self.display_manager.status_message_requested.connect(self.show_status_message)
238
+ self.display_manager.cursor_preview_state_changed.connect(
239
+ lambda state: setattr(self.image_viewer, 'allow_cursor_preview', state))
240
+ self.io_gui_handler.status_message_requested.connect(self.show_status_message)
241
+ self.io_gui_handler.update_title_requested.connect(self.update_title)
242
+ self.io_gui_handler.mark_as_modified_requested.connect(self.mark_as_modified)
243
+ self.io_gui_handler.change_layer_requested.connect(self.change_layer)
244
+ self.brush_tool.setup_ui(self.brush, self.brush_preview, self.image_viewer,
245
+ self.brush_size_slider, self.hardness_slider, self.opacity_slider,
246
+ self.flow_slider)
247
+ self.image_viewer.brush = self.brush_tool.brush
248
+ self.brush_tool.update_brush_thumb()
249
+ self.io_gui_handler.setup_ui(self.display_manager, self.image_viewer)
250
+ self.image_viewer.display_manager = self.display_manager
206
251
 
207
- def setup_menu(self):
208
252
  menubar = self.menuBar()
209
253
  file_menu = menubar.addMenu("&File")
210
254
  file_menu.addAction("&Open...", self.io_gui_handler.open_file, "Ctrl+O")
211
- file_menu.addAction("&Save", self.io_gui_handler.save_file, "Ctrl+S")
212
- file_menu.addAction("Save &As...", self.io_gui_handler.save_file_as, "Ctrl+Shift+S")
213
- self.save_master_only = QAction("Save Master &Only", self)
214
- self.save_master_only.setCheckable(True)
215
- self.save_master_only.setChecked(True)
216
- file_menu.addAction(self.save_master_only)
217
-
218
- file_menu.addAction("&Close", self.io_gui_handler.close_file, "Ctrl+W")
255
+ self.save_action = QAction("&Save", self)
256
+ self.save_action.setShortcut("Ctrl+S")
257
+ self.save_action.triggered.connect(self.io_gui_handler.save_file)
258
+ file_menu.addAction(self.save_action)
259
+ self.save_as_action = QAction("Save &As...", self)
260
+ self.save_as_action.setShortcut("Ctrl+Shift+S")
261
+ self.save_as_action.triggered.connect(self.io_gui_handler.save_file_as)
262
+ file_menu.addAction(self.save_as_action)
263
+ self.io_gui_handler.save_master_only = QAction("Save Master &Only", self)
264
+ self.io_gui_handler.save_master_only.setCheckable(True)
265
+ self.io_gui_handler.save_master_only.setChecked(True)
266
+ self.io_gui_handler.save_master_only.triggered.connect(self.save_master_only)
267
+ file_menu.addAction(self.io_gui_handler.save_master_only)
268
+ self.save_actions_set_enabled(False)
269
+
270
+ file_menu.addAction("&Close", self.close_file, "Ctrl+W")
219
271
  file_menu.addSeparator()
220
272
  file_menu.addAction("&Import frames", self.io_gui_handler.import_frames)
221
273
  file_menu.addAction("Import &EXIF data", self.io_gui_handler.select_exif_path)
@@ -271,15 +323,21 @@ class ImageEditorUI(ImageFilters):
271
323
 
272
324
  view_master_action = QAction("View Master", self)
273
325
  view_master_action.setShortcut("M")
274
- view_master_action.triggered.connect(self.display_manager.set_view_master)
326
+ view_master_action.triggered.connect(self.set_view_master)
275
327
  view_menu.addAction(view_master_action)
276
328
 
277
329
  view_individual_action = QAction("View Individual", self)
278
330
  view_individual_action.setShortcut("L")
279
- view_individual_action.triggered.connect(self.display_manager.set_view_individual)
331
+ view_individual_action.triggered.connect(self.set_view_individual)
280
332
  view_menu.addAction(view_individual_action)
281
333
  view_menu.addSeparator()
282
334
 
335
+ toggle_view_master_individual_action = QAction("View Individual", self)
336
+ toggle_view_master_individual_action.setShortcut("T")
337
+ toggle_view_master_individual_action.triggered.connect(self.toggle_view_master_individual)
338
+ view_menu.addAction(toggle_view_master_individual_action)
339
+ view_menu.addSeparator()
340
+
283
341
  sort_asc_action = QAction("Sort Layers A-Z", self)
284
342
  sort_asc_action.triggered.connect(lambda: self.sort_layers('asc'))
285
343
  view_menu.addAction(sort_asc_action)
@@ -327,16 +385,270 @@ class ImageEditorUI(ImageFilters):
327
385
  white_balance_action = QAction("White Balance", self)
328
386
  white_balance_action.triggered.connect(self.white_balance)
329
387
  filter_menu.addAction(white_balance_action)
388
+ vignetting_action = QAction("Vignetting correction", self)
389
+ vignetting_action.triggered.connect(self.vignetting_correction)
390
+ filter_menu.addAction(vignetting_action)
330
391
 
331
392
  help_menu = menubar.addMenu("&Help")
332
393
  help_menu.setObjectName("Help")
333
394
  shortcuts_help_action = QAction("Shortcuts and mouse", self)
334
- shortcuts_help_action.triggered.connect(self.shortcuts_help)
395
+
396
+ def shortcuts_help():
397
+ self.shortcuts_help_dialog = ShortcutsHelp(self)
398
+ self.shortcuts_help_dialog.exec()
399
+
400
+ shortcuts_help_action.triggered.connect(shortcuts_help)
335
401
  help_menu.addAction(shortcuts_help_action)
336
402
 
337
- def shortcuts_help(self):
338
- self._dialog = ShortcutsHelp(self)
339
- self._dialog.exec()
403
+ prev_layer = QShortcut(QKeySequence(Qt.Key_Up), self, context=Qt.ApplicationShortcut)
404
+ prev_layer.activated.connect(self.prev_layer)
405
+ next_layer = QShortcut(QKeySequence(Qt.Key_Down), self, context=Qt.ApplicationShortcut)
406
+ next_layer.activated.connect(self.next_layer)
407
+ self.installEventFilter(self)
408
+
409
+ def update_title(self):
410
+ title = constants.APP_TITLE
411
+ if self.io_gui_handler is not None:
412
+ path = self.io_gui_handler.current_file_path()
413
+ if path != '':
414
+ title += f" - {path.split('/')[-1]}"
415
+ if self.modified:
416
+ title += " *"
417
+ self.window().setWindowTitle(title)
418
+
419
+ def show_status_message(self, message):
420
+ self.statusBar().showMessage(message)
421
+
422
+ def mark_as_modified(self, value=True):
423
+ self.modified = value
424
+ self.save_actions_set_enabled(value)
425
+ self.update_title()
426
+
427
+ def check_unsaved_changes(self) -> bool:
428
+ if self.modified:
429
+ reply = QMessageBox.question(
430
+ self, "Unsaved Changes",
431
+ "The image stack has unsaved changes. Do you want to continue?",
432
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
433
+ )
434
+ if reply == QMessageBox.Save:
435
+ self.io_gui_handler.save_file()
436
+ return True
437
+ if reply == QMessageBox.Discard:
438
+ return True
439
+ return False
440
+ return True
441
+
442
+ # pylint: disable=C0103
443
+ def keyPressEvent(self, event):
444
+ if self.image_viewer.empty:
445
+ return
446
+ if event.text() == '[':
447
+ self.brush_tool.decrease_brush_size()
448
+ return
449
+ if event.text() == ']':
450
+ self.brush_tool.increase_brush_size()
451
+ return
452
+ if event.text() == '{':
453
+ self.brush_tool.decrease_brush_hardness()
454
+ return
455
+ if event.text() == '}':
456
+ self.brush_tool.increase_brush_hardness()
457
+ return
458
+ super().keyPressEvent(event)
459
+ # pylint: enable=C0103
460
+
461
+ def save_master_only(self, _checked):
462
+ self.update_title()
463
+
464
+ def sort_layers(self, order):
465
+ self.sort_layers(order)
466
+ self.display_manager.update_thumbnails()
467
+ self.change_layer(self.current_layer())
468
+
469
+ def change_layer(self, layer_idx):
470
+ if 0 <= layer_idx < self.number_of_layers():
471
+ view_state = self.image_viewer.get_view_state()
472
+ self.set_current_layer_idx(layer_idx)
473
+ self.display_manager.display_current_view()
474
+ self.image_viewer.set_view_state(view_state)
475
+ self.thumbnail_list.setCurrentRow(layer_idx)
476
+ self.thumbnail_list.setFocus()
477
+ self.image_viewer.update_brush_cursor()
478
+ self.image_viewer.setFocus()
479
+
480
+ def prev_layer(self):
481
+ if self.layer_stack() is not None:
482
+ new_idx = max(0, self.current_layer_idx() - 1)
483
+ if new_idx != self.current_layer_idx():
484
+ self.change_layer(new_idx)
485
+ self.display_manager.highlight_thumbnail(new_idx)
486
+
487
+ def next_layer(self):
488
+ if self.layer_stack() is not None:
489
+ new_idx = min(self.number_of_layers() - 1, self.current_layer_idx() + 1)
490
+ if new_idx != self.current_layer_idx():
491
+ self.change_layer(new_idx)
492
+ self.display_manager.highlight_thumbnail(new_idx)
493
+
494
+ def copy_layer_to_master(self):
495
+ if self.layer_stack() is None or self.master_layer() is None:
496
+ return
497
+ reply = QMessageBox.question(
498
+ self,
499
+ "Confirm Copy",
500
+ "Warning: the current master layer will be erased.\n\nDo you want to continue?",
501
+ QMessageBox.Yes | QMessageBox.No,
502
+ QMessageBox.No
503
+ )
504
+ if reply == QMessageBox.Yes:
505
+ self.set_master_layer(self.current_layer().copy())
506
+ self.master_layer().setflags(write=True)
507
+ self.display_manager.display_current_view()
508
+ self.display_manager.update_thumbnails()
509
+ self.mark_as_modified()
510
+ self.statusBar().showMessage(f"Copied layer {self.current_layer_idx() + 1} to master")
511
+
512
+ def copy_brush_area_to_master(self, view_pos):
513
+ if self.layer_stack() is None or self.number_of_layers() == 0 \
514
+ or not self.display_manager.allow_cursor_preview():
515
+ return
516
+ area = self.brush_tool.apply_brush_operation(
517
+ self.master_layer_copy(),
518
+ self.current_layer(),
519
+ self.master_layer(), self.mask_layer,
520
+ view_pos)
521
+ self.undo_manager.extend_undo_area(*area)
522
+
523
+ def begin_copy_brush_area(self, pos):
524
+ if self.display_manager.allow_cursor_preview():
525
+ self.mask_layer = self.io_gui_handler.blank_layer.copy()
526
+ self.copy_master_layer()
527
+ self.undo_manager.reset_undo_area()
528
+ self.copy_brush_area_to_master(pos)
529
+ self.display_manager.needs_update = True
530
+ if not self.display_manager.update_timer.isActive():
531
+ self.display_manager.update_timer.start()
532
+ self.mark_as_modified()
533
+
534
+ def continue_copy_brush_area(self, pos):
535
+ if self.display_manager.allow_cursor_preview():
536
+ self.copy_brush_area_to_master(pos)
537
+ self.display_manager.needs_update = True
538
+ if not self.display_manager.update_timer.isActive():
539
+ self.display_manager.update_timer.start()
540
+ self.mark_as_modified()
541
+
542
+ def end_copy_brush_area(self):
543
+ if self.display_manager.update_timer.isActive():
544
+ self.display_manager.display_master_layer()
545
+ self.display_manager.update_master_thumbnail()
546
+ self.undo_manager.save_undo_state(self.master_layer_copy(), 'Brush Stroke')
547
+ self.display_manager.update_timer.stop()
548
+ self.mark_as_modified()
549
+
550
+ def update_undo_redo_actions(self, has_undo, undo_desc, has_redo, redo_desc):
551
+ if self.undo_action:
552
+ if has_undo:
553
+ self.undo_action.setText(f"Undo {undo_desc}")
554
+ self.undo_action.setEnabled(True)
555
+ else:
556
+ self.undo_action.setText("Undo")
557
+ self.undo_action.setEnabled(False)
558
+ if self.redo_action:
559
+ if has_redo:
560
+ self.redo_action.setText(f"Redo {redo_desc}")
561
+ self.redo_action.setEnabled(True)
562
+ else:
563
+ self.redo_action.setText("Redo")
564
+ self.redo_action.setEnabled(False)
565
+
566
+ def denoise_filter(self):
567
+ self.filter_manager.apply("Denoise")
568
+
569
+ def unsharp_mask(self):
570
+ self.filter_manager.apply("Unsharp Mask")
571
+
572
+ def white_balance(self, init_val=None):
573
+ self.filter_manager.apply("White Balance", init_val=init_val or (128, 128, 128))
574
+
575
+ def vignetting_correction(self):
576
+ self.filter_manager.apply("Vignetting Correction")
577
+
578
+ def connect_preview_toggle(self, preview_check, do_preview, restore_original):
579
+ def on_toggled(checked):
580
+ if checked:
581
+ do_preview()
582
+ else:
583
+ restore_original()
584
+ preview_check.toggled.connect(on_toggled)
585
+
586
+ def get_pixel_color_at(self, pos, radius=None):
587
+ item_pos = self.image_viewer.position_on_image(pos)
588
+ x = int(item_pos.x())
589
+ y = int(item_pos.y())
590
+ master_layer = self.master_layer()
591
+ if (0 <= x < self.master_layer().shape[1]) and \
592
+ (0 <= y < self.master_layer().shape[0]):
593
+ if radius is None:
594
+ radius = int(self.brush.size)
595
+ if radius > 0:
596
+ y_indices, x_indices = np.ogrid[-radius:radius + 1, -radius:radius + 1]
597
+ mask = x_indices**2 + y_indices**2 <= radius**2
598
+ x0 = max(0, x - radius)
599
+ x1 = min(master_layer.shape[1], x + radius + 1)
600
+ y0 = max(0, y - radius)
601
+ y1 = min(master_layer.shape[0], y + radius + 1)
602
+ mask = mask[radius - (y - y0): radius + (y1 - y),
603
+ radius - (x - x0): radius + (x1 - x)]
604
+ region = master_layer[y0:y1, x0:x1]
605
+ if region.size == 0:
606
+ pixel = master_layer[y, x]
607
+ else:
608
+ if region.ndim == 3:
609
+ pixel = [region[:, :, c][mask].mean() for c in range(region.shape[2])]
610
+ else:
611
+ pixel = region[mask].mean()
612
+ else:
613
+ pixel = self.master_layer()[y, x]
614
+ if np.isscalar(pixel):
615
+ pixel = [pixel, pixel, pixel]
616
+ pixel = [np.float32(x) for x in pixel]
617
+ if master_layer.dtype == np.uint16:
618
+ pixel = [x / 256.0 for x in pixel]
619
+ return tuple(int(v) for v in pixel)
620
+ return (0, 0, 0)
621
+
622
+ def highlight_master_thumbnail(self):
623
+ self.master_thumbnail_frame.setStyleSheet(
624
+ f"#thumbnailContainer{{ border: 2px solid {self.thumbnail_highlight}; }}")
625
+
626
+ def save_actions_set_enabled(self, enabled):
627
+ self.save_action.setEnabled(enabled)
628
+ self.save_as_action.setEnabled(enabled)
629
+ self.io_gui_handler.save_master_only.setEnabled(enabled)
630
+
631
+ def close_file(self):
632
+ if self.check_unsaved_changes():
633
+ self.io_gui_handler.close_file()
634
+ self.set_master_layer(None)
635
+ self.mark_as_modified(False)
636
+
637
+ def set_view_master(self):
638
+ self.display_manager.set_view_master()
639
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
640
+ self.highlight_master_thumbnail()
641
+
642
+ def set_view_individual(self):
643
+ self.display_manager.set_view_individual()
644
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_LO_COLOR
645
+ self.highlight_master_thumbnail()
646
+
647
+ def toggle_view_master_individual(self):
648
+ if self.display_manager.view_mode == 'master':
649
+ self.set_view_individual()
650
+ else:
651
+ self.set_view_master()
340
652
 
341
653
  def toggle_fullscreen(self, checked):
342
654
  if checked:
@@ -365,8 +677,12 @@ class ImageEditorUI(ImageFilters):
365
677
  def handle_temp_view(self, start):
366
678
  if start:
367
679
  self.display_manager.start_temp_view()
680
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_LO_COLOR
681
+ self.highlight_master_thumbnail()
368
682
  else:
369
683
  self.display_manager.end_temp_view()
684
+ self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
685
+ self.highlight_master_thumbnail()
370
686
 
371
687
  def handle_brush_size_change(self, delta):
372
688
  if delta > 0:
@@ -1,7 +1,8 @@
1
1
  # pylint: disable=C0114, C0115, C0116, E0611, R0904, R0902, R0914, R0912
2
2
  import math
3
- from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem
4
- from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush, QCursor, QShortcut, QKeySequence
3
+ from PySide6.QtWidgets import (QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
4
+ QGraphicsEllipseItem)
5
+ from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QBrush, QCursor
5
6
  from PySide6.QtCore import Qt, QRectF, QTime, QPoint, QPointF, Signal, QEvent
6
7
  from .. config.gui_constants import gui_constants
7
8
  from .brush_preview import BrushPreviewItem
@@ -64,8 +65,9 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
64
65
  pixmap = QPixmap.fromImage(qimage)
65
66
  self.pixmap_item.setPixmap(pixmap)
66
67
  self.setSceneRect(QRectF(pixmap.rect()))
67
- img_width = pixmap.width()
68
- self.min_scale = gui_constants.MIN_ZOOMED_IMG_WIDTH / img_width
68
+ img_width, img_height = pixmap.width(), pixmap.height()
69
+ self.min_scale = min(gui_constants.MIN_ZOOMED_IMG_WIDTH / img_width,
70
+ gui_constants.MIN_ZOOMED_IMG_HEIGHT / img_height)
69
71
  self.max_scale = gui_constants.MAX_ZOOMED_IMG_PX_SIZE
70
72
  if self.zoom_factor == 1.0:
71
73
  self.fitInView(self.pixmap_item, Qt.KeepAspectRatio)
@@ -221,7 +223,6 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
221
223
  self.zoom_factor = new_scale
222
224
  self.update_brush_cursor()
223
225
  else: # Touchpad event - fallback for systems without gesture recognition
224
- # Handle touchpad panning (two-finger scroll)
225
226
  if not self.control_pressed:
226
227
  delta = event.pixelDelta() or event.angleDelta() / 8
227
228
  if delta:
@@ -318,6 +319,9 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
318
319
  self.setCursor(Qt.BlankCursor)
319
320
  pen = QPen(QColor(*gui_constants.BRUSH_COLORS['pen']), 1)
320
321
  brush = QBrush(QColor(*gui_constants.BRUSH_COLORS['cursor_inner']))
322
+ for item in self.scene.items():
323
+ if isinstance(item, QGraphicsEllipseItem):
324
+ self.scene.removeItem(item)
321
325
  self.brush_cursor = self.scene.addEllipse(
322
326
  0, 0, self.brush.size, self.brush.size, pen, brush)
323
327
  self.brush_cursor.setZValue(1000)
@@ -369,12 +373,6 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
369
373
  gui_constants.BRUSH_LINE_WIDTH / self.zoom_factor))
370
374
  self.brush_cursor.setBrush(QBrush(gradient))
371
375
 
372
- def setup_shortcuts(self):
373
- prev_layer = QShortcut(QKeySequence(Qt.Key_Up), self, context=Qt.ApplicationShortcut)
374
- prev_layer.activated.connect(self.prev_layer)
375
- next_layer = QShortcut(QKeySequence(Qt.Key_Down), self, context=Qt.ApplicationShortcut)
376
- next_layer.activated.connect(self.next_layer)
377
-
378
376
  def zoom_in(self):
379
377
  if self.empty:
380
378
  return
@@ -398,6 +396,11 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
398
396
  def reset_zoom(self):
399
397
  if self.empty:
400
398
  return
399
+ self.pinch_start_scale = 1.0
400
+ self.last_scroll_pos = QPointF()
401
+ self.gesture_active = False
402
+ self.pinch_center_view = None
403
+ self.pinch_center_scene = None
401
404
  self.fitInView(self.pixmap_item, Qt.KeepAspectRatio)
402
405
  self.zoom_factor = self.get_current_scale()
403
406
  self.zoom_factor = max(self.min_scale, min(self.max_scale, self.zoom_factor))
@@ -440,3 +443,23 @@ class ImageViewer(QGraphicsView, LayerCollectionHandler):
440
443
  scene_pos = self.mapToScene(pos)
441
444
  item_pos = self.pixmap_item.mapFromScene(scene_pos)
442
445
  return item_pos
446
+
447
+ def get_visible_image_region(self):
448
+ if self.empty:
449
+ return None
450
+ view_rect = self.viewport().rect()
451
+ scene_rect = self.mapToScene(view_rect).boundingRect()
452
+ image_rect = self.pixmap_item.mapFromScene(scene_rect).boundingRect()
453
+ image_rect = image_rect.intersected(self.pixmap_item.boundingRect().toRect())
454
+ return image_rect
455
+
456
+ def get_visible_image_portion(self):
457
+ if self.has_no_master_layer():
458
+ return None
459
+ visible_rect = self.get_visible_image_region()
460
+ if not visible_rect:
461
+ return self.master_layer()
462
+ x, y = int(visible_rect.x()), int(visible_rect.y())
463
+ w, h = int(visible_rect.width()), int(visible_rect.height())
464
+ master_img = self.master_layer()
465
+ return master_img[y:y + h, x:x + w], (x, y, w, h)