shinestacker 0.5.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 (57) 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 +37 -16
  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 +3 -4
  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 +275 -371
  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 +2 -5
  35. shinestacker/retouch/denoise_filter.py +5 -44
  36. shinestacker/retouch/exif_data.py +10 -13
  37. shinestacker/retouch/file_loader.py +1 -1
  38. shinestacker/retouch/filter_manager.py +1 -4
  39. shinestacker/retouch/image_editor_ui.py +318 -40
  40. shinestacker/retouch/image_viewer.py +34 -11
  41. shinestacker/retouch/io_gui_handler.py +34 -30
  42. shinestacker/retouch/layer_collection.py +2 -0
  43. shinestacker/retouch/shortcuts_help.py +12 -0
  44. shinestacker/retouch/unsharp_mask_filter.py +10 -10
  45. shinestacker/retouch/vignetting_filter.py +69 -0
  46. shinestacker/retouch/white_balance_filter.py +46 -14
  47. {shinestacker-0.5.0.dist-info → shinestacker-1.0.0.dist-info}/METADATA +14 -2
  48. shinestacker-1.0.0.dist-info/RECORD +90 -0
  49. shinestacker/app/app_config.py +0 -22
  50. shinestacker/gui/actions_window.py +0 -266
  51. shinestacker/retouch/image_editor.py +0 -197
  52. shinestacker/retouch/image_filters.py +0 -69
  53. shinestacker-0.5.0.dist-info/RECORD +0 -87
  54. {shinestacker-0.5.0.dist-info → shinestacker-1.0.0.dist-info}/WHEEL +0 -0
  55. {shinestacker-0.5.0.dist-info → shinestacker-1.0.0.dist-info}/entry_points.txt +0 -0
  56. {shinestacker-0.5.0.dist-info → shinestacker-1.0.0.dist-info}/licenses/LICENSE +0 -0
  57. {shinestacker-0.5.0.dist-info → shinestacker-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,43 +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):
28
+ QMainWindow.__init__(self)
29
+ LayerCollectionHandler.__init__(self, LayerCollection())
26
30
  self.thumbnail_highlight = gui_constants.THUMB_MASTER_HI_COLOR
27
- super().__init__()
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
28
37
  self.brush = Brush()
29
- self.setup_ui()
30
- self.setup_menu()
31
- self.setup_shortcuts()
32
- self._dialog = None
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
33
47
 
34
- def setup_shortcuts(self):
35
- prev_layer = QShortcut(QKeySequence(Qt.Key_Up), self, context=Qt.ApplicationShortcut)
36
- prev_layer.activated.connect(self.prev_layer)
37
- next_layer = QShortcut(QKeySequence(Qt.Key_Down), self, context=Qt.ApplicationShortcut)
38
- next_layer.activated.connect(self.next_layer)
39
-
40
- def setup_ui(self):
41
48
  self.update_title()
42
49
  self.resize(1400, 900)
43
50
  center = QGuiApplication.primaryScreen().geometry().center()
@@ -70,6 +77,16 @@ class ImageEditorUI(ImageFilters):
70
77
 
71
78
  self.brush_size_slider = QSlider(Qt.Horizontal)
72
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
+
73
90
  self.brush_size_slider.setValue(brush_size_to_slider(self.brush.size))
74
91
  brush_layout.addWidget(self.brush_size_slider)
75
92
 
@@ -141,6 +158,13 @@ class ImageEditorUI(ImageFilters):
141
158
  gui_constants.UI_SIZES['thumbnail_width'])
142
159
  self.master_thumbnail_label.mousePressEvent = \
143
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
144
168
  master_thumbnail_layout.addWidget(self.master_thumbnail_label)
145
169
  side_layout.addWidget(self.master_thumbnail_frame)
146
170
  side_layout.addSpacing(10)
@@ -206,13 +230,25 @@ class ImageEditorUI(ImageFilters):
206
230
  layout.addWidget(control_panel, 0)
207
231
  layout.setContentsMargins(0, 0, 0, 0)
208
232
  layout.setSpacing(2)
209
- 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
210
251
 
211
- def highlight_master_thumbnail(self):
212
- self.master_thumbnail_frame.setStyleSheet(
213
- f"#thumbnailContainer{{ border: 2px solid {self.thumbnail_highlight}; }}")
214
-
215
- def setup_menu(self):
216
252
  menubar = self.menuBar()
217
253
  file_menu = menubar.addMenu("&File")
218
254
  file_menu.addAction("&Open...", self.io_gui_handler.open_file, "Ctrl+O")
@@ -227,6 +263,7 @@ class ImageEditorUI(ImageFilters):
227
263
  self.io_gui_handler.save_master_only = QAction("Save Master &Only", self)
228
264
  self.io_gui_handler.save_master_only.setCheckable(True)
229
265
  self.io_gui_handler.save_master_only.setChecked(True)
266
+ self.io_gui_handler.save_master_only.triggered.connect(self.save_master_only)
230
267
  file_menu.addAction(self.io_gui_handler.save_master_only)
231
268
  self.save_actions_set_enabled(False)
232
269
 
@@ -295,6 +332,12 @@ class ImageEditorUI(ImageFilters):
295
332
  view_menu.addAction(view_individual_action)
296
333
  view_menu.addSeparator()
297
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
+
298
341
  sort_asc_action = QAction("Sort Layers A-Z", self)
299
342
  sort_asc_action.triggered.connect(lambda: self.sort_layers('asc'))
300
343
  view_menu.addAction(sort_asc_action)
@@ -342,21 +385,254 @@ class ImageEditorUI(ImageFilters):
342
385
  white_balance_action = QAction("White Balance", self)
343
386
  white_balance_action.triggered.connect(self.white_balance)
344
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)
345
391
 
346
392
  help_menu = menubar.addMenu("&Help")
347
393
  help_menu.setObjectName("Help")
348
394
  shortcuts_help_action = QAction("Shortcuts and mouse", self)
349
- 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)
350
401
  help_menu.addAction(shortcuts_help_action)
351
402
 
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
+
352
626
  def save_actions_set_enabled(self, enabled):
353
627
  self.save_action.setEnabled(enabled)
354
628
  self.save_as_action.setEnabled(enabled)
355
629
  self.io_gui_handler.save_master_only.setEnabled(enabled)
356
630
 
357
631
  def close_file(self):
358
- self.io_gui_handler.close_file()
359
- self.save_actions_set_enabled(False)
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)
360
636
 
361
637
  def set_view_master(self):
362
638
  self.display_manager.set_view_master()
@@ -368,9 +644,11 @@ class ImageEditorUI(ImageFilters):
368
644
  self.thumbnail_highlight = gui_constants.THUMB_MASTER_LO_COLOR
369
645
  self.highlight_master_thumbnail()
370
646
 
371
- def shortcuts_help(self):
372
- self._dialog = ShortcutsHelp(self)
373
- self._dialog.exec()
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()
374
652
 
375
653
  def toggle_fullscreen(self, checked):
376
654
  if checked:
@@ -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)
@@ -14,6 +14,8 @@ from .layer_collection import LayerCollectionHandler
14
14
  class IOGuiHandler(QObject, LayerCollectionHandler):
15
15
  status_message_requested = Signal(str)
16
16
  update_title_requested = Signal()
17
+ mark_as_modified_requested = Signal(bool)
18
+ change_layer_requested = Signal(int)
17
19
 
18
20
  def __init__(self, layer_collection, undo_manager, parent):
19
21
  QObject.__init__(self, parent)
@@ -54,16 +56,8 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
54
56
  self.set_master_layer(master_layer)
55
57
  self.undo_manager.reset()
56
58
  self.blank_layer = np.zeros(master_layer.shape[:2])
57
- self.display_manager.update_thumbnails()
58
- self.image_viewer.setup_brush_cursor()
59
- self.image_viewer.reset_zoom()
60
- self.status_message_requested.emit(f"Loaded: {self.current_file_path()}")
61
- self.update_title_requested.emit()
62
- self.current_file_path_master = ''
63
- self.current_file_path_multi = ''
64
- self.parent().mark_as_modified()
65
- self.parent().change_layer(0)
66
- self.parent().thumbnail_list.setFocus()
59
+ self.finish_loading_setup(stack, None, master_layer,
60
+ f"Loaded: {self.current_file_path()}")
67
61
 
68
62
  def on_file_error(self, error_msg):
69
63
  QApplication.restoreOverrideCursor()
@@ -71,6 +65,8 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
71
65
  self.loading_dialog.accept()
72
66
  self.loading_dialog.deleteLater()
73
67
  QMessageBox.critical(self.parent(), "Error", error_msg)
68
+ self.current_file_path_master = ''
69
+ self.current_file_path_multi = ''
74
70
  self.status_message_requested.emit(f"Error loading: {self.current_file_path()}")
75
71
 
76
72
  def on_multilayer_save_success(self):
@@ -78,7 +74,7 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
78
74
  self.saving_timer.stop()
79
75
  self.saving_dialog.hide()
80
76
  self.saving_dialog.deleteLater()
81
- self.parent().modified = False
77
+ self.mark_as_modified_requested.emit(False)
82
78
  self.update_title_requested.emit()
83
79
  self.status_message_requested.emit(f"Saved multilayer to: {self.current_file_path_multi}")
84
80
 
@@ -140,20 +136,30 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
140
136
  msg.setText(str(e))
141
137
  msg.exec()
142
138
  return
139
+ self.finish_loading_setup(stack, labels, master, "Selected frames imported")
140
+
141
+ def finish_loading_setup(self, stack, labels, master, message):
143
142
  if self.layer_stack() is None and len(stack) > 0:
144
143
  self.set_layer_stack(np.array(stack))
145
- self.set_layer_labels(labels)
144
+ if labels is None:
145
+ labels = self.layer_labels()
146
+ else:
147
+ self.set_layer_labels(labels)
146
148
  self.set_master_layer(master)
147
149
  self.blank_layer = np.zeros(master.shape[:2])
148
150
  else:
151
+ if labels is None:
152
+ labels = self.layer_labels()
149
153
  for img, label in zip(stack, labels):
150
154
  self.add_layer_label(label)
151
155
  self.add_layer(img)
152
- self.parent().mark_as_modified()
153
- self.parent().change_layer(0)
154
- self.image_viewer.reset_zoom()
155
- self.parent().thumbnail_list.setFocus()
156
156
  self.display_manager.update_thumbnails()
157
+ self.mark_as_modified_requested.emit(True)
158
+ self.change_layer_requested.emit(0)
159
+ self.image_viewer.setup_brush_cursor()
160
+ self.image_viewer.reset_zoom()
161
+ self.status_message_requested.emit(message)
162
+ self.update_title_requested.emit()
157
163
 
158
164
  def save_file(self):
159
165
  if self.save_master_only.isChecked():
@@ -239,7 +245,7 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
239
245
  try:
240
246
  self.io_manager.save_master(path)
241
247
  self.current_file_path_master = os.path.abspath(path)
242
- self.parent().modified = False
248
+ self.mark_as_modified_requested.emit(False)
243
249
  self.update_title_requested.emit()
244
250
  self.status_message_requested.emit(f"Saved master layer to: {path}")
245
251
  except Exception as e:
@@ -255,16 +261,14 @@ class IOGuiHandler(QObject, LayerCollectionHandler):
255
261
  self.exif_dialog.exec()
256
262
 
257
263
  def close_file(self):
258
- if self.parent().check_unsaved_changes():
259
- self.set_master_layer(None)
260
- self.blank_layer = None
261
- self.layer_collection.reset()
262
- self.current_file_path_master = ''
263
- self.current_file_path_multi = ''
264
- self.parent().modified = False
265
- self.undo_manager.reset()
266
- self.image_viewer.clear_image()
267
- self.display_manager.thumbnail_list.clear()
268
- self.display_manager.update_thumbnails()
269
- self.update_title_requested.emit()
270
- self.status_message_requested.emit("File closed")
264
+ self.mark_as_modified_requested.emit(False)
265
+ self.blank_layer = None
266
+ self.layer_collection.reset()
267
+ self.current_file_path_master = ''
268
+ self.current_file_path_multi = ''
269
+ self.undo_manager.reset()
270
+ self.image_viewer.clear_image()
271
+ self.display_manager.thumbnail_list.clear()
272
+ self.display_manager.update_thumbnails()
273
+ self.update_title_requested.emit()
274
+ self.status_message_requested.emit("File closed")
@@ -32,6 +32,8 @@ class LayerCollection:
32
32
  return self.master_layer_copy is None
33
33
 
34
34
  def number_of_layers(self):
35
+ if self.layer_stack is None:
36
+ return 0
35
37
  return len(self.layer_stack)
36
38
 
37
39
  def layer_label(self, i):
@@ -32,6 +32,7 @@ class ShortcutsHelp(QDialog):
32
32
  self.create_form(left_layout, right_layout)
33
33
  button_box = QHBoxLayout()
34
34
  ok_button = QPushButton("OK")
35
+ ok_button.setFixedWidth(100)
35
36
  ok_button.setFocus()
36
37
  button_box.addWidget(ok_button)
37
38
  self.layout.addLayout(button_box)
@@ -48,6 +49,7 @@ class ShortcutsHelp(QDialog):
48
49
  shortcuts = {
49
50
  "M": "show master layer",
50
51
  "L": "show selected layer",
52
+ "T": "toggle master/selected layer",
51
53
  "X": "temp. toggle between master and source layer",
52
54
  "↑": "select one layer up",
53
55
  "↓": "selcet one layer down",
@@ -80,3 +82,13 @@ class ShortcutsHelp(QDialog):
80
82
  self.add_bold_label(right_layout, "Mouse Controls")
81
83
  for k, v in mouse_controls.items():
82
84
  right_layout.addRow(f"<b>{k}</b>", QLabel(v))
85
+
86
+ touchpad_controls = {
87
+ "Two fingers": "pan",
88
+ "Pinch": "zoom in/out",
89
+ "Ctrl + two fingers": "zoom in/out",
90
+ }
91
+ self.add_bold_label(right_layout, " ")
92
+ self.add_bold_label(right_layout, "Touchpad Controls")
93
+ for k, v in touchpad_controls.items():
94
+ right_layout.addRow(f"<b>{k}</b>", QLabel(v))