Skip to content

API Reference

Application

App Module

find_and_import_subclass(file_path, base_class_name)

Find and import the first subclass of a given base class in a Python file.

Parameters:

Name Type Description Default
file_path str

The path to the Python file to inspect.

required
base_class_name str

The name of the base class to look for subclasses of.

required

Returns:

Name Type Description
type

The first subclass found, or None if no subclass is found.

Source code in imagebaker/window/app.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def find_and_import_subclass(file_path: str, base_class_name: str):
    """
    Find and import the first subclass of a given base class in a Python file.

    Args:
        file_path (str): The path to the Python file to inspect.
        base_class_name (str): The name of the base class to look for subclasses of.

    Returns:
        type: The first subclass found, or None if no subclass is found.
    """
    with open(file_path, "r") as file:
        tree = ast.parse(file.read(), filename=file_path)

    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            for base in node.bases:
                if isinstance(base, ast.Name) and base.id == base_class_name:
                    # Dynamically import the file and return the class
                    module_name = Path(file_path).stem
                    spec = importlib.util.spec_from_file_location(
                        module_name, file_path
                    )
                    module = importlib.util.module_from_spec(spec)
                    spec.loader.exec_module(module)
                    return getattr(module, node.name)
    return None

load_models(file_path)

Dynamically load the LOADED_MODELS object from the specified file.

Source code in imagebaker/window/app.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def load_models(file_path: str):
    """Dynamically load the LOADED_MODELS object from the specified file."""
    try:
        # Ensure the file path is absolute
        file_path = Path(file_path).resolve()

        # Execute the file and return its global variables
        loaded_globals = runpy.run_path(str(file_path))
    except Exception as e:
        logger.error(f"Failed to load models from {file_path}: {e}")
        return {}

    # Ensure LOADED_MODELS exists in the loaded context
    if "LOADED_MODELS" not in loaded_globals:
        logger.warning(f"No LOADED_MODELS object found in {file_path}.")
        return {}

    return loaded_globals.get("LOADED_MODELS", {})

run(models_file=typer.Option('loaded_models.py', help='Path to the Python file defining LOADED_MODELS.'), project_dir=typer.Option('.', help='The project directory to use for the application.'), configs_file=typer.Option('imagebaker/core/configs.py', help='The Python file to search for LayerConfig and CanvasConfig subclasses.'))

Run the ImageBaker application.

Parameters:

Name Type Description Default
models_file str

Path to the Python file defining LOADED_MODELS.

Option('loaded_models.py', help='Path to the Python file defining LOADED_MODELS.')
project_dir str

The project directory to use for the application.

Option('.', help='The project directory to use for the application.')
configs_file str

The Python file to search for LayerConfig and CanvasConfig subclasses.

Option('imagebaker/core/configs.py', help='The Python file to search for LayerConfig and CanvasConfig subclasses.')
Source code in imagebaker/window/app.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@app_cli.command()
def run(
    models_file: str = typer.Option(
        "loaded_models.py", help="Path to the Python file defining LOADED_MODELS."
    ),
    project_dir: str = typer.Option(
        ".", help="The project directory to use for the application."
    ),
    configs_file: str = typer.Option(
        "imagebaker/core/configs.py",
        help="The Python file to search for LayerConfig and CanvasConfig subclasses.",
    ),
):
    """
    Run the ImageBaker application.

    Args:
        models_file (str): Path to the Python file defining LOADED_MODELS.
        project_dir (str): The project directory to use for the application.
        configs_file (str): The Python file to search for LayerConfig and CanvasConfig subclasses.
    """
    models_file_path = Path(models_file)
    if not models_file_path.is_file():
        logger.warning(f"Models file not found: {models_file_path}")
        LOADED_MODELS = {None: None}
    else:
        LOADED_MODELS = load_models(models_file_path)

    configs_file_path = Path(configs_file)
    if not configs_file_path.is_file():
        logger.warning(f"Configs file not found: {configs_file_path}")
        layer_config_class = None
        canvas_config_class = None
    else:
        # Find and import subclasses of LayerConfig and CanvasConfig
        layer_config_class = find_and_import_subclass(configs_file_path, "LayerConfig")
        canvas_config_class = find_and_import_subclass(
            configs_file_path, "CanvasConfig"
        )

    # Use the imported subclass if found, or fall back to the default
    if layer_config_class:
        logger.info(f"Using LayerConfig subclass: {layer_config_class.__name__}")
        layer_config = layer_config_class()
    else:
        logger.info("No LayerConfig subclass found. Using default LayerConfig.")
        layer_config = LayerConfig(project_dir=project_dir)

    if canvas_config_class:
        logger.info(f"Using CanvasConfig subclass: {canvas_config_class.__name__}")
        canvas_config = canvas_config_class()
    else:
        logger.info("No CanvasConfig subclass found. Using default CanvasConfig.")
        canvas_config = CanvasConfig(project_dir=project_dir)

    main(layer_config, canvas_config, LOADED_MODELS)

Tabs

Layerify Tab

Bases: QWidget

Layerify Tab implementation

Source code in imagebaker/tabs/layerify_tab.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
class LayerifyTab(QWidget):
    """Layerify Tab implementation"""

    annotationRemoved = Signal(Annotation)
    layerAdded = Signal(AnnotableLayer)
    clearAnnotations = Signal()
    messageSignal = Signal(str)
    annotationAdded = Signal(Annotation)
    annotationUpdated = Signal(Annotation)
    gotToTab = Signal(int)

    def __init__(
        self,
        main_window,
        config: LayerConfig,
        canvas_config: CanvasConfig,
        loaded_models,
    ):
        """
        A tab for layerifying annotations and managing multiple layers.

        Args:
            main_window: The main window instance.
            config: LayerConfig instance with settings for the tab.
            canvas_config: CanvasConfig instance with settings for the canvas.
            loaded_models: Dictionary of loaded models.
        """
        super().__init__(parent=main_window)

        self.setFocusPolicy(Qt.StrongFocus)
        self.main_window = main_window
        self.config = config
        self.canvas_config = canvas_config
        self.main_layout = QVBoxLayout(self)

        self.all_models = loaded_models
        self.current_model = list(self.all_models.values())[0]
        self.current_label = self.config.default_label.name
        self.image_entries = []
        self.curr_image_idx = 0
        self.processed_images = set()
        self.annotable_layers: Deque[AnnotableLayer] = deque(
            maxlen=self.config.deque_maxlen
        )
        self.baked_results: Deque[AnnotableLayer] = deque(
            maxlen=self.config.deque_maxlen
        )
        self.layer = None
        self.init_ui()
        self._connect_signals()

    def _connect_signals(self):
        """Connect all necessary signals"""
        # Connect all layers in the deque to annotation list
        for layer in self.annotable_layers:
            layer.annotationAdded.connect(self.annotation_list.update_list)
            layer.annotationUpdated.connect(self.annotation_list.update_list)
            layer.messageSignal.connect(self.messageSignal)
            layer.layerSignal.connect(self.add_layer)

        # Connect image list panel signals
        self.image_list_panel.imageSelected.connect(self.on_image_selected)

    def init_ui(self):
        """Initialize the UI components"""
        # Create annotation list and image list panel
        self.annotation_list = AnnotationList(
            None, parent=self.main_window, max_name_length=self.config.max_name_length
        )
        self.image_list_panel = ImageListPanel(
            self.image_entries, self.processed_images
        )

        self.main_window.addDockWidget(Qt.LeftDockWidgetArea, self.image_list_panel)

        # Add multiple layers (canvas) to the main layout
        for _ in range(self.annotable_layers.maxlen):
            layer = AnnotableLayer(
                parent=self.main_window,
                config=self.config,
                canvas_config=self.canvas_config,
            )
            layer.setVisible(False)  # Initially hide all layers
            self.annotable_layers.append(layer)
            self.main_layout.addWidget(layer)

        # Set the annotation list to the first layer by default
        if self.annotable_layers:
            self.layer = self.annotable_layers[0]
            self.layer.set_mode(MouseMode.RECTANGLE)
            self.annotation_list.layer = self.layer

        self.create_toolbar()

        # Create a dock widget for the toolbar
        self.toolbar_dock = QDockWidget("Tools", self)
        self.toolbar_dock.setWidget(self.toolbar)
        self.toolbar_dock.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )
        self.main_window.addDockWidget(Qt.BottomDockWidgetArea, self.toolbar_dock)

        # Add annotation list to main window's docks
        self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.annotation_list)
        self.load_default_images()

    def on_image_selected(self, image_entry: ImageEntry):
        """Handle image selection from the image list panel."""
        logger.info(f"Image selected: {image_entry}")

        # Hide all layers first
        for idx, layer in enumerate(self.annotable_layers):
            layer.setVisible(False)
            # logger.info(f"Layer {idx} hidden.")

        if not image_entry.is_baked_result:  # Regular image
            image_path = image_entry.data
            self.curr_image_idx = self.image_entries.index(image_entry)

            # Make the corresponding layer visible and set the image
            selected_layer = self.annotable_layers[self.curr_image_idx]
            selected_layer.setVisible(True)
            # logger.info(f"Layer {self.curr_image_idx} made visible for regular image.")
            selected_layer.set_image(image_path)  # Set the selected image
            if self.layer:
                selected_layer.set_mode(self.layer.mouse_mode)
            self.layer = selected_layer  # Update the currently selected layer

        else:  # Baked result
            baked_result_layer = image_entry.data
            self.curr_image_idx = self.image_entries.index(image_entry)

            # Make the baked result layer visible
            baked_result_layer.setVisible(True)
            # logger.info(f"Layer {self.curr_image_idx} made visible for baked result.")
            self.layer = baked_result_layer  # Set the baked result as the current layer

        self.annotation_list.layer = self.layer
        self.annotation_list.update_list()

        self.messageSignal.emit(
            f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
        )
        self.update()

    def load_default_images(self):
        """Load the first set of images as the default."""
        # If no images are loaded, try to load from the assets folder
        if not self.image_entries:
            assets_folder = self.config.assets_folder
            if assets_folder.exists() and assets_folder.is_dir():
                self._load_images_from_folder(assets_folder)

        # Load images into layers if any are found
        if self.image_entries:
            for i, layer in enumerate(self.annotable_layers):
                if i < len(self.image_entries):
                    layer.set_image(self.image_entries[i].data)
                    layer.setVisible(
                        i == 0
                    )  # Only the first layer is visible by default
                    if i == 0:
                        self.layer = layer  # Set the first layer as the current layer
                else:
                    layer.setVisible(False)

            self.messageSignal.emit(f"Showing image 1/{len(self.image_entries)}")
        else:
            # If no images are found, log a message
            logger.warning("No images found in the assets folder.")
            self.messageSignal.emit("No images found in the assets folder.")

        # Update the image list panel
        self.image_list_panel.update_image_list(self.image_entries)
        self.update()

    def clear_annotations(self):
        """Safely clear all annotations"""
        try:
            # Clear layer annotations
            self.clearAnnotations.emit()
            self.messageSignal.emit("Annotations cleared")

        except Exception as e:
            logger.error(f"Clear error: {str(e)}")
            self.messageSignal.emit(f"Error clearing: {str(e)}")

    def on_annotation_added(self, annotation: Annotation):
        """Handle annotation added event

        Args:
            annotation (Annotation): The annotation that was added.
        """
        if annotation.label not in self.config.predefined_labels:
            self.config.predefined_labels.append(
                Label(annotation.label, annotation.color)
            )
            self.update_label_combo()
        logger.info(f"Added annotation: {annotation.label}")
        self.messageSignal.emit(f"Added annotation: {annotation.label}")

        # Refresh the annotation list
        self.annotation_list.update_list()

    def on_annotation_updated(self, annotation: Annotation):
        """
        A slot to handle the annotation updated signal.

        Args:
            annotation (Annotation): The updated annotation.
        """
        logger.info(f"Updated annotation: {annotation.label}")
        self.messageSignal.emit(f"Updated annotation: {annotation.label}")

        # Refresh the annotation list
        self.annotation_list.update_list()

    def update_label_combo(self):
        """
        Add predefined labels to the label combo box.

        This method is called when a new label is added.
        """
        self.label_combo.clear()
        for label in self.config.predefined_labels:
            pixmap = QPixmap(16, 16)
            pixmap.fill(label.color)
            self.label_combo.addItem(QIcon(pixmap), label.name)

    def load_default_image(self):
        """
        Load a default image from the assets folder.
        """
        default_path = self.config.assets_folder / "desk.png"
        if not default_path.exists():
            default_path, _ = QFileDialog.getOpenFileName()
            default_path = Path(default_path)

        if default_path.exists():
            self.layer.set_image(default_path)

    def handle_predict(self):
        """
        Handle the predict button click event.

        """
        if self.current_model is None:
            logger.warning("No model selected to predict")
            self.messageSignal.emit("No model selected/or loaded to predict")
            return
        # get image as an numpy array from canvas
        image = qpixmap_to_numpy(self.layer.image)
        if image is None:
            return
        # get annotations from canvas
        annotations = [
            ann
            for ann in self.layer.annotations
            if not ann.is_model_generated and ann.visible
        ]

        if len(annotations) == 0:
            logger.warning("No annotations to predict passing image to model")
            self.messageSignal.emit("No annotations to predict passing image to model")
            # return

        points = []
        polygons = []
        rectangles = []
        label_hints = []
        for ann in annotations:
            if ann.points:
                points.append([[p.x(), p.y()] for p in ann.points])
            if ann.polygon:
                polygons.append([[p.x(), p.y()] for p in ann.polygon])
            if ann.rectangle:
                rectangles.append(
                    [
                        ann.rectangle.x(),
                        ann.rectangle.y(),
                        ann.rectangle.x() + ann.rectangle.width(),
                        ann.rectangle.y() + ann.rectangle.height(),
                    ]
                )
            label_hints.append([0])
            ann.visible = False

        points = points if len(points) > 0 else None
        polygons = polygons if len(polygons) > 0 else None
        rectangles = [rectangles] if len(rectangles) > 0 else None
        label_hints = label_hints if len(label_hints) > 0 else None

        self.loading_dialog = QProgressDialog(
            "Processing annotation...",
            "Cancel",  # Optional cancel button
            0,
            0,
            self.parentWidget(),  # Or your main window reference
        )
        self.loading_dialog.setWindowTitle("Please Wait")
        self.loading_dialog.setWindowModality(Qt.WindowModal)
        self.loading_dialog.setCancelButton(None)  # Remove cancel button if not needed
        self.loading_dialog.show()

        # Force UI update
        QApplication.processEvents()

        # Setup worker thread
        self.worker_thread = QThread()
        self.worker = ModelPredictionWorker(
            self.current_model, image, points, polygons, rectangles, label_hints
        )
        self.worker.moveToThread(self.worker_thread)

        # Connect signals
        self.worker_thread.started.connect(self.worker.process)
        self.worker.finished.connect(self.handle_model_result)
        self.worker.finished.connect(self.worker_thread.quit)
        self.worker.error.connect(self.handle_model_error)

        # Cleanup connections
        self.worker.finished.connect(self.worker.deleteLater)
        self.worker_thread.finished.connect(self.worker_thread.deleteLater)
        self.worker_thread.finished.connect(self.loading_dialog.close)

        # Start processing
        self.worker_thread.start()

    def handle_model_result(self, predictions: list[PredictionResult]):
        """
        A slot to handle the model prediction results.

        Args:
            predictions (list[PredictionResult]): The list of prediction results.
        """
        # update canvas with predictions
        for prediction in predictions:
            if prediction.class_name not in self.config.predefined_labels:
                self.config.predefined_labels.append(Label(prediction.class_name))
                self.update_label_combo()
            if prediction.rectangle:
                # make sure the returned rectangle is within the image

                self.layer.annotations.append(
                    Annotation(
                        annotation_id=len(self.layer.annotations),
                        label=prediction.class_name,
                        color=self.config.get_label_color(prediction.class_name)
                        or QColor(255, 255, 255),
                        rectangle=QRectF(*prediction.rectangle),
                        is_complete=True,
                        score=prediction.score,
                        annotator=self.current_model.name,
                        annotation_time=str(
                            prediction.annotation_time
                            if prediction.annotation_time
                            else ""
                        ),
                        file_path=self.layer.file_path,
                    )
                )
            elif prediction.polygon is not None:

                self.layer.annotations.append(
                    Annotation(
                        annotation_id=len(self.layer.annotations),
                        label=prediction.class_name,
                        color=self.config.get_label_color(prediction.class_name)
                        or QColor(255, 255, 255),
                        polygon=QPolygonF([QPointF(*p) for p in prediction.polygon]),
                        is_complete=True,
                        score=prediction.score,
                        annotator=self.current_model.name,
                        annotation_time=str(prediction.annotation_time),
                        file_path=self.layer.file_path,
                    )
                )
            else:
                # points as center of canvas
                x, y = self.layer.width() // 2, self.layer.height() // 2
                self.layer.annotations.append(
                    Annotation(
                        annotation_id=len(self.layer.annotations),
                        label=prediction.class_name,
                        color=self.config.get_label_color(prediction.class_name)
                        or QColor(255, 255, 255),
                        points=[QPointF(x, y)],
                        is_complete=True,
                        score=prediction.score,
                        annotator=self.current_model.name,
                        annotation_time=str(prediction.annotation_time),
                        file_path=self.layer.file_path,
                    )
                )

        self.layer.update()
        self.annotation_list.update_list()
        self.update_annotation_list()

    def handle_model_change(self, index):
        """
        Handle the model change event.

        Args:
            index (int): The index of the selected model.
        """
        model_name = self.model_combo.currentText()
        self.current_model = self.all_models[model_name]
        msg = f"Model changed to {model_name}"
        logger.info(msg)
        self.messageSignal.emit(msg)

    def handle_model_error(self, error):
        logger.error(f"Model error: {error}")
        QMessageBox.critical(self, "Error", f"Model error: {error}")
        self.loading_dialog.close()

    def save_annotations(self):
        """Save annotations to a JSON file."""
        if not self.layer.annotations:
            QMessageBox.warning(self, "Warning", "No annotations to save!")
            return

        options = QFileDialog.Options()
        file_name, _ = QFileDialog.getSaveFileName(
            self, "Save Annotations", "", "JSON Files (*.json)", options=options
        )

        if file_name:
            try:
                Annotation.save_as_json(self.layer.annotations, file_name)

                QMessageBox.information(
                    self, "Success", "Annotations saved successfully!"
                )

            except Exception as e:
                QMessageBox.critical(
                    self, "Error", f"Failed to save annotations: {str(e)}"
                )

    def load_annotations(self):
        """
        Load annotations from a JSON file.
        """
        options = QFileDialog.Options()
        file_name, _ = QFileDialog.getOpenFileName(
            self, "Load Annotations", "", "JSON Files (*.json)", options=options
        )

        if file_name:
            try:
                self.layer.annotations = Annotation.load_from_json(file_name)
                self.layer.update()
                self.update_annotation_list()
                QMessageBox.information(
                    self, "Success", "Annotations loaded successfully!"
                )

            except Exception as e:
                QMessageBox.critical(
                    self, "Error", f"Failed to load annotations: {str(e)}"
                )
                self.layer.annotations = []
                self.layer.update()

    def update_annotation_list(self):
        """Update the annotation list with the current annotations."""
        self.annotation_list.update_list()

    def choose_color(self):
        """Choose a color for the current label."""
        current_label = self.label_combo.currentText()
        label_info = next(
            (
                label
                for label in self.config.predefined_labels
                if label.name == current_label
            ),
            None,
        )

        if label_info:
            color = QColorDialog.getColor(label_info.color)
            if color.isValid():
                # Update label color
                label_info.color = color
                # Update combo box display
                index = self.label_combo.currentIndex()
                pixmap = QPixmap(16, 16)
                pixmap.fill(color)
                self.label_combo.setItemIcon(index, QIcon(pixmap))
                # Update canvas color
                self.layer.current_color = color
                self.layer.update()
        for annotation in self.layer.annotations:
            if annotation.label == current_label:
                annotation.color = color
                self.layer.update()

    def add_new_label(self):
        """Add a new label to the predefined labels."""
        name, ok = QInputDialog.getText(self, "New Label", "Enter label name:")
        if not ok or not name:
            return

        # Check for existing label
        existing_names = [label.name for label in self.config.predefined_labels]
        if name in existing_names:
            QMessageBox.warning(self, "Duplicate", "Label name already exists!")
            return

        color = QColorDialog.getColor()
        if not color.isValid():
            return

        # Add new predefined label
        self.config.predefined_labels.append(Label(name=name, color=color))

        # Update combo box
        self.update_label_combo()

        # Select the new label
        index = self.label_combo.findText(name)
        self.label_combo.setCurrentIndex(index)

    def handle_label_change(self, index):
        """Handle the label change event."""
        label_info = self.config.predefined_labels[index]
        self.current_label = label_info.name
        # sort the labels by putting selected label on top
        self.config.predefined_labels.remove(label_info)
        self.config.predefined_labels.insert(0, label_info)
        # self.update_label_combo()

        self.layer.current_color = label_info.color
        self.layer.current_label = (
            self.current_label if self.current_label != "Custom" else None
        )
        msg = f"Label changed to {self.current_label}"
        self.messageSignal.emit(msg)
        self.layer.update()
        self.update()

    def add_layer(self, layer):
        """Add a new layer to the tab."""
        # this layer i.e. canvas will have only one annotation
        logger.info(f"AnnotableLayer added: {layer.annotations[0].label}")
        self.layerAdded.emit(layer)

        self.layer.update()

    def layerify_all(self):
        """Layerify all annotations in the current layer."""
        if len(self.layer.annotations) == 0:
            logger.warning("No annotations to layerify")
            self.messageSignal.emit("No annotations to layerify")

            return
        logger.info("Layerifying all annotations")

        # else appends already added too
        self.layer.layerify_annotation(self.layer.annotations)

    def create_toolbar(self):
        """Create Layerify-specific toolbar"""
        self.toolbar = QWidget()
        toolbar_layout = QHBoxLayout(self.toolbar)

        modes = [
            ("📍", "Point", lambda x: self.layer.set_mode(MouseMode.POINT)),
            ("🔷", "Polygon", lambda x: self.layer.set_mode(MouseMode.POLYGON)),
            ("🔳", "Rectangle", lambda x: self.layer.set_mode(MouseMode.RECTANGLE)),
            ("⏳", "Idle", lambda x: self.layer.set_mode(MouseMode.IDLE)),
            ("💾", "Annotations", self.save_annotations),
            ("📂", "Annotations", self.load_annotations),
            ("🔮", "Predict", self.handle_predict),
            ("🎨", "Color", self.choose_color),
            ("🧅", "Layerify All", self.layerify_all),
            ("🏷️", "Add Label", self.add_new_label),
            ("🗑️", "Clear", lambda x: self.clearAnnotations.emit()),
        ]

        # Folder navigation buttons
        self.select_folder_btn = QPushButton("Select Folder")
        self.select_folder_btn.clicked.connect(self.select_folder)
        toolbar_layout.addWidget(self.select_folder_btn)

        self.next_image_btn = QPushButton("Next")
        self.next_image_btn.clicked.connect(self.show_next_image)
        toolbar_layout.addWidget(self.next_image_btn)

        self.prev_image_btn = QPushButton("Prev")
        self.prev_image_btn.clicked.connect(self.show_prev_image)
        toolbar_layout.addWidget(self.prev_image_btn)

        # Initially hide next/prev buttons
        self.next_image_btn.setVisible(False)
        self.prev_image_btn.setVisible(False)

        # Add mode buttons
        for icon, text, mode in modes:
            btn_txt = icon + text
            btn = QPushButton(btn_txt)
            btn.setToolTip(btn_txt)
            btn.setMaximumWidth(80)
            if isinstance(mode, MouseMode):
                btn.clicked.connect(lambda _, m=mode: self.layer.set_mode(m))
            else:
                btn.clicked.connect(mode)
            toolbar_layout.addWidget(btn)

        # Add spacer
        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        toolbar_layout.addWidget(spacer)

        # Label and Model dropdowns
        self.label_combo = QComboBox()
        self.label_combo.setStyleSheet("QComboBox { min-width: 120px; }")
        for label in self.config.predefined_labels:
            pixmap = QPixmap(16, 16)
            pixmap.fill(label.color)
            self.label_combo.addItem(QIcon(pixmap), label.name)
        self.label_combo.currentIndexChanged.connect(self.handle_label_change)
        toolbar_layout.addWidget(self.label_combo)

        self.model_combo = QComboBox()
        self.model_combo.setStyleSheet("QComboBox { min-width: 120px; }")
        for model_name in self.all_models.keys():
            self.model_combo.addItem(model_name)
        self.model_combo.currentIndexChanged.connect(self.handle_model_change)
        toolbar_layout.addWidget(self.model_combo)

    def _load_images_from_folder(self, folder_path: Path):
        """Load images from a folder and update the image list."""
        self.image_entries = []  # Clear the existing image paths

        if self.config.full_search:
            image_paths = list(folder_path.rglob("*.*"))
        else:
            image_paths = list(folder_path.glob("*.*"))

        for img_path in image_paths:
            if img_path.suffix.lower() in [
                ".jpg",
                ".jpeg",
                ".png",
                ".bmp",
                ".tiff",
            ]:
                self.image_entries.append(
                    ImageEntry(is_baked_result=False, data=img_path)
                )

    def select_folder(self):
        """Allow the user to select a folder and load images from it."""
        folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
        if folder_path:
            self.image_entries = []  # Clear the existing image paths
            folder_path = Path(folder_path)

            self._load_images_from_folder(folder_path)

            self.curr_image_idx = 0  # Reset the current image index

            if len(self.image_entries) > 0:
                msg = f"Loaded {len(self.image_entries)} images from {folder_path}"
                logger.info(msg)
                self.messageSignal.emit(msg)

                # Update the image list panel with the new image paths
                self.image_list_panel.image_entries = self.image_entries
                self.image_list_panel.update_image_list(self.image_entries)

                # Load the first set of images into the layers
                self.load_default_images()

                # Unhide the next/prev buttons if there are multiple images
                self.next_image_btn.setVisible(len(self.image_entries) > 1)
                self.prev_image_btn.setVisible(len(self.image_entries) > 1)
            else:
                QMessageBox.warning(
                    self,
                    "No Images Found",
                    "No valid image files found in the selected folder.",
                )

    def show_next_image(self):
        """Show next image in the list. If at the end, show first image."""
        if self.curr_image_idx < len(self.image_entries) - 1:
            self.curr_image_idx += 1
        else:
            self.curr_image_idx = 0
        self.layer.set_image(self.image_entries[self.curr_image_idx]["data"])
        self.messageSignal.emit(
            f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
        )

    def show_prev_image(self):
        """Show previous image in the list. If at the start, show last image."""
        if self.curr_image_idx > 0:
            self.curr_image_idx -= 1
        else:
            self.curr_image_idx = len(self.image_entries) - 1
        self.layer.set_image(self.image_entries[self.curr_image_idx]["data"])
        self.messageSignal.emit(
            f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
        )

    def __del__(self):
        logger.warning(f"Tab {id(self)} deleted")

    def add_baked_result(self, baking_result: BakingResult):
        """Add a baked result to the baked results list and update the image list."""
        # Create a new layer for the baked result
        self.layer.setVisible(False)  # Hide the current layer
        layer = AnnotableLayer(
            parent=self.main_window,
            config=self.config,
            canvas_config=self.canvas_config,
        )
        layer.annotations = baking_result.annotations

        layer.annotationAdded.connect(self.annotation_list.update_list)
        layer.annotationUpdated.connect(self.annotation_list.update_list)
        layer.messageSignal.connect(self.messageSignal)
        layer.layerSignal.connect(self.add_layer)

        layer.set_image(baking_result.image)  # Set the baked result's image
        layer.setVisible(True)  # Hide the layer initially
        self.main_layout.addWidget(layer)  # Add the layer to the layout

        # Add the baked result layer to annotable_layers for proper visibility management
        self.annotable_layers.append(layer)

        # Add baked result to image_entries
        baked_result_entry = ImageEntry(is_baked_result=True, data=layer)
        self.image_entries.append(baked_result_entry)
        # baking_result.image.save(str(baking_result.filename))
        layer.update()

        logger.info("A baked result has arrived, adding it to the image list.")

        # Update the image list panel
        self.image_list_panel.update_image_list(self.image_entries)
        self.image_list_panel.imageSelected.emit(baked_result_entry)

        self.messageSignal.emit("Baked result added")
        self.gotToTab.emit(0)

    def keyPressEvent(self, event):
        """Handle key press events for setting labels and deleting annotations."""
        key = event.key()

        # Debugging: Log the key press
        logger.info(f"Key pressed in LayerifyTab: {key}")

        # Handle keys 0-9 for setting labels
        if Qt.Key_0 <= key <= Qt.Key_9:
            label_index = key - Qt.Key_0  # Convert key to index (0-9)
            if label_index < len(self.config.predefined_labels):
                # Set the current label to the corresponding predefined label
                self.current_label = self.config.predefined_labels[label_index].name
                self.label_combo.setCurrentIndex(label_index)
                self.layer.current_label = self.current_label
                self.layer.update()
                logger.info(f"Label set to: {self.current_label}")
            else:
                # Show dialog to add a new label if the index is out of range
                self.add_new_label()

        # Handle Delete key for removing the selected annotation
        elif key == Qt.Key_Delete:
            self.layer.selected_annotation = self.layer._get_selected_annotation()
            if self.layer and self.layer.selected_annotation:

                self.layer.annotations.remove(self.layer.selected_annotation)
                self.layer.selected_annotation = None  # Clear the selection
                self.layer.update()
                self.annotation_list.update_list()
                logger.info("Selected annotation deleted.")

        # Pass the event to the annotation list if it needs to handle it
        if self.annotation_list.hasFocus():
            self.annotation_list.keyPressEvent(event)

        # Pass unhandled events to the base class
        super().keyPressEvent(event)

add_baked_result(baking_result)

Add a baked result to the baked results list and update the image list.

Source code in imagebaker/tabs/layerify_tab.py
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def add_baked_result(self, baking_result: BakingResult):
    """Add a baked result to the baked results list and update the image list."""
    # Create a new layer for the baked result
    self.layer.setVisible(False)  # Hide the current layer
    layer = AnnotableLayer(
        parent=self.main_window,
        config=self.config,
        canvas_config=self.canvas_config,
    )
    layer.annotations = baking_result.annotations

    layer.annotationAdded.connect(self.annotation_list.update_list)
    layer.annotationUpdated.connect(self.annotation_list.update_list)
    layer.messageSignal.connect(self.messageSignal)
    layer.layerSignal.connect(self.add_layer)

    layer.set_image(baking_result.image)  # Set the baked result's image
    layer.setVisible(True)  # Hide the layer initially
    self.main_layout.addWidget(layer)  # Add the layer to the layout

    # Add the baked result layer to annotable_layers for proper visibility management
    self.annotable_layers.append(layer)

    # Add baked result to image_entries
    baked_result_entry = ImageEntry(is_baked_result=True, data=layer)
    self.image_entries.append(baked_result_entry)
    # baking_result.image.save(str(baking_result.filename))
    layer.update()

    logger.info("A baked result has arrived, adding it to the image list.")

    # Update the image list panel
    self.image_list_panel.update_image_list(self.image_entries)
    self.image_list_panel.imageSelected.emit(baked_result_entry)

    self.messageSignal.emit("Baked result added")
    self.gotToTab.emit(0)

add_layer(layer)

Add a new layer to the tab.

Source code in imagebaker/tabs/layerify_tab.py
595
596
597
598
599
600
601
def add_layer(self, layer):
    """Add a new layer to the tab."""
    # this layer i.e. canvas will have only one annotation
    logger.info(f"AnnotableLayer added: {layer.annotations[0].label}")
    self.layerAdded.emit(layer)

    self.layer.update()

add_new_label()

Add a new label to the predefined labels.

Source code in imagebaker/tabs/layerify_tab.py
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def add_new_label(self):
    """Add a new label to the predefined labels."""
    name, ok = QInputDialog.getText(self, "New Label", "Enter label name:")
    if not ok or not name:
        return

    # Check for existing label
    existing_names = [label.name for label in self.config.predefined_labels]
    if name in existing_names:
        QMessageBox.warning(self, "Duplicate", "Label name already exists!")
        return

    color = QColorDialog.getColor()
    if not color.isValid():
        return

    # Add new predefined label
    self.config.predefined_labels.append(Label(name=name, color=color))

    # Update combo box
    self.update_label_combo()

    # Select the new label
    index = self.label_combo.findText(name)
    self.label_combo.setCurrentIndex(index)

choose_color()

Choose a color for the current label.

Source code in imagebaker/tabs/layerify_tab.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
def choose_color(self):
    """Choose a color for the current label."""
    current_label = self.label_combo.currentText()
    label_info = next(
        (
            label
            for label in self.config.predefined_labels
            if label.name == current_label
        ),
        None,
    )

    if label_info:
        color = QColorDialog.getColor(label_info.color)
        if color.isValid():
            # Update label color
            label_info.color = color
            # Update combo box display
            index = self.label_combo.currentIndex()
            pixmap = QPixmap(16, 16)
            pixmap.fill(color)
            self.label_combo.setItemIcon(index, QIcon(pixmap))
            # Update canvas color
            self.layer.current_color = color
            self.layer.update()
    for annotation in self.layer.annotations:
        if annotation.label == current_label:
            annotation.color = color
            self.layer.update()

clear_annotations()

Safely clear all annotations

Source code in imagebaker/tabs/layerify_tab.py
227
228
229
230
231
232
233
234
235
236
def clear_annotations(self):
    """Safely clear all annotations"""
    try:
        # Clear layer annotations
        self.clearAnnotations.emit()
        self.messageSignal.emit("Annotations cleared")

    except Exception as e:
        logger.error(f"Clear error: {str(e)}")
        self.messageSignal.emit(f"Error clearing: {str(e)}")

create_toolbar()

Create Layerify-specific toolbar

Source code in imagebaker/tabs/layerify_tab.py
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
def create_toolbar(self):
    """Create Layerify-specific toolbar"""
    self.toolbar = QWidget()
    toolbar_layout = QHBoxLayout(self.toolbar)

    modes = [
        ("📍", "Point", lambda x: self.layer.set_mode(MouseMode.POINT)),
        ("🔷", "Polygon", lambda x: self.layer.set_mode(MouseMode.POLYGON)),
        ("🔳", "Rectangle", lambda x: self.layer.set_mode(MouseMode.RECTANGLE)),
        ("⏳", "Idle", lambda x: self.layer.set_mode(MouseMode.IDLE)),
        ("💾", "Annotations", self.save_annotations),
        ("📂", "Annotations", self.load_annotations),
        ("🔮", "Predict", self.handle_predict),
        ("🎨", "Color", self.choose_color),
        ("🧅", "Layerify All", self.layerify_all),
        ("🏷️", "Add Label", self.add_new_label),
        ("🗑️", "Clear", lambda x: self.clearAnnotations.emit()),
    ]

    # Folder navigation buttons
    self.select_folder_btn = QPushButton("Select Folder")
    self.select_folder_btn.clicked.connect(self.select_folder)
    toolbar_layout.addWidget(self.select_folder_btn)

    self.next_image_btn = QPushButton("Next")
    self.next_image_btn.clicked.connect(self.show_next_image)
    toolbar_layout.addWidget(self.next_image_btn)

    self.prev_image_btn = QPushButton("Prev")
    self.prev_image_btn.clicked.connect(self.show_prev_image)
    toolbar_layout.addWidget(self.prev_image_btn)

    # Initially hide next/prev buttons
    self.next_image_btn.setVisible(False)
    self.prev_image_btn.setVisible(False)

    # Add mode buttons
    for icon, text, mode in modes:
        btn_txt = icon + text
        btn = QPushButton(btn_txt)
        btn.setToolTip(btn_txt)
        btn.setMaximumWidth(80)
        if isinstance(mode, MouseMode):
            btn.clicked.connect(lambda _, m=mode: self.layer.set_mode(m))
        else:
            btn.clicked.connect(mode)
        toolbar_layout.addWidget(btn)

    # Add spacer
    spacer = QWidget()
    spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
    toolbar_layout.addWidget(spacer)

    # Label and Model dropdowns
    self.label_combo = QComboBox()
    self.label_combo.setStyleSheet("QComboBox { min-width: 120px; }")
    for label in self.config.predefined_labels:
        pixmap = QPixmap(16, 16)
        pixmap.fill(label.color)
        self.label_combo.addItem(QIcon(pixmap), label.name)
    self.label_combo.currentIndexChanged.connect(self.handle_label_change)
    toolbar_layout.addWidget(self.label_combo)

    self.model_combo = QComboBox()
    self.model_combo.setStyleSheet("QComboBox { min-width: 120px; }")
    for model_name in self.all_models.keys():
        self.model_combo.addItem(model_name)
    self.model_combo.currentIndexChanged.connect(self.handle_model_change)
    toolbar_layout.addWidget(self.model_combo)

handle_label_change(index)

Handle the label change event.

Source code in imagebaker/tabs/layerify_tab.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
def handle_label_change(self, index):
    """Handle the label change event."""
    label_info = self.config.predefined_labels[index]
    self.current_label = label_info.name
    # sort the labels by putting selected label on top
    self.config.predefined_labels.remove(label_info)
    self.config.predefined_labels.insert(0, label_info)
    # self.update_label_combo()

    self.layer.current_color = label_info.color
    self.layer.current_label = (
        self.current_label if self.current_label != "Custom" else None
    )
    msg = f"Label changed to {self.current_label}"
    self.messageSignal.emit(msg)
    self.layer.update()
    self.update()

handle_model_change(index)

Handle the model change event.

Parameters:

Name Type Description Default
index int

The index of the selected model.

required
Source code in imagebaker/tabs/layerify_tab.py
450
451
452
453
454
455
456
457
458
459
460
461
def handle_model_change(self, index):
    """
    Handle the model change event.

    Args:
        index (int): The index of the selected model.
    """
    model_name = self.model_combo.currentText()
    self.current_model = self.all_models[model_name]
    msg = f"Model changed to {model_name}"
    logger.info(msg)
    self.messageSignal.emit(msg)

handle_model_result(predictions)

A slot to handle the model prediction results.

Parameters:

Name Type Description Default
predictions list[PredictionResult]

The list of prediction results.

required
Source code in imagebaker/tabs/layerify_tab.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
def handle_model_result(self, predictions: list[PredictionResult]):
    """
    A slot to handle the model prediction results.

    Args:
        predictions (list[PredictionResult]): The list of prediction results.
    """
    # update canvas with predictions
    for prediction in predictions:
        if prediction.class_name not in self.config.predefined_labels:
            self.config.predefined_labels.append(Label(prediction.class_name))
            self.update_label_combo()
        if prediction.rectangle:
            # make sure the returned rectangle is within the image

            self.layer.annotations.append(
                Annotation(
                    annotation_id=len(self.layer.annotations),
                    label=prediction.class_name,
                    color=self.config.get_label_color(prediction.class_name)
                    or QColor(255, 255, 255),
                    rectangle=QRectF(*prediction.rectangle),
                    is_complete=True,
                    score=prediction.score,
                    annotator=self.current_model.name,
                    annotation_time=str(
                        prediction.annotation_time
                        if prediction.annotation_time
                        else ""
                    ),
                    file_path=self.layer.file_path,
                )
            )
        elif prediction.polygon is not None:

            self.layer.annotations.append(
                Annotation(
                    annotation_id=len(self.layer.annotations),
                    label=prediction.class_name,
                    color=self.config.get_label_color(prediction.class_name)
                    or QColor(255, 255, 255),
                    polygon=QPolygonF([QPointF(*p) for p in prediction.polygon]),
                    is_complete=True,
                    score=prediction.score,
                    annotator=self.current_model.name,
                    annotation_time=str(prediction.annotation_time),
                    file_path=self.layer.file_path,
                )
            )
        else:
            # points as center of canvas
            x, y = self.layer.width() // 2, self.layer.height() // 2
            self.layer.annotations.append(
                Annotation(
                    annotation_id=len(self.layer.annotations),
                    label=prediction.class_name,
                    color=self.config.get_label_color(prediction.class_name)
                    or QColor(255, 255, 255),
                    points=[QPointF(x, y)],
                    is_complete=True,
                    score=prediction.score,
                    annotator=self.current_model.name,
                    annotation_time=str(prediction.annotation_time),
                    file_path=self.layer.file_path,
                )
            )

    self.layer.update()
    self.annotation_list.update_list()
    self.update_annotation_list()

handle_predict()

Handle the predict button click event.

Source code in imagebaker/tabs/layerify_tab.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
def handle_predict(self):
    """
    Handle the predict button click event.

    """
    if self.current_model is None:
        logger.warning("No model selected to predict")
        self.messageSignal.emit("No model selected/or loaded to predict")
        return
    # get image as an numpy array from canvas
    image = qpixmap_to_numpy(self.layer.image)
    if image is None:
        return
    # get annotations from canvas
    annotations = [
        ann
        for ann in self.layer.annotations
        if not ann.is_model_generated and ann.visible
    ]

    if len(annotations) == 0:
        logger.warning("No annotations to predict passing image to model")
        self.messageSignal.emit("No annotations to predict passing image to model")
        # return

    points = []
    polygons = []
    rectangles = []
    label_hints = []
    for ann in annotations:
        if ann.points:
            points.append([[p.x(), p.y()] for p in ann.points])
        if ann.polygon:
            polygons.append([[p.x(), p.y()] for p in ann.polygon])
        if ann.rectangle:
            rectangles.append(
                [
                    ann.rectangle.x(),
                    ann.rectangle.y(),
                    ann.rectangle.x() + ann.rectangle.width(),
                    ann.rectangle.y() + ann.rectangle.height(),
                ]
            )
        label_hints.append([0])
        ann.visible = False

    points = points if len(points) > 0 else None
    polygons = polygons if len(polygons) > 0 else None
    rectangles = [rectangles] if len(rectangles) > 0 else None
    label_hints = label_hints if len(label_hints) > 0 else None

    self.loading_dialog = QProgressDialog(
        "Processing annotation...",
        "Cancel",  # Optional cancel button
        0,
        0,
        self.parentWidget(),  # Or your main window reference
    )
    self.loading_dialog.setWindowTitle("Please Wait")
    self.loading_dialog.setWindowModality(Qt.WindowModal)
    self.loading_dialog.setCancelButton(None)  # Remove cancel button if not needed
    self.loading_dialog.show()

    # Force UI update
    QApplication.processEvents()

    # Setup worker thread
    self.worker_thread = QThread()
    self.worker = ModelPredictionWorker(
        self.current_model, image, points, polygons, rectangles, label_hints
    )
    self.worker.moveToThread(self.worker_thread)

    # Connect signals
    self.worker_thread.started.connect(self.worker.process)
    self.worker.finished.connect(self.handle_model_result)
    self.worker.finished.connect(self.worker_thread.quit)
    self.worker.error.connect(self.handle_model_error)

    # Cleanup connections
    self.worker.finished.connect(self.worker.deleteLater)
    self.worker_thread.finished.connect(self.worker_thread.deleteLater)
    self.worker_thread.finished.connect(self.loading_dialog.close)

    # Start processing
    self.worker_thread.start()

init_ui()

Initialize the UI components

Source code in imagebaker/tabs/layerify_tab.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def init_ui(self):
    """Initialize the UI components"""
    # Create annotation list and image list panel
    self.annotation_list = AnnotationList(
        None, parent=self.main_window, max_name_length=self.config.max_name_length
    )
    self.image_list_panel = ImageListPanel(
        self.image_entries, self.processed_images
    )

    self.main_window.addDockWidget(Qt.LeftDockWidgetArea, self.image_list_panel)

    # Add multiple layers (canvas) to the main layout
    for _ in range(self.annotable_layers.maxlen):
        layer = AnnotableLayer(
            parent=self.main_window,
            config=self.config,
            canvas_config=self.canvas_config,
        )
        layer.setVisible(False)  # Initially hide all layers
        self.annotable_layers.append(layer)
        self.main_layout.addWidget(layer)

    # Set the annotation list to the first layer by default
    if self.annotable_layers:
        self.layer = self.annotable_layers[0]
        self.layer.set_mode(MouseMode.RECTANGLE)
        self.annotation_list.layer = self.layer

    self.create_toolbar()

    # Create a dock widget for the toolbar
    self.toolbar_dock = QDockWidget("Tools", self)
    self.toolbar_dock.setWidget(self.toolbar)
    self.toolbar_dock.setFeatures(
        QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
    )
    self.main_window.addDockWidget(Qt.BottomDockWidgetArea, self.toolbar_dock)

    # Add annotation list to main window's docks
    self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.annotation_list)
    self.load_default_images()

keyPressEvent(event)

Handle key press events for setting labels and deleting annotations.

Source code in imagebaker/tabs/layerify_tab.py
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
def keyPressEvent(self, event):
    """Handle key press events for setting labels and deleting annotations."""
    key = event.key()

    # Debugging: Log the key press
    logger.info(f"Key pressed in LayerifyTab: {key}")

    # Handle keys 0-9 for setting labels
    if Qt.Key_0 <= key <= Qt.Key_9:
        label_index = key - Qt.Key_0  # Convert key to index (0-9)
        if label_index < len(self.config.predefined_labels):
            # Set the current label to the corresponding predefined label
            self.current_label = self.config.predefined_labels[label_index].name
            self.label_combo.setCurrentIndex(label_index)
            self.layer.current_label = self.current_label
            self.layer.update()
            logger.info(f"Label set to: {self.current_label}")
        else:
            # Show dialog to add a new label if the index is out of range
            self.add_new_label()

    # Handle Delete key for removing the selected annotation
    elif key == Qt.Key_Delete:
        self.layer.selected_annotation = self.layer._get_selected_annotation()
        if self.layer and self.layer.selected_annotation:

            self.layer.annotations.remove(self.layer.selected_annotation)
            self.layer.selected_annotation = None  # Clear the selection
            self.layer.update()
            self.annotation_list.update_list()
            logger.info("Selected annotation deleted.")

    # Pass the event to the annotation list if it needs to handle it
    if self.annotation_list.hasFocus():
        self.annotation_list.keyPressEvent(event)

    # Pass unhandled events to the base class
    super().keyPressEvent(event)

layerify_all()

Layerify all annotations in the current layer.

Source code in imagebaker/tabs/layerify_tab.py
603
604
605
606
607
608
609
610
611
612
613
def layerify_all(self):
    """Layerify all annotations in the current layer."""
    if len(self.layer.annotations) == 0:
        logger.warning("No annotations to layerify")
        self.messageSignal.emit("No annotations to layerify")

        return
    logger.info("Layerifying all annotations")

    # else appends already added too
    self.layer.layerify_annotation(self.layer.annotations)

load_annotations()

Load annotations from a JSON file.

Source code in imagebaker/tabs/layerify_tab.py
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def load_annotations(self):
    """
    Load annotations from a JSON file.
    """
    options = QFileDialog.Options()
    file_name, _ = QFileDialog.getOpenFileName(
        self, "Load Annotations", "", "JSON Files (*.json)", options=options
    )

    if file_name:
        try:
            self.layer.annotations = Annotation.load_from_json(file_name)
            self.layer.update()
            self.update_annotation_list()
            QMessageBox.information(
                self, "Success", "Annotations loaded successfully!"
            )

        except Exception as e:
            QMessageBox.critical(
                self, "Error", f"Failed to load annotations: {str(e)}"
            )
            self.layer.annotations = []
            self.layer.update()

load_default_image()

Load a default image from the assets folder.

Source code in imagebaker/tabs/layerify_tab.py
280
281
282
283
284
285
286
287
288
289
290
def load_default_image(self):
    """
    Load a default image from the assets folder.
    """
    default_path = self.config.assets_folder / "desk.png"
    if not default_path.exists():
        default_path, _ = QFileDialog.getOpenFileName()
        default_path = Path(default_path)

    if default_path.exists():
        self.layer.set_image(default_path)

load_default_images()

Load the first set of images as the default.

Source code in imagebaker/tabs/layerify_tab.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def load_default_images(self):
    """Load the first set of images as the default."""
    # If no images are loaded, try to load from the assets folder
    if not self.image_entries:
        assets_folder = self.config.assets_folder
        if assets_folder.exists() and assets_folder.is_dir():
            self._load_images_from_folder(assets_folder)

    # Load images into layers if any are found
    if self.image_entries:
        for i, layer in enumerate(self.annotable_layers):
            if i < len(self.image_entries):
                layer.set_image(self.image_entries[i].data)
                layer.setVisible(
                    i == 0
                )  # Only the first layer is visible by default
                if i == 0:
                    self.layer = layer  # Set the first layer as the current layer
            else:
                layer.setVisible(False)

        self.messageSignal.emit(f"Showing image 1/{len(self.image_entries)}")
    else:
        # If no images are found, log a message
        logger.warning("No images found in the assets folder.")
        self.messageSignal.emit("No images found in the assets folder.")

    # Update the image list panel
    self.image_list_panel.update_image_list(self.image_entries)
    self.update()

on_annotation_added(annotation)

Handle annotation added event

Parameters:

Name Type Description Default
annotation Annotation

The annotation that was added.

required
Source code in imagebaker/tabs/layerify_tab.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def on_annotation_added(self, annotation: Annotation):
    """Handle annotation added event

    Args:
        annotation (Annotation): The annotation that was added.
    """
    if annotation.label not in self.config.predefined_labels:
        self.config.predefined_labels.append(
            Label(annotation.label, annotation.color)
        )
        self.update_label_combo()
    logger.info(f"Added annotation: {annotation.label}")
    self.messageSignal.emit(f"Added annotation: {annotation.label}")

    # Refresh the annotation list
    self.annotation_list.update_list()

on_annotation_updated(annotation)

A slot to handle the annotation updated signal.

Parameters:

Name Type Description Default
annotation Annotation

The updated annotation.

required
Source code in imagebaker/tabs/layerify_tab.py
255
256
257
258
259
260
261
262
263
264
265
266
def on_annotation_updated(self, annotation: Annotation):
    """
    A slot to handle the annotation updated signal.

    Args:
        annotation (Annotation): The updated annotation.
    """
    logger.info(f"Updated annotation: {annotation.label}")
    self.messageSignal.emit(f"Updated annotation: {annotation.label}")

    # Refresh the annotation list
    self.annotation_list.update_list()

on_image_selected(image_entry)

Handle image selection from the image list panel.

Source code in imagebaker/tabs/layerify_tab.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def on_image_selected(self, image_entry: ImageEntry):
    """Handle image selection from the image list panel."""
    logger.info(f"Image selected: {image_entry}")

    # Hide all layers first
    for idx, layer in enumerate(self.annotable_layers):
        layer.setVisible(False)
        # logger.info(f"Layer {idx} hidden.")

    if not image_entry.is_baked_result:  # Regular image
        image_path = image_entry.data
        self.curr_image_idx = self.image_entries.index(image_entry)

        # Make the corresponding layer visible and set the image
        selected_layer = self.annotable_layers[self.curr_image_idx]
        selected_layer.setVisible(True)
        # logger.info(f"Layer {self.curr_image_idx} made visible for regular image.")
        selected_layer.set_image(image_path)  # Set the selected image
        if self.layer:
            selected_layer.set_mode(self.layer.mouse_mode)
        self.layer = selected_layer  # Update the currently selected layer

    else:  # Baked result
        baked_result_layer = image_entry.data
        self.curr_image_idx = self.image_entries.index(image_entry)

        # Make the baked result layer visible
        baked_result_layer.setVisible(True)
        # logger.info(f"Layer {self.curr_image_idx} made visible for baked result.")
        self.layer = baked_result_layer  # Set the baked result as the current layer

    self.annotation_list.layer = self.layer
    self.annotation_list.update_list()

    self.messageSignal.emit(
        f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
    )
    self.update()

save_annotations()

Save annotations to a JSON file.

Source code in imagebaker/tabs/layerify_tab.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
def save_annotations(self):
    """Save annotations to a JSON file."""
    if not self.layer.annotations:
        QMessageBox.warning(self, "Warning", "No annotations to save!")
        return

    options = QFileDialog.Options()
    file_name, _ = QFileDialog.getSaveFileName(
        self, "Save Annotations", "", "JSON Files (*.json)", options=options
    )

    if file_name:
        try:
            Annotation.save_as_json(self.layer.annotations, file_name)

            QMessageBox.information(
                self, "Success", "Annotations saved successfully!"
            )

        except Exception as e:
            QMessageBox.critical(
                self, "Error", f"Failed to save annotations: {str(e)}"
            )

select_folder()

Allow the user to select a folder and load images from it.

Source code in imagebaker/tabs/layerify_tab.py
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
def select_folder(self):
    """Allow the user to select a folder and load images from it."""
    folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
    if folder_path:
        self.image_entries = []  # Clear the existing image paths
        folder_path = Path(folder_path)

        self._load_images_from_folder(folder_path)

        self.curr_image_idx = 0  # Reset the current image index

        if len(self.image_entries) > 0:
            msg = f"Loaded {len(self.image_entries)} images from {folder_path}"
            logger.info(msg)
            self.messageSignal.emit(msg)

            # Update the image list panel with the new image paths
            self.image_list_panel.image_entries = self.image_entries
            self.image_list_panel.update_image_list(self.image_entries)

            # Load the first set of images into the layers
            self.load_default_images()

            # Unhide the next/prev buttons if there are multiple images
            self.next_image_btn.setVisible(len(self.image_entries) > 1)
            self.prev_image_btn.setVisible(len(self.image_entries) > 1)
        else:
            QMessageBox.warning(
                self,
                "No Images Found",
                "No valid image files found in the selected folder.",
            )

show_next_image()

Show next image in the list. If at the end, show first image.

Source code in imagebaker/tabs/layerify_tab.py
739
740
741
742
743
744
745
746
747
748
def show_next_image(self):
    """Show next image in the list. If at the end, show first image."""
    if self.curr_image_idx < len(self.image_entries) - 1:
        self.curr_image_idx += 1
    else:
        self.curr_image_idx = 0
    self.layer.set_image(self.image_entries[self.curr_image_idx]["data"])
    self.messageSignal.emit(
        f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
    )

show_prev_image()

Show previous image in the list. If at the start, show last image.

Source code in imagebaker/tabs/layerify_tab.py
750
751
752
753
754
755
756
757
758
759
def show_prev_image(self):
    """Show previous image in the list. If at the start, show last image."""
    if self.curr_image_idx > 0:
        self.curr_image_idx -= 1
    else:
        self.curr_image_idx = len(self.image_entries) - 1
    self.layer.set_image(self.image_entries[self.curr_image_idx]["data"])
    self.messageSignal.emit(
        f"Showing image {self.curr_image_idx + 1}/{len(self.image_entries)}"
    )

update_annotation_list()

Update the annotation list with the current annotations.

Source code in imagebaker/tabs/layerify_tab.py
517
518
519
def update_annotation_list(self):
    """Update the annotation list with the current annotations."""
    self.annotation_list.update_list()

update_label_combo()

Add predefined labels to the label combo box.

This method is called when a new label is added.

Source code in imagebaker/tabs/layerify_tab.py
268
269
270
271
272
273
274
275
276
277
278
def update_label_combo(self):
    """
    Add predefined labels to the label combo box.

    This method is called when a new label is added.
    """
    self.label_combo.clear()
    for label in self.config.predefined_labels:
        pixmap = QPixmap(16, 16)
        pixmap.fill(label.color)
        self.label_combo.addItem(QIcon(pixmap), label.name)

Baker Tab

Bases: QWidget

Baker Tab implementation

Source code in imagebaker/tabs/baker_tab.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
class BakerTab(QWidget):
    """Baker Tab implementation"""

    messageSignal = Signal(str)
    bakingResult = Signal(BakingResult)

    def __init__(self, main_window, config: CanvasConfig):
        """Initialize the Baker Tab."""
        super().__init__(main_window)
        self.main_window = main_window
        self.config = config
        self.toolbar = None
        self.main_layout = QVBoxLayout(self)

        # Deque to store multiple CanvasLayer objects with a fixed size
        self.canvases = deque(maxlen=self.config.deque_maxlen)

        # Currently selected canvas
        self.current_canvas = None

        self.init_ui()

    def init_ui(self):
        """Initialize the UI components."""
        # Create toolbar
        self.create_toolbar()

        # Create a single canvas for now
        self.current_canvas = CanvasLayer(parent=self.main_window, config=self.config)
        self.current_canvas.setVisible(True)  # Initially hide all canvases
        self.canvases.append(self.current_canvas)
        self.main_layout.addWidget(self.current_canvas)

        # Create and add CanvasList
        self.canvas_list = CanvasList(self.canvases, parent=self.main_window)
        self.main_window.addDockWidget(Qt.LeftDockWidgetArea, self.canvas_list)

        # Create and add LayerList
        self.layer_settings = LayerSettings(
            parent=self.main_window,
            max_xpos=self.config.max_xpos,
            max_ypos=self.config.max_ypos,
            max_scale=self.config.max_scale,
            max_edge_width=self.config.max_edge_width,
        )
        self.layer_list = LayerList(
            canvas=self.current_canvas,
            parent=self.main_window,
            layer_settings=self.layer_settings,
        )
        self.layer_settings.setVisible(False)
        self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.layer_list)
        self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.layer_settings)

        # Create a dock widget for the toolbar
        self.toolbar_dock = QDockWidget("Tools", self)
        self.toolbar_dock.setWidget(self.toolbar)
        self.toolbar_dock.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )
        self.main_window.addDockWidget(Qt.BottomDockWidgetArea, self.toolbar_dock)

        # Connections
        self.layer_settings.messageSignal.connect(self.messageSignal.emit)
        self.current_canvas.bakingResult.connect(self.bakingResult.emit)
        self.current_canvas.layersChanged.connect(self.update_list)
        self.current_canvas.layerRemoved.connect(self.update_list)

        self.canvas_list.canvasSelected.connect(self.on_canvas_selected)
        self.canvas_list.canvasAdded.connect(self.on_canvas_added)
        self.canvas_list.canvasDeleted.connect(self.on_canvas_deleted)
        # self.current_canvas.thumbnailsAvailable.connect(self.generate_state_previews)

    def update_slider_range(self, steps):
        """Update the slider range based on the number of steps."""
        self.timeline_slider.setMaximum(steps - 1)
        self.messageSignal.emit(f"Updated steps to {steps}")
        self.timeline_slider.setEnabled(False)  # Disable the slider
        self.timeline_slider.update()

    def generate_state_previews(self):
        """Generate previews for each state."""
        # Clear existing previews
        for i in reversed(range(self.preview_layout.count())):
            widget = self.preview_layout.itemAt(i).widget()
            if widget:
                widget.deleteLater()

        # Generate a preview for each state
        for step, states in sorted(self.current_canvas.states.items()):
            # Create a container widget for the preview
            preview_widget = QWidget()
            preview_layout = QVBoxLayout(preview_widget)
            preview_layout.setContentsMargins(0, 0, 0, 0)
            preview_layout.setSpacing(2)

            # Placeholder thumbnail
            placeholder = QPixmap(50, 50)
            placeholder.fill(Qt.gray)  # Gray placeholder
            thumbnail_label = QLabel()
            thumbnail_label.setPixmap(placeholder)
            thumbnail_label.setFixedSize(50, 50)  # Set a fixed size for the thumbnail
            thumbnail_label.setScaledContents(True)

            # Add the step number on top of the thumbnail
            step_label = QLabel(f"Step {step}")
            step_label.setAlignment(Qt.AlignCenter)
            step_label.setStyleSheet("font-weight: bold; font-size: 10px;")

            # Add a button to make the preview clickable
            preview_button = QPushButton()
            preview_button.setFixedSize(
                50, 70
            )  # Match the size of the thumbnail + step label
            preview_button.setStyleSheet("background: transparent; border: none;")
            preview_button.clicked.connect(lambda _, s=step: self.seek_state(s))

            # Add the thumbnail and step label to the layout
            preview_layout.addWidget(thumbnail_label)
            preview_layout.addWidget(step_label)

            # Add the preview widget to the button
            preview_button.setLayout(preview_layout)

            # Add the button to the preview panel
            self.preview_layout.addWidget(preview_button)

            # Update the thumbnail dynamically when it becomes available
            self.current_canvas.thumbnailsAvailable.connect(
                lambda step=step, label=thumbnail_label: self.update_thumbnail(
                    step, label
                )
            )

        # Refresh the preview panel
        self.preview_panel.update()

    def update_thumbnail(self, step, thumbnail_label):
        """Update the thumbnail for a specific step."""
        if step in self.current_canvas.state_thumbnail:
            thumbnail = self.current_canvas.state_thumbnail[step]
            thumbnail_label.setPixmap(thumbnail)
            thumbnail_label.update()

    def update_list(self, layer=None):
        """Update the layer list and layer settings."""
        if layer:
            self.layer_list.layers = self.current_canvas.layers
        self.layer_list.update_list()
        self.layer_settings.update_sliders()
        self.update()

    def on_canvas_deleted(self, canvas: CanvasLayer):
        """Handle the deletion of a canvas."""
        # Ensure only the currently selected canvas is visible
        if self.canvases:
            self.layer_list.canvas = self.canvases[-1]
            self.layer_list.layers = self.canvases[-1].layers
            self.current_canvas = self.canvases[-1]  # Select the last canvas
            self.current_canvas.setVisible(True)  # Show the last canvas
        else:
            self.current_canvas = None  # No canvases left
            self.messageSignal.emit("No canvases available.")  # Notify the user
            self.layer_list.canvas = None
            self.layer_list.layers = []
            self.layer_settings.selected_layer = None
        self.layer_settings.update_sliders()
        self.canvas_list.update_canvas_list()  # Update the canvas list
        self.layer_list.update_list()
        self.update()

    def on_canvas_selected(self, canvas: CanvasLayer):
        """Handle canvas selection from the CanvasList."""
        # Hide all canvases and show only the selected one
        for layer in self.canvases:
            layer.setVisible(layer == canvas)

        # Update the current canvas
        self.current_canvas = canvas
        self.layer_list.canvas = canvas
        self.layer_list.layers = canvas.layers
        self.layer_settings.selected_layer = canvas.selected_layer
        self.layer_list.layer_settings = self.layer_settings

        self.layer_list.update_list()
        self.layer_settings.update_sliders()

        logger.info(f"Selected canvas: {canvas.layer_name}")
        self.update()

    def on_canvas_added(self, new_canvas: CanvasLayer):
        """Handle the addition of a new canvas."""
        logger.info(f"New canvas added: {new_canvas.layer_name}")
        self.main_layout.addWidget(new_canvas)  # Add the new canvas to the layout
        if self.current_canvas is not None:
            self.current_canvas.setVisible(False)  # Hide the current canvas

        # self.canvases.append(new_canvas)  # Add the new canvas to the deque
        # connect it to the layer list
        self.layer_list.canvas = new_canvas
        self.current_canvas = new_canvas  # Update the current canvas
        self.canvas_list.update_canvas_list()  # Update the canvas list
        new_canvas.setVisible(True)  # Hide the new canvas initially
        # already added to the list
        # self.canvases.append(new_canvas)  # Add to the deque

        self.current_canvas.bakingResult.connect(self.bakingResult.emit)
        self.current_canvas.layersChanged.connect(self.update_list)
        self.current_canvas.layerRemoved.connect(self.update_list)

        self.current_canvas.update()
        self.layer_list.layers = new_canvas.layers
        self.layer_list.update_list()
        self.layer_settings.selected_layer = None
        self.layer_settings.update_sliders()

    def create_toolbar(self):
        """Create Baker-specific toolbar"""
        self.toolbar = QWidget()
        baker_toolbar_layout = QHBoxLayout(self.toolbar)
        baker_toolbar_layout.setContentsMargins(5, 5, 5, 5)
        baker_toolbar_layout.setSpacing(10)

        # Add a label for "Steps"
        steps_label = QLabel("Steps:")
        steps_label.setStyleSheet("font-weight: bold;")
        baker_toolbar_layout.addWidget(steps_label)

        # Add a spin box for entering the number of steps
        self.steps_spinbox = QSpinBox()
        self.steps_spinbox.setMinimum(1)
        self.steps_spinbox.setMaximum(1000)  # Arbitrary maximum value
        self.steps_spinbox.setValue(1)  # Default value
        self.steps_spinbox.valueChanged.connect(self.update_slider_range)
        baker_toolbar_layout.addWidget(self.steps_spinbox)

        # Add buttons for Baker modes with emojis
        baker_modes = [
            ("📤 Export Current State", self.export_current_state),
            ("💾 Save State", self.save_current_state),
            ("🔮 Predict State", self.predict_state),
            ("▶️ Play States", self.play_saved_states),
            ("🗑️ Clear States", self.clear_states),  # New button
            ("📤 Annotate States", self.export_for_annotation),
            ("📤 Export States", self.export_locally),
        ]

        for text, callback in baker_modes:
            btn = QPushButton(text)
            btn.clicked.connect(callback)
            baker_toolbar_layout.addWidget(btn)

            # If the button is "Play States", add the slider beside it
            if text == "▶️ Play States":
                self.timeline_slider = QSlider(Qt.Horizontal)  # Create the slider
                self.timeline_slider.setMinimum(0)
                self.timeline_slider.setMaximum(0)  # Will be updated dynamically
                self.timeline_slider.setValue(0)
                self.timeline_slider.setSingleStep(
                    1
                )  # Set the granularity of the slider
                self.timeline_slider.setPageStep(1)  # Allow smoother jumps
                self.timeline_slider.setEnabled(False)  # Initially disabled
                self.timeline_slider.valueChanged.connect(self.seek_state)
                baker_toolbar_layout.addWidget(self.timeline_slider)

        # Add a drawing button
        draw_button = QPushButton("✏️ Draw")
        draw_button.setCheckable(True)  # Make it toggleable
        draw_button.clicked.connect(self.toggle_drawing_mode)
        baker_toolbar_layout.addWidget(draw_button)

        # Add an erase button
        erase_button = QPushButton("🧹 Erase")
        erase_button.setCheckable(True)  # Make it toggleable
        erase_button.clicked.connect(self.toggle_erase_mode)
        baker_toolbar_layout.addWidget(erase_button)

        # Add a color picker button
        color_picker_button = QPushButton("🎨")
        color_picker_button.clicked.connect(self.open_color_picker)
        baker_toolbar_layout.addWidget(color_picker_button)

        # Add a spacer to push the rest of the elements to the right
        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        baker_toolbar_layout.addWidget(spacer)

        # Add the toolbar to the main layout
        self.main_layout.addWidget(self.toolbar)

    def toggle_drawing_mode(self):
        """Toggle drawing mode on the current canvas."""
        if self.current_canvas:
            self.current_canvas.mouse_mode = (
                MouseMode.DRAW
                if self.current_canvas.mouse_mode != MouseMode.DRAW
                else MouseMode.IDLE
            )
            mode = self.current_canvas.mouse_mode.name.lower()
            self.messageSignal.emit(f"Drawing mode {mode}.")

    def toggle_erase_mode(self):
        """Toggle drawing mode on the current canvas."""
        if self.current_canvas:
            self.current_canvas.mouse_mode = (
                MouseMode.ERASE
                if self.current_canvas.mouse_mode != MouseMode.ERASE
                else MouseMode.IDLE
            )
            mode = self.current_canvas.mouse_mode.name.lower()
            self.messageSignal.emit(f"Erasing mode {mode}.")

    def open_color_picker(self):
        """Open a color picker dialog to select a custom color."""
        color = QColorDialog.getColor()
        if color.isValid():
            self.current_canvas.drawing_color = color
            self.messageSignal.emit(f"Selected custom color: {color.name()}")

    def export_for_annotation(self):
        """Export the baked states for annotation."""
        self.messageSignal.emit("Exporting states for prediction...")
        self.current_canvas.export_baked_states(export_to_annotation_tab=True)

    def export_locally(self):
        """Export the baked states locally."""
        self.messageSignal.emit("Exporting baked states...")
        self.current_canvas.export_baked_states()

    def play_saved_states(self):
        """Play the saved states in sequence."""
        self.messageSignal.emit("Playing saved state...")

        # Enable the timeline slider

        # Update the slider range based on the number of states
        if self.current_canvas.states:
            num_states = len(self.current_canvas.states)
            self.timeline_slider.setMaximum(num_states - 1)
            self.steps_spinbox.setValue(
                num_states
            )  # Sync the spinbox with the number of states
            self.timeline_slider.setEnabled(True)
        else:
            self.timeline_slider.setMaximum(0)
            self.steps_spinbox.setValue(1)
            self.messageSignal.emit("No saved states available.")
            self.timeline_slider.setEnabled(False)

        self.timeline_slider.update()
        # Start playing the states
        self.current_canvas.play_states()

    def save_current_state(self):
        """Save the current state of the canvas."""
        self.messageSignal.emit("Saving current state...")
        logger.info(f"Saving current state for {self.steps_spinbox.value()}...")

        self.current_canvas.save_current_state(steps=self.steps_spinbox.value())
        self.messageSignal.emit(
            "Current state saved. Total states: {}".format(
                len(self.current_canvas.states)
            )
        )

        self.steps_spinbox.setValue(1)  # Reset the spinbox value
        self.steps_spinbox.update()
        # Disable the timeline slider
        self.timeline_slider.setEnabled(False)
        self.timeline_slider.update()

    def clear_states(self):
        """Clear all saved states and disable the timeline slider."""
        self.messageSignal.emit("Clearing all saved states...")
        if self.current_canvas:
            self.current_canvas.previous_state = None
            self.current_canvas.current_step = 0
            self.current_canvas.states.clear()  # Clear all saved states
        self.timeline_slider.setEnabled(False)  # Disable the slider
        self.timeline_slider.setMaximum(0)  # Reset the slider range
        self.timeline_slider.setValue(0)  # Reset the slider position
        self.messageSignal.emit("All states cleared.")
        self.steps_spinbox.setValue(1)  # Reset the spinbox value

        self.steps_spinbox.update()
        self.timeline_slider.update()
        self.current_canvas.update()

    def seek_state(self, step):
        """Seek to a specific state using the timeline slider."""
        self.messageSignal.emit(f"Seeking to step {step}")
        logger.info(f"Seeking to step {step}")
        self.current_canvas.seek_state(step)

        # Update the canvas
        self.current_canvas.update()

    def export_current_state(self):
        """Export the current state as an image."""
        self.messageSignal.emit("Exporting current state...")
        self.current_canvas.export_current_state()

    def predict_state(self):
        """Pass the current state to predict."""
        self.messageSignal.emit("Predicting state...")

        self.current_canvas.predict_state()

    def add_layer(self, layer: CanvasLayer):
        """Add a new layer to the canvas."""
        self.layer_list.add_layer(layer)
        self.layer_settings.selected_layer = self.current_canvas.selected_layer
        self.layer_settings.update_sliders()

    def keyPressEvent(self, event):
        """Handle key press events."""
        # Ctrl + S: Save the current state
        curr_mode = self.current_canvas.mouse_mode
        if event.key() == Qt.Key_S and event.modifiers() == Qt.ControlModifier:
            self.save_current_state()
            self.current_canvas.mouse_mode = curr_mode
            self.current_canvas.update()
        # if ctrl + D: Toggle drawing mode
        if event.key() == Qt.Key_D and event.modifiers() == Qt.ControlModifier:
            self.toggle_drawing_mode()
            self.current_canvas.update()
        # if ctrl + E: Toggle erase mode
        if event.key() == Qt.Key_E and event.modifiers() == Qt.ControlModifier:
            self.toggle_erase_mode()
            self.current_canvas.update()

        # Delete: Delete the selected layer
        if event.key() == Qt.Key_Delete:
            if (
                self.current_canvas.selected_layer
                and self.current_canvas.selected_layer in self.current_canvas.layers
            ):
                self.current_canvas.layers.remove(self.current_canvas.selected_layer)
                self.current_canvas.selected_layer = None
                self.layer_settings.selected_layer = None

            self.current_canvas.update()
            self.layer_list.update_list()
            self.layer_settings.update_sliders()
            self.messageSignal.emit("Deleted selected layer")

        # Ctrl + N: Add a new layer to the current canvas
        if event.key() == Qt.Key_N and event.modifiers() == Qt.ControlModifier:
            new_layer = CanvasLayer(parent=self.current_canvas)
            new_layer.layer_name = f"Layer {len(self.current_canvas.layers) + 1}"
            new_layer.annotations = [
                Annotation(annotation_id=0, label="New Annotation"),
            ]
            balnk_qimage = QPixmap(self.current_canvas.size())
            balnk_qimage.fill(Qt.transparent)
            new_layer.set_image(balnk_qimage)
            self.current_canvas.layers.append(new_layer)
            self.current_canvas.update()
            self.layer_list.update_list()
            self.messageSignal.emit(f"Added new layer: {new_layer.layer_name}")

        self.update()
        return super().keyPressEvent(event)

add_layer(layer)

Add a new layer to the canvas.

Source code in imagebaker/tabs/baker_tab.py
435
436
437
438
439
def add_layer(self, layer: CanvasLayer):
    """Add a new layer to the canvas."""
    self.layer_list.add_layer(layer)
    self.layer_settings.selected_layer = self.current_canvas.selected_layer
    self.layer_settings.update_sliders()

clear_states()

Clear all saved states and disable the timeline slider.

Source code in imagebaker/tabs/baker_tab.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def clear_states(self):
    """Clear all saved states and disable the timeline slider."""
    self.messageSignal.emit("Clearing all saved states...")
    if self.current_canvas:
        self.current_canvas.previous_state = None
        self.current_canvas.current_step = 0
        self.current_canvas.states.clear()  # Clear all saved states
    self.timeline_slider.setEnabled(False)  # Disable the slider
    self.timeline_slider.setMaximum(0)  # Reset the slider range
    self.timeline_slider.setValue(0)  # Reset the slider position
    self.messageSignal.emit("All states cleared.")
    self.steps_spinbox.setValue(1)  # Reset the spinbox value

    self.steps_spinbox.update()
    self.timeline_slider.update()
    self.current_canvas.update()

create_toolbar()

Create Baker-specific toolbar

Source code in imagebaker/tabs/baker_tab.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def create_toolbar(self):
    """Create Baker-specific toolbar"""
    self.toolbar = QWidget()
    baker_toolbar_layout = QHBoxLayout(self.toolbar)
    baker_toolbar_layout.setContentsMargins(5, 5, 5, 5)
    baker_toolbar_layout.setSpacing(10)

    # Add a label for "Steps"
    steps_label = QLabel("Steps:")
    steps_label.setStyleSheet("font-weight: bold;")
    baker_toolbar_layout.addWidget(steps_label)

    # Add a spin box for entering the number of steps
    self.steps_spinbox = QSpinBox()
    self.steps_spinbox.setMinimum(1)
    self.steps_spinbox.setMaximum(1000)  # Arbitrary maximum value
    self.steps_spinbox.setValue(1)  # Default value
    self.steps_spinbox.valueChanged.connect(self.update_slider_range)
    baker_toolbar_layout.addWidget(self.steps_spinbox)

    # Add buttons for Baker modes with emojis
    baker_modes = [
        ("📤 Export Current State", self.export_current_state),
        ("💾 Save State", self.save_current_state),
        ("🔮 Predict State", self.predict_state),
        ("▶️ Play States", self.play_saved_states),
        ("🗑️ Clear States", self.clear_states),  # New button
        ("📤 Annotate States", self.export_for_annotation),
        ("📤 Export States", self.export_locally),
    ]

    for text, callback in baker_modes:
        btn = QPushButton(text)
        btn.clicked.connect(callback)
        baker_toolbar_layout.addWidget(btn)

        # If the button is "Play States", add the slider beside it
        if text == "▶️ Play States":
            self.timeline_slider = QSlider(Qt.Horizontal)  # Create the slider
            self.timeline_slider.setMinimum(0)
            self.timeline_slider.setMaximum(0)  # Will be updated dynamically
            self.timeline_slider.setValue(0)
            self.timeline_slider.setSingleStep(
                1
            )  # Set the granularity of the slider
            self.timeline_slider.setPageStep(1)  # Allow smoother jumps
            self.timeline_slider.setEnabled(False)  # Initially disabled
            self.timeline_slider.valueChanged.connect(self.seek_state)
            baker_toolbar_layout.addWidget(self.timeline_slider)

    # Add a drawing button
    draw_button = QPushButton("✏️ Draw")
    draw_button.setCheckable(True)  # Make it toggleable
    draw_button.clicked.connect(self.toggle_drawing_mode)
    baker_toolbar_layout.addWidget(draw_button)

    # Add an erase button
    erase_button = QPushButton("🧹 Erase")
    erase_button.setCheckable(True)  # Make it toggleable
    erase_button.clicked.connect(self.toggle_erase_mode)
    baker_toolbar_layout.addWidget(erase_button)

    # Add a color picker button
    color_picker_button = QPushButton("🎨")
    color_picker_button.clicked.connect(self.open_color_picker)
    baker_toolbar_layout.addWidget(color_picker_button)

    # Add a spacer to push the rest of the elements to the right
    spacer = QWidget()
    spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
    baker_toolbar_layout.addWidget(spacer)

    # Add the toolbar to the main layout
    self.main_layout.addWidget(self.toolbar)

export_current_state()

Export the current state as an image.

Source code in imagebaker/tabs/baker_tab.py
424
425
426
427
def export_current_state(self):
    """Export the current state as an image."""
    self.messageSignal.emit("Exporting current state...")
    self.current_canvas.export_current_state()

export_for_annotation()

Export the baked states for annotation.

Source code in imagebaker/tabs/baker_tab.py
346
347
348
349
def export_for_annotation(self):
    """Export the baked states for annotation."""
    self.messageSignal.emit("Exporting states for prediction...")
    self.current_canvas.export_baked_states(export_to_annotation_tab=True)

export_locally()

Export the baked states locally.

Source code in imagebaker/tabs/baker_tab.py
351
352
353
354
def export_locally(self):
    """Export the baked states locally."""
    self.messageSignal.emit("Exporting baked states...")
    self.current_canvas.export_baked_states()

generate_state_previews()

Generate previews for each state.

Source code in imagebaker/tabs/baker_tab.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def generate_state_previews(self):
    """Generate previews for each state."""
    # Clear existing previews
    for i in reversed(range(self.preview_layout.count())):
        widget = self.preview_layout.itemAt(i).widget()
        if widget:
            widget.deleteLater()

    # Generate a preview for each state
    for step, states in sorted(self.current_canvas.states.items()):
        # Create a container widget for the preview
        preview_widget = QWidget()
        preview_layout = QVBoxLayout(preview_widget)
        preview_layout.setContentsMargins(0, 0, 0, 0)
        preview_layout.setSpacing(2)

        # Placeholder thumbnail
        placeholder = QPixmap(50, 50)
        placeholder.fill(Qt.gray)  # Gray placeholder
        thumbnail_label = QLabel()
        thumbnail_label.setPixmap(placeholder)
        thumbnail_label.setFixedSize(50, 50)  # Set a fixed size for the thumbnail
        thumbnail_label.setScaledContents(True)

        # Add the step number on top of the thumbnail
        step_label = QLabel(f"Step {step}")
        step_label.setAlignment(Qt.AlignCenter)
        step_label.setStyleSheet("font-weight: bold; font-size: 10px;")

        # Add a button to make the preview clickable
        preview_button = QPushButton()
        preview_button.setFixedSize(
            50, 70
        )  # Match the size of the thumbnail + step label
        preview_button.setStyleSheet("background: transparent; border: none;")
        preview_button.clicked.connect(lambda _, s=step: self.seek_state(s))

        # Add the thumbnail and step label to the layout
        preview_layout.addWidget(thumbnail_label)
        preview_layout.addWidget(step_label)

        # Add the preview widget to the button
        preview_button.setLayout(preview_layout)

        # Add the button to the preview panel
        self.preview_layout.addWidget(preview_button)

        # Update the thumbnail dynamically when it becomes available
        self.current_canvas.thumbnailsAvailable.connect(
            lambda step=step, label=thumbnail_label: self.update_thumbnail(
                step, label
            )
        )

    # Refresh the preview panel
    self.preview_panel.update()

init_ui()

Initialize the UI components.

Source code in imagebaker/tabs/baker_tab.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def init_ui(self):
    """Initialize the UI components."""
    # Create toolbar
    self.create_toolbar()

    # Create a single canvas for now
    self.current_canvas = CanvasLayer(parent=self.main_window, config=self.config)
    self.current_canvas.setVisible(True)  # Initially hide all canvases
    self.canvases.append(self.current_canvas)
    self.main_layout.addWidget(self.current_canvas)

    # Create and add CanvasList
    self.canvas_list = CanvasList(self.canvases, parent=self.main_window)
    self.main_window.addDockWidget(Qt.LeftDockWidgetArea, self.canvas_list)

    # Create and add LayerList
    self.layer_settings = LayerSettings(
        parent=self.main_window,
        max_xpos=self.config.max_xpos,
        max_ypos=self.config.max_ypos,
        max_scale=self.config.max_scale,
        max_edge_width=self.config.max_edge_width,
    )
    self.layer_list = LayerList(
        canvas=self.current_canvas,
        parent=self.main_window,
        layer_settings=self.layer_settings,
    )
    self.layer_settings.setVisible(False)
    self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.layer_list)
    self.main_window.addDockWidget(Qt.RightDockWidgetArea, self.layer_settings)

    # Create a dock widget for the toolbar
    self.toolbar_dock = QDockWidget("Tools", self)
    self.toolbar_dock.setWidget(self.toolbar)
    self.toolbar_dock.setFeatures(
        QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
    )
    self.main_window.addDockWidget(Qt.BottomDockWidgetArea, self.toolbar_dock)

    # Connections
    self.layer_settings.messageSignal.connect(self.messageSignal.emit)
    self.current_canvas.bakingResult.connect(self.bakingResult.emit)
    self.current_canvas.layersChanged.connect(self.update_list)
    self.current_canvas.layerRemoved.connect(self.update_list)

    self.canvas_list.canvasSelected.connect(self.on_canvas_selected)
    self.canvas_list.canvasAdded.connect(self.on_canvas_added)
    self.canvas_list.canvasDeleted.connect(self.on_canvas_deleted)

keyPressEvent(event)

Handle key press events.

Source code in imagebaker/tabs/baker_tab.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def keyPressEvent(self, event):
    """Handle key press events."""
    # Ctrl + S: Save the current state
    curr_mode = self.current_canvas.mouse_mode
    if event.key() == Qt.Key_S and event.modifiers() == Qt.ControlModifier:
        self.save_current_state()
        self.current_canvas.mouse_mode = curr_mode
        self.current_canvas.update()
    # if ctrl + D: Toggle drawing mode
    if event.key() == Qt.Key_D and event.modifiers() == Qt.ControlModifier:
        self.toggle_drawing_mode()
        self.current_canvas.update()
    # if ctrl + E: Toggle erase mode
    if event.key() == Qt.Key_E and event.modifiers() == Qt.ControlModifier:
        self.toggle_erase_mode()
        self.current_canvas.update()

    # Delete: Delete the selected layer
    if event.key() == Qt.Key_Delete:
        if (
            self.current_canvas.selected_layer
            and self.current_canvas.selected_layer in self.current_canvas.layers
        ):
            self.current_canvas.layers.remove(self.current_canvas.selected_layer)
            self.current_canvas.selected_layer = None
            self.layer_settings.selected_layer = None

        self.current_canvas.update()
        self.layer_list.update_list()
        self.layer_settings.update_sliders()
        self.messageSignal.emit("Deleted selected layer")

    # Ctrl + N: Add a new layer to the current canvas
    if event.key() == Qt.Key_N and event.modifiers() == Qt.ControlModifier:
        new_layer = CanvasLayer(parent=self.current_canvas)
        new_layer.layer_name = f"Layer {len(self.current_canvas.layers) + 1}"
        new_layer.annotations = [
            Annotation(annotation_id=0, label="New Annotation"),
        ]
        balnk_qimage = QPixmap(self.current_canvas.size())
        balnk_qimage.fill(Qt.transparent)
        new_layer.set_image(balnk_qimage)
        self.current_canvas.layers.append(new_layer)
        self.current_canvas.update()
        self.layer_list.update_list()
        self.messageSignal.emit(f"Added new layer: {new_layer.layer_name}")

    self.update()
    return super().keyPressEvent(event)

on_canvas_added(new_canvas)

Handle the addition of a new canvas.

Source code in imagebaker/tabs/baker_tab.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def on_canvas_added(self, new_canvas: CanvasLayer):
    """Handle the addition of a new canvas."""
    logger.info(f"New canvas added: {new_canvas.layer_name}")
    self.main_layout.addWidget(new_canvas)  # Add the new canvas to the layout
    if self.current_canvas is not None:
        self.current_canvas.setVisible(False)  # Hide the current canvas

    # self.canvases.append(new_canvas)  # Add the new canvas to the deque
    # connect it to the layer list
    self.layer_list.canvas = new_canvas
    self.current_canvas = new_canvas  # Update the current canvas
    self.canvas_list.update_canvas_list()  # Update the canvas list
    new_canvas.setVisible(True)  # Hide the new canvas initially
    # already added to the list
    # self.canvases.append(new_canvas)  # Add to the deque

    self.current_canvas.bakingResult.connect(self.bakingResult.emit)
    self.current_canvas.layersChanged.connect(self.update_list)
    self.current_canvas.layerRemoved.connect(self.update_list)

    self.current_canvas.update()
    self.layer_list.layers = new_canvas.layers
    self.layer_list.update_list()
    self.layer_settings.selected_layer = None
    self.layer_settings.update_sliders()

on_canvas_deleted(canvas)

Handle the deletion of a canvas.

Source code in imagebaker/tabs/baker_tab.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def on_canvas_deleted(self, canvas: CanvasLayer):
    """Handle the deletion of a canvas."""
    # Ensure only the currently selected canvas is visible
    if self.canvases:
        self.layer_list.canvas = self.canvases[-1]
        self.layer_list.layers = self.canvases[-1].layers
        self.current_canvas = self.canvases[-1]  # Select the last canvas
        self.current_canvas.setVisible(True)  # Show the last canvas
    else:
        self.current_canvas = None  # No canvases left
        self.messageSignal.emit("No canvases available.")  # Notify the user
        self.layer_list.canvas = None
        self.layer_list.layers = []
        self.layer_settings.selected_layer = None
    self.layer_settings.update_sliders()
    self.canvas_list.update_canvas_list()  # Update the canvas list
    self.layer_list.update_list()
    self.update()

on_canvas_selected(canvas)

Handle canvas selection from the CanvasList.

Source code in imagebaker/tabs/baker_tab.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def on_canvas_selected(self, canvas: CanvasLayer):
    """Handle canvas selection from the CanvasList."""
    # Hide all canvases and show only the selected one
    for layer in self.canvases:
        layer.setVisible(layer == canvas)

    # Update the current canvas
    self.current_canvas = canvas
    self.layer_list.canvas = canvas
    self.layer_list.layers = canvas.layers
    self.layer_settings.selected_layer = canvas.selected_layer
    self.layer_list.layer_settings = self.layer_settings

    self.layer_list.update_list()
    self.layer_settings.update_sliders()

    logger.info(f"Selected canvas: {canvas.layer_name}")
    self.update()

open_color_picker()

Open a color picker dialog to select a custom color.

Source code in imagebaker/tabs/baker_tab.py
339
340
341
342
343
344
def open_color_picker(self):
    """Open a color picker dialog to select a custom color."""
    color = QColorDialog.getColor()
    if color.isValid():
        self.current_canvas.drawing_color = color
        self.messageSignal.emit(f"Selected custom color: {color.name()}")

play_saved_states()

Play the saved states in sequence.

Source code in imagebaker/tabs/baker_tab.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def play_saved_states(self):
    """Play the saved states in sequence."""
    self.messageSignal.emit("Playing saved state...")

    # Enable the timeline slider

    # Update the slider range based on the number of states
    if self.current_canvas.states:
        num_states = len(self.current_canvas.states)
        self.timeline_slider.setMaximum(num_states - 1)
        self.steps_spinbox.setValue(
            num_states
        )  # Sync the spinbox with the number of states
        self.timeline_slider.setEnabled(True)
    else:
        self.timeline_slider.setMaximum(0)
        self.steps_spinbox.setValue(1)
        self.messageSignal.emit("No saved states available.")
        self.timeline_slider.setEnabled(False)

    self.timeline_slider.update()
    # Start playing the states
    self.current_canvas.play_states()

predict_state()

Pass the current state to predict.

Source code in imagebaker/tabs/baker_tab.py
429
430
431
432
433
def predict_state(self):
    """Pass the current state to predict."""
    self.messageSignal.emit("Predicting state...")

    self.current_canvas.predict_state()

save_current_state()

Save the current state of the canvas.

Source code in imagebaker/tabs/baker_tab.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def save_current_state(self):
    """Save the current state of the canvas."""
    self.messageSignal.emit("Saving current state...")
    logger.info(f"Saving current state for {self.steps_spinbox.value()}...")

    self.current_canvas.save_current_state(steps=self.steps_spinbox.value())
    self.messageSignal.emit(
        "Current state saved. Total states: {}".format(
            len(self.current_canvas.states)
        )
    )

    self.steps_spinbox.setValue(1)  # Reset the spinbox value
    self.steps_spinbox.update()
    # Disable the timeline slider
    self.timeline_slider.setEnabled(False)
    self.timeline_slider.update()

seek_state(step)

Seek to a specific state using the timeline slider.

Source code in imagebaker/tabs/baker_tab.py
415
416
417
418
419
420
421
422
def seek_state(self, step):
    """Seek to a specific state using the timeline slider."""
    self.messageSignal.emit(f"Seeking to step {step}")
    logger.info(f"Seeking to step {step}")
    self.current_canvas.seek_state(step)

    # Update the canvas
    self.current_canvas.update()

toggle_drawing_mode()

Toggle drawing mode on the current canvas.

Source code in imagebaker/tabs/baker_tab.py
317
318
319
320
321
322
323
324
325
326
def toggle_drawing_mode(self):
    """Toggle drawing mode on the current canvas."""
    if self.current_canvas:
        self.current_canvas.mouse_mode = (
            MouseMode.DRAW
            if self.current_canvas.mouse_mode != MouseMode.DRAW
            else MouseMode.IDLE
        )
        mode = self.current_canvas.mouse_mode.name.lower()
        self.messageSignal.emit(f"Drawing mode {mode}.")

toggle_erase_mode()

Toggle drawing mode on the current canvas.

Source code in imagebaker/tabs/baker_tab.py
328
329
330
331
332
333
334
335
336
337
def toggle_erase_mode(self):
    """Toggle drawing mode on the current canvas."""
    if self.current_canvas:
        self.current_canvas.mouse_mode = (
            MouseMode.ERASE
            if self.current_canvas.mouse_mode != MouseMode.ERASE
            else MouseMode.IDLE
        )
        mode = self.current_canvas.mouse_mode.name.lower()
        self.messageSignal.emit(f"Erasing mode {mode}.")

update_list(layer=None)

Update the layer list and layer settings.

Source code in imagebaker/tabs/baker_tab.py
170
171
172
173
174
175
176
def update_list(self, layer=None):
    """Update the layer list and layer settings."""
    if layer:
        self.layer_list.layers = self.current_canvas.layers
    self.layer_list.update_list()
    self.layer_settings.update_sliders()
    self.update()

update_slider_range(steps)

Update the slider range based on the number of steps.

Source code in imagebaker/tabs/baker_tab.py
 99
100
101
102
103
104
def update_slider_range(self, steps):
    """Update the slider range based on the number of steps."""
    self.timeline_slider.setMaximum(steps - 1)
    self.messageSignal.emit(f"Updated steps to {steps}")
    self.timeline_slider.setEnabled(False)  # Disable the slider
    self.timeline_slider.update()

update_thumbnail(step, thumbnail_label)

Update the thumbnail for a specific step.

Source code in imagebaker/tabs/baker_tab.py
163
164
165
166
167
168
def update_thumbnail(self, step, thumbnail_label):
    """Update the thumbnail for a specific step."""
    if step in self.current_canvas.state_thumbnail:
        thumbnail = self.current_canvas.state_thumbnail[step]
        thumbnail_label.setPixmap(thumbnail)
        thumbnail_label.update()

List Views

Image List Panel

Bases: QDockWidget

Source code in imagebaker/list_views/image_list.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
class ImageListPanel(QDockWidget):
    imageSelected = Signal(Path)

    def __init__(
        self,
        image_entries: list["ImageEntry"],
        processed_images: set[Path],
        parent=None,
        max_name_length=15,
    ):
        """
        :param image_entries: List of image paths to display.
        :param processed_images: Set of image paths that have already been processed.
        """
        super().__init__("Image List", parent)
        self.image_entries: list["ImageEntry"] = image_entries
        self.processed_images = processed_images
        self.current_page = 0
        self.images_per_page = 10
        self.max_name_length = max_name_length
        self.init_ui()

    def init_ui(self):
        """Initialize the UI for the image list panel."""
        logger.info("Initializing ImageListPanel")
        self.setMinimumWidth(150)
        self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)

        widget = QWidget()
        layout = QVBoxLayout(widget)

        # Image list widget
        self.list_widget = QListWidget()
        self.list_widget.itemClicked.connect(self.handle_item_clicked)  # Connect signal
        layout.addWidget(self.list_widget)

        # Pagination controls
        pagination_layout = QHBoxLayout()
        self.prev_page_btn = QPushButton("Prev")
        self.prev_page_btn.clicked.connect(self.show_prev_page)
        self.next_page_btn = QPushButton("Next")
        self.next_page_btn.clicked.connect(self.show_next_page)
        pagination_layout.addWidget(self.prev_page_btn)
        pagination_layout.addWidget(self.next_page_btn)
        layout.addLayout(pagination_layout)

        # Pagination info
        self.pagination_label = QLabel("Showing 0 of 0")
        layout.addWidget(self.pagination_label)

        self.setWidget(widget)
        self.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )

        self.update_image_list(self.image_entries)

    def show_next_page(self):
        """Show the next page of images"""
        if (self.current_page + 1) * self.images_per_page < len(self.image_entries):
            self.current_page += 1
            self.update_image_list(self.image_entries)

    def show_prev_page(self):
        """Show the previous page of images"""
        if self.current_page > 0:
            self.current_page -= 1
            self.update_image_list(self.image_entries)

    def update_image_list(self, image_entries):
        """Update the image list with image paths and baked results."""
        self.list_widget.clear()

        for idx, image_entry in enumerate(image_entries):
            item_widget = QWidget()
            item_layout = QHBoxLayout(item_widget)
            item_layout.setContentsMargins(5, 5, 5, 5)

            # Generate thumbnail
            thumbnail_label = QLabel()
            if image_entry.is_baked_result:
                thumbnail_pixmap = (
                    image_entry.data.get_thumbnail()
                )  # Baked result thumbnail
                name_label_text = f"Baked Result {idx + 1}"
            else:
                thumbnail_pixmap = QPixmap(str(image_entry.data)).scaled(
                    50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
                )
                name_label_text = Path(image_entry.data).name[: self.max_name_length]

            thumbnail_label.setPixmap(thumbnail_pixmap)
            item_layout.addWidget(thumbnail_label)

            # Text for image
            name_label = QLabel(name_label_text)
            name_label.setStyleSheet("font-weight: bold;")
            item_layout.addWidget(name_label)

            item_layout.addStretch()

            # Add the custom widget to the list
            list_item = QListWidgetItem(self.list_widget)
            list_item.setSizeHint(item_widget.sizeHint())
            self.list_widget.addItem(list_item)
            self.list_widget.setItemWidget(list_item, item_widget)

            # Store metadata for the image
            list_item.setData(Qt.UserRole, image_entry)
        self.pagination_label.setText(
            f"Showing {self.current_page * self.images_per_page + 1} to "
            f"{min((self.current_page + 1) * self.images_per_page, len(image_entries))} "
            f"of {len(image_entries)}"
        )
        self.update()

    def handle_item_clicked(self, item: QListWidgetItem):
        """Handle item click and emit the imageSelected signal."""
        item_data = item.data(Qt.UserRole)
        if item_data:
            self.imageSelected.emit(item_data)

handle_item_clicked(item)

Handle item click and emit the imageSelected signal.

Source code in imagebaker/list_views/image_list.py
136
137
138
139
140
def handle_item_clicked(self, item: QListWidgetItem):
    """Handle item click and emit the imageSelected signal."""
    item_data = item.data(Qt.UserRole)
    if item_data:
        self.imageSelected.emit(item_data)

init_ui()

Initialize the UI for the image list panel.

Source code in imagebaker/list_views/image_list.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def init_ui(self):
    """Initialize the UI for the image list panel."""
    logger.info("Initializing ImageListPanel")
    self.setMinimumWidth(150)
    self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)

    widget = QWidget()
    layout = QVBoxLayout(widget)

    # Image list widget
    self.list_widget = QListWidget()
    self.list_widget.itemClicked.connect(self.handle_item_clicked)  # Connect signal
    layout.addWidget(self.list_widget)

    # Pagination controls
    pagination_layout = QHBoxLayout()
    self.prev_page_btn = QPushButton("Prev")
    self.prev_page_btn.clicked.connect(self.show_prev_page)
    self.next_page_btn = QPushButton("Next")
    self.next_page_btn.clicked.connect(self.show_next_page)
    pagination_layout.addWidget(self.prev_page_btn)
    pagination_layout.addWidget(self.next_page_btn)
    layout.addLayout(pagination_layout)

    # Pagination info
    self.pagination_label = QLabel("Showing 0 of 0")
    layout.addWidget(self.pagination_label)

    self.setWidget(widget)
    self.setFeatures(
        QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
    )

    self.update_image_list(self.image_entries)

show_next_page()

Show the next page of images

Source code in imagebaker/list_views/image_list.py
77
78
79
80
81
def show_next_page(self):
    """Show the next page of images"""
    if (self.current_page + 1) * self.images_per_page < len(self.image_entries):
        self.current_page += 1
        self.update_image_list(self.image_entries)

show_prev_page()

Show the previous page of images

Source code in imagebaker/list_views/image_list.py
83
84
85
86
87
def show_prev_page(self):
    """Show the previous page of images"""
    if self.current_page > 0:
        self.current_page -= 1
        self.update_image_list(self.image_entries)

update_image_list(image_entries)

Update the image list with image paths and baked results.

Source code in imagebaker/list_views/image_list.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def update_image_list(self, image_entries):
    """Update the image list with image paths and baked results."""
    self.list_widget.clear()

    for idx, image_entry in enumerate(image_entries):
        item_widget = QWidget()
        item_layout = QHBoxLayout(item_widget)
        item_layout.setContentsMargins(5, 5, 5, 5)

        # Generate thumbnail
        thumbnail_label = QLabel()
        if image_entry.is_baked_result:
            thumbnail_pixmap = (
                image_entry.data.get_thumbnail()
            )  # Baked result thumbnail
            name_label_text = f"Baked Result {idx + 1}"
        else:
            thumbnail_pixmap = QPixmap(str(image_entry.data)).scaled(
                50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
            )
            name_label_text = Path(image_entry.data).name[: self.max_name_length]

        thumbnail_label.setPixmap(thumbnail_pixmap)
        item_layout.addWidget(thumbnail_label)

        # Text for image
        name_label = QLabel(name_label_text)
        name_label.setStyleSheet("font-weight: bold;")
        item_layout.addWidget(name_label)

        item_layout.addStretch()

        # Add the custom widget to the list
        list_item = QListWidgetItem(self.list_widget)
        list_item.setSizeHint(item_widget.sizeHint())
        self.list_widget.addItem(list_item)
        self.list_widget.setItemWidget(list_item, item_widget)

        # Store metadata for the image
        list_item.setData(Qt.UserRole, image_entry)
    self.pagination_label.setText(
        f"Showing {self.current_page * self.images_per_page + 1} to "
        f"{min((self.current_page + 1) * self.images_per_page, len(image_entries))} "
        f"of {len(image_entries)}"
    )
    self.update()

Annotation List

Bases: QDockWidget

Source code in imagebaker/list_views/annotation_list.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
class AnnotationList(QDockWidget):
    messageSignal = Signal(str)

    def __init__(self, layer: AnnotableLayer, parent=None, max_name_length=15):
        super().__init__("Annotations", parent)
        self.layer = layer
        self.max_name_length = max_name_length
        self.init_ui()

    def init_ui(self):
        logger.info("Initializing AnnotationList")
        self.setMinimumWidth(150)
        self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)

        widget = QWidget()
        layout = QVBoxLayout(widget)
        self.list_widget = QListWidget()

        # Set the size policy for the list widget to expand dynamically
        self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

        layout.addWidget(self.list_widget)
        self.setWidget(widget)
        self.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )

    def update_list(self):
        self.list_widget.clear()
        if self.layer is None:
            return
        for idx, ann in enumerate(self.layer.annotations):
            item = QListWidgetItem(self.list_widget)

            # Create container widget
            widget = QWidget()
            widget.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed))

            layout = QHBoxLayout(widget)
            layout.setContentsMargins(1, 1, 2, 2)

            # on clicking this element, select the annotation
            widget.mousePressEvent = lambda event, i=idx: self.on_annotation_selected(i)

            widget.setCursor(Qt.PointingHandCursor)

            # Color indicator
            color_label = QLabel()
            color = QColor(ann.color)
            if not ann.visible:
                color.setAlpha(128)
            pixmap = QPixmap(20, 20)
            pixmap.fill(ann.color)
            color_label.setPixmap(pixmap)
            layout.addWidget(color_label)

            # Text container
            text_container = QWidget()
            text_layout = QVBoxLayout(text_container)
            text_layout.setContentsMargins(0, 0, 0, 0)

            # Main label with conditional color
            main_label = QLabel(f"{ann.name}")
            main_color = "#666" if not ann.visible else "black"
            main_label.setStyleSheet(f"font-weight: bold; color: {main_color};")
            text_layout.addWidget(main_label)

            # Change the text color of the selected annotation
            if ann.selected:
                main_label.setStyleSheet("font-weight: bold; color: blue;")
            else:
                main_label.setStyleSheet(f"font-weight: bold; color: {main_color};")

            # Secondary info
            secondary_text = []
            score_text = (
                f"{ann.annotator}: {ann.score:.2f}"
                if ann.score is not None
                else ann.annotator
            )
            secondary_text.append(score_text)
            short_path = ann.file_path.stem[: self.max_name_length]
            secondary_text.append(f"<span style='color:#666;'>{short_path}</span>")

            if secondary_text:
                info_color = "#888" if not ann.visible else "#444"
                info_label = QLabel("<br>".join(secondary_text))
                info_label.setStyleSheet(f"color: {info_color}; font-size: 10px;")
                text_layout.addWidget(info_label)

            text_layout.addStretch()
            layout.addWidget(text_container)
            layout.addStretch()

            # Buttons
            btn_container = QWidget()
            btn_layout = QHBoxLayout(btn_container)

            layerify_btn = QPushButton("🖨")
            layerify_btn.setFixedWidth(30)
            layerify_btn.setToolTip("Make AnnotableLayer from annotation")
            layerify_btn.clicked.connect(lambda _, i=idx: self.layerify_annotation(i))
            btn_layout.addWidget(layerify_btn)

            vis_btn = QPushButton("👀" if ann.visible else "👁️")
            vis_btn.setFixedWidth(30)
            vis_btn.setCheckable(True)
            vis_btn.setChecked(ann.visible)
            vis_btn.setToolTip("Visible" if ann.visible else "Hidden")
            vis_btn.clicked.connect(lambda _, i=idx: self.toggle_visibility(i))
            btn_layout.addWidget(vis_btn)

            del_btn = QPushButton("🗑️")
            del_btn.setFixedWidth(30)
            del_btn.setToolTip("Delete annotation")
            del_btn.clicked.connect(lambda _, i=idx: self.delete_annotation(i))
            btn_layout.addWidget(del_btn)

            layout.addWidget(btn_container)

            item.setSizeHint(widget.sizeHint())
            self.list_widget.setItemWidget(item, widget)
        self.update()

    def create_color_icon(self, color):
        pixmap = QPixmap(16, 16)
        pixmap.fill(color)
        return QIcon(pixmap)

    def on_annotation_selected(self, index):
        if 0 <= index < len(self.layer.annotations):
            ann = self.layer.annotations[index]
            ann.selected = not ann.selected
            # Set other annotations to not selected
            for i, a in enumerate(self.layer.annotations):
                if i != index:
                    a.selected = False
            self.layer.update()
            self.update_list()

    def delete_annotation(self, index):
        if 0 <= index < len(self.layer.annotations):
            logger.info(f"Deleting annotation: {self.layer.annotations[index].label}")
            del self.layer.annotations[index]
            self.layer.update()
            self.update_list()
            logger.info("Annotation deleted")

    def toggle_visibility(self, index):
        if 0 <= index < len(self.layer.annotations):
            ann = self.layer.annotations[index]
            ann.visible = not ann.visible
            self.layer.update()
            self.update_list()
            logger.info(f"Annotation visibility toggled: {ann.label}, {ann.visible}")

    def layerify_annotation(self, index):
        if 0 <= index < len(self.layer.annotations):
            ann = self.layer.annotations[index]
            logger.info(f"Layerifying annotation: {ann.label}")

            self.update_list()
            self.layer.layerify_annotation([ann])
            self.layer.update()

    def sizeHint(self):
        """Calculate the preferred size based on the content."""
        base_size = super().sizeHint()
        content_width = self.list_widget.sizeHintForColumn(0) + 40  # Add padding
        return QSize(max(base_size.width(), content_width), base_size.height())

    def keyPressEvent(self, event):
        key = event.key()
        logger.info(f"Key pressed in AnnotationList: {key}")
        if event.key() == Qt.Key_C and event.modifiers() == Qt.ControlModifier:
            self.layer.copy_annotation()
        elif event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier:
            self.layer.paste_annotation()
        elif event.key() == Qt.Key_Delete:
            self.delete_annotation(self.layer.selected_annotation_index)
        else:
            self.parentWidget().keyPressEvent(event)

sizeHint()

Calculate the preferred size based on the content.

Source code in imagebaker/list_views/annotation_list.py
188
189
190
191
192
def sizeHint(self):
    """Calculate the preferred size based on the content."""
    base_size = super().sizeHint()
    content_width = self.list_widget.sizeHintForColumn(0) + 40  # Add padding
    return QSize(max(base_size.width(), content_width), base_size.height())

Canvas List

Bases: QDockWidget

Source code in imagebaker/list_views/canvas_list.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
class CanvasList(QDockWidget):
    canvasSelected = Signal(CanvasLayer)
    canvasDeleted = Signal(CanvasLayer)
    canvasAdded = Signal(CanvasLayer)

    def __init__(self, canvases: list[CanvasLayer], parent=None):
        """
        :param canvases: List of CanvasLayer objects to display.
        """
        super().__init__("Canvas List", parent)
        self.canvases = canvases
        self.current_page = 0
        self.canvases_per_page = 10
        self.init_ui()

    def init_ui(self):
        """Initialize the UI for the canvas list panel."""
        logger.info("Initializing CanvasList")
        self.setMinimumWidth(150)
        self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)

        widget = QWidget()
        layout = QVBoxLayout(widget)

        # Add "Create New Canvas" button
        self.create_canvas_button = QPushButton("Create New Canvas")
        self.create_canvas_button.clicked.connect(self.create_new_canvas)
        layout.addWidget(self.create_canvas_button)

        # Canvas list widget
        self.list_widget = QListWidget()
        self.list_widget.itemClicked.connect(self.handle_item_clicked)
        layout.addWidget(self.list_widget)
        # set first item as selected
        if len(self.canvases) > 0:
            self.list_widget.setCurrentRow(0)

        # Pagination controls
        pagination_layout = QHBoxLayout()
        self.prev_page_btn = QPushButton("Prev")
        self.prev_page_btn.clicked.connect(self.show_prev_page)
        self.next_page_btn = QPushButton("Next")
        self.next_page_btn.clicked.connect(self.show_next_page)
        pagination_layout.addWidget(self.prev_page_btn)
        pagination_layout.addWidget(self.next_page_btn)
        layout.addLayout(pagination_layout)

        # Pagination info
        self.pagination_label = QLabel("Showing 0 of 0")
        layout.addWidget(self.pagination_label)

        self.setWidget(widget)
        self.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )

        self.update_canvas_list()

    def create_new_canvas(self):
        """Create a new canvas and emit the canvasAdded signal."""
        canvas_idx = len(self.canvases) + 1
        default_name = f"Canvas {canvas_idx}"

        # Show input dialog to ask for canvas name
        canvas_name, ok = QInputDialog.getText(
            self, "New Canvas", "Enter canvas name:", text=default_name
        )

        # If the user cancels or provides an empty name, use the default name
        if not ok or not canvas_name.strip():
            canvas_name = default_name

        # Create the new canvas
        new_canvas = CanvasLayer(parent=self.parent())
        new_canvas.layer_name = canvas_name  # Assign the name to the canvas
        self.canvases.append(new_canvas)  # Add the new canvas to the list
        self.canvasAdded.emit(
            new_canvas
        )  # Emit the signal to notify about the new canvas
        self.update_canvas_list()  # Refresh the canvas list

    def show_next_page(self):
        """Show the next page of canvases."""
        if (self.current_page + 1) * self.canvases_per_page < len(self.canvases):
            self.current_page += 1
            self.update_canvas_list()

    def show_prev_page(self):
        """Show the previous page of canvases."""
        if self.current_page > 0:
            self.current_page -= 1
            self.update_canvas_list()

    def update_canvas_list(self):
        """Update the canvas list with pagination."""
        self.list_widget.clear()

        canvases_list = list(self.canvases)

        start_idx = self.current_page * self.canvases_per_page
        end_idx = min(start_idx + self.canvases_per_page, len(canvases_list))

        for idx, canvas in enumerate(canvases_list[start_idx:end_idx], start=start_idx):
            item_widget = QWidget()
            item_layout = QHBoxLayout(item_widget)
            item_layout.setContentsMargins(5, 5, 5, 5)

            # Thumbnail
            thumbnail_label = QLabel()
            thumbnail_pixmap = canvas.get_thumbnail().scaled(
                50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
            )
            thumbnail_label.setPixmap(thumbnail_pixmap)
            item_layout.addWidget(thumbnail_label)

            # Canvas name
            canvas_name = getattr(canvas, "layer_name", f"Canvas {idx + 1}")
            name_label = QLabel(canvas_name)
            name_label.setStyleSheet("font-weight: bold;")
            item_layout.addWidget(name_label)

            # Delete button
            delete_button = QPushButton("Delete")
            delete_button.setStyleSheet(
                "background-color: red; color: white; font-weight: bold;"
            )
            delete_button.clicked.connect(partial(self.delete_canvas, canvas))
            item_layout.addWidget(delete_button)

            item_layout.addStretch()

            # Add the custom widget to the list
            list_item = QListWidgetItem(self.list_widget)
            list_item.setSizeHint(item_widget.sizeHint())
            self.list_widget.addItem(list_item)
            self.list_widget.setItemWidget(list_item, item_widget)

            # Store metadata for the canvas
            list_item.setData(Qt.UserRole, canvas)

        # Select the first item by default if it exists
        if self.list_widget.count() > 0:
            self.list_widget.setCurrentRow(0)
            first_item = self.list_widget.item(0)
            self.handle_item_clicked(first_item)

        self.pagination_label.setText(
            f"Showing {start_idx + 1} to {end_idx} of {len(canvases_list)}"
        )
        self.update()

    def handle_item_clicked(self, item: QListWidgetItem):
        """Handle item click and emit the canvasSelected signal."""
        canvas = item.data(Qt.UserRole)
        if canvas:
            self.canvasSelected.emit(canvas)

    def delete_canvas(self, canvas: CanvasLayer):
        """Delete a canvas from the list."""
        if canvas in self.canvases:
            canvas.layers.clear()
            logger.info(f"Deleting canvas: {canvas.layer_name}")
            canvas.setVisible(False)
            canvas.deleteLater()
            self.canvases.remove(canvas)
            self.canvasDeleted.emit(canvas)
            self.update_canvas_list()

create_new_canvas()

Create a new canvas and emit the canvasAdded signal.

Source code in imagebaker/list_views/canvas_list.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def create_new_canvas(self):
    """Create a new canvas and emit the canvasAdded signal."""
    canvas_idx = len(self.canvases) + 1
    default_name = f"Canvas {canvas_idx}"

    # Show input dialog to ask for canvas name
    canvas_name, ok = QInputDialog.getText(
        self, "New Canvas", "Enter canvas name:", text=default_name
    )

    # If the user cancels or provides an empty name, use the default name
    if not ok or not canvas_name.strip():
        canvas_name = default_name

    # Create the new canvas
    new_canvas = CanvasLayer(parent=self.parent())
    new_canvas.layer_name = canvas_name  # Assign the name to the canvas
    self.canvases.append(new_canvas)  # Add the new canvas to the list
    self.canvasAdded.emit(
        new_canvas
    )  # Emit the signal to notify about the new canvas
    self.update_canvas_list()  # Refresh the canvas list

delete_canvas(canvas)

Delete a canvas from the list.

Source code in imagebaker/list_views/canvas_list.py
176
177
178
179
180
181
182
183
184
185
def delete_canvas(self, canvas: CanvasLayer):
    """Delete a canvas from the list."""
    if canvas in self.canvases:
        canvas.layers.clear()
        logger.info(f"Deleting canvas: {canvas.layer_name}")
        canvas.setVisible(False)
        canvas.deleteLater()
        self.canvases.remove(canvas)
        self.canvasDeleted.emit(canvas)
        self.update_canvas_list()

handle_item_clicked(item)

Handle item click and emit the canvasSelected signal.

Source code in imagebaker/list_views/canvas_list.py
170
171
172
173
174
def handle_item_clicked(self, item: QListWidgetItem):
    """Handle item click and emit the canvasSelected signal."""
    canvas = item.data(Qt.UserRole)
    if canvas:
        self.canvasSelected.emit(canvas)

init_ui()

Initialize the UI for the canvas list panel.

Source code in imagebaker/list_views/canvas_list.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def init_ui(self):
    """Initialize the UI for the canvas list panel."""
    logger.info("Initializing CanvasList")
    self.setMinimumWidth(150)
    self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)

    widget = QWidget()
    layout = QVBoxLayout(widget)

    # Add "Create New Canvas" button
    self.create_canvas_button = QPushButton("Create New Canvas")
    self.create_canvas_button.clicked.connect(self.create_new_canvas)
    layout.addWidget(self.create_canvas_button)

    # Canvas list widget
    self.list_widget = QListWidget()
    self.list_widget.itemClicked.connect(self.handle_item_clicked)
    layout.addWidget(self.list_widget)
    # set first item as selected
    if len(self.canvases) > 0:
        self.list_widget.setCurrentRow(0)

    # Pagination controls
    pagination_layout = QHBoxLayout()
    self.prev_page_btn = QPushButton("Prev")
    self.prev_page_btn.clicked.connect(self.show_prev_page)
    self.next_page_btn = QPushButton("Next")
    self.next_page_btn.clicked.connect(self.show_next_page)
    pagination_layout.addWidget(self.prev_page_btn)
    pagination_layout.addWidget(self.next_page_btn)
    layout.addLayout(pagination_layout)

    # Pagination info
    self.pagination_label = QLabel("Showing 0 of 0")
    layout.addWidget(self.pagination_label)

    self.setWidget(widget)
    self.setFeatures(
        QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
    )

    self.update_canvas_list()

show_next_page()

Show the next page of canvases.

Source code in imagebaker/list_views/canvas_list.py
100
101
102
103
104
def show_next_page(self):
    """Show the next page of canvases."""
    if (self.current_page + 1) * self.canvases_per_page < len(self.canvases):
        self.current_page += 1
        self.update_canvas_list()

show_prev_page()

Show the previous page of canvases.

Source code in imagebaker/list_views/canvas_list.py
106
107
108
109
110
def show_prev_page(self):
    """Show the previous page of canvases."""
    if self.current_page > 0:
        self.current_page -= 1
        self.update_canvas_list()

update_canvas_list()

Update the canvas list with pagination.

Source code in imagebaker/list_views/canvas_list.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def update_canvas_list(self):
    """Update the canvas list with pagination."""
    self.list_widget.clear()

    canvases_list = list(self.canvases)

    start_idx = self.current_page * self.canvases_per_page
    end_idx = min(start_idx + self.canvases_per_page, len(canvases_list))

    for idx, canvas in enumerate(canvases_list[start_idx:end_idx], start=start_idx):
        item_widget = QWidget()
        item_layout = QHBoxLayout(item_widget)
        item_layout.setContentsMargins(5, 5, 5, 5)

        # Thumbnail
        thumbnail_label = QLabel()
        thumbnail_pixmap = canvas.get_thumbnail().scaled(
            50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
        )
        thumbnail_label.setPixmap(thumbnail_pixmap)
        item_layout.addWidget(thumbnail_label)

        # Canvas name
        canvas_name = getattr(canvas, "layer_name", f"Canvas {idx + 1}")
        name_label = QLabel(canvas_name)
        name_label.setStyleSheet("font-weight: bold;")
        item_layout.addWidget(name_label)

        # Delete button
        delete_button = QPushButton("Delete")
        delete_button.setStyleSheet(
            "background-color: red; color: white; font-weight: bold;"
        )
        delete_button.clicked.connect(partial(self.delete_canvas, canvas))
        item_layout.addWidget(delete_button)

        item_layout.addStretch()

        # Add the custom widget to the list
        list_item = QListWidgetItem(self.list_widget)
        list_item.setSizeHint(item_widget.sizeHint())
        self.list_widget.addItem(list_item)
        self.list_widget.setItemWidget(list_item, item_widget)

        # Store metadata for the canvas
        list_item.setData(Qt.UserRole, canvas)

    # Select the first item by default if it exists
    if self.list_widget.count() > 0:
        self.list_widget.setCurrentRow(0)
        first_item = self.list_widget.item(0)
        self.handle_item_clicked(first_item)

    self.pagination_label.setText(
        f"Showing {start_idx + 1} to {end_idx} of {len(canvases_list)}"
    )
    self.update()

Layer List

Bases: QDockWidget

Source code in imagebaker/list_views/layer_list.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
class LayerList(QDockWidget):

    layersSelected = Signal(list)
    messageSignal = Signal(str)

    def __init__(
        self,
        canvas: Canvas,
        layer_settings: LayerSettings,
        parent=None,
    ):
        super().__init__("Layers", parent)
        self.canvas = canvas
        self.layers: list[BaseLayer] = []
        self.layer_settings = layer_settings
        self.init_ui()

    def init_ui(self):
        logger.info("Initializing LayerList")
        main_widget = QWidget()
        main_layout = QVBoxLayout(main_widget)
        self.setMinimumWidth(150)

        # Create list widget for layers
        self.list_widget = QListWidget()
        self.list_widget.setSelectionMode(QListWidget.MultiSelection)

        # Enable drag and drop in the list widget
        self.list_widget.setDragEnabled(True)
        self.list_widget.setAcceptDrops(True)
        self.list_widget.setDropIndicatorShown(True)
        self.list_widget.setDragDropMode(QAbstractItemView.InternalMove)

        # Connect signals
        self.list_widget.itemClicked.connect(self.on_item_clicked)
        self.list_widget.model().rowsMoved.connect(self.on_rows_moved)
        self.list_widget.keyPressEvent = self.list_key_press_event

        # Add list and buttons to main layout
        main_layout.addWidget(self.list_widget)
        # main_layout.addWidget(delete_button)

        # Set main widget
        self.setWidget(main_widget)
        self.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )

    def clear_layers(self):
        """Clear all layers from the list"""
        self.layers.clear()
        self.update_list()

    def on_rows_moved(self, parent, start, end, destination, row):
        """Handle rows being moved in the list widget via drag and drop"""
        # Calculate the source and destination indices
        source_index = start
        dest_index = row

        # If moving down, we need to adjust the destination index
        if dest_index > source_index:
            dest_index -= 1

        # Reorder the layers accordingly
        if 0 <= source_index < len(self.layers) and 0 <= dest_index < len(self.layers):
            # Move the layer in our internal list
            layer = self.layers.pop(source_index)
            self.layers.insert(dest_index, layer)
            layer.order = dest_index

            # Update the canvas with the new layer order
            self.canvas.layers = self.layers
            self.canvas.update()

            # Update the UI
            self.update_list()
            logger.info(
                f"BaseLayer: {layer.layer_name} moved from {source_index} to {dest_index}"
            )

    def update_list(self):
        """Update the list widget with current layers"""
        # Remember current selection
        selected_row = self.list_widget.currentRow()

        # Clear the list
        self.list_widget.clear()
        selected_layer = None

        if not self.canvas:
            # No canvas, show dialog and return
            logger.warning("No canvas found")
            QDialog.critical(
                self,
                "Error",
                "No canvas found. Please create a canvas first.",
                QDialog.StandardButton.Ok,
            )

        # Add all layers to the list
        for idx, layer in enumerate(self.layers):
            if layer.selected:
                selected_layer = layer

            # Get annotation info
            ann = layer.annotations[0]
            thumbnail = layer.get_thumbnail(annotation=ann)

            # Create widget for this layer item
            widget = QWidget()
            layout = QHBoxLayout(widget)
            layout.setContentsMargins(5, 5, 5, 5)

            # Add thumbnail
            thumbnail_label = QLabel()
            thumbnail_label.setPixmap(thumbnail)
            layout.addWidget(thumbnail_label)

            # Add text info
            text_container = QWidget()
            text_layout = QVBoxLayout(text_container)

            # Main label
            main_label = QLabel(ann.label)
            text_color = "#666" if not layer.visible else "black"
            if layer.selected:
                text_color = "blue"
            main_label.setStyleSheet(f"font-weight: bold; color: {text_color};")
            text_layout.addWidget(main_label)

            # Add secondary info if available
            secondary_text = []
            score_text = (
                f"{ann.annotator}: {ann.score:.2f}"
                if ann.score is not None
                else ann.annotator
            )
            secondary_text.append(score_text)
            short_path = ann.file_path.stem
            secondary_text.append(f"<span style='color:#666;'>{short_path}</span>")

            if secondary_text:
                info_color = "#888" if not layer.visible else "#444"
                info_label = QLabel("<br>".join(secondary_text))
                info_label.setStyleSheet(f"color: {info_color}; font-size: 10px;")
                info_label.setTextFormat(Qt.RichText)
                text_layout.addWidget(info_label)

            text_layout.addStretch()
            layout.addWidget(text_container)
            layout.addStretch()

            # Add control buttons
            btn_container = QWidget()
            btn_layout = QHBoxLayout(btn_container)

            # Visibility button
            vis_btn = QPushButton("👀" if layer.visible else "👁️")
            vis_btn.setMaximumWidth(self.canvas.config.normal_draw_config.button_width)
            vis_btn.setCheckable(True)
            vis_btn.setChecked(layer.visible)
            vis_btn.setToolTip("Visible" if layer.visible else "Hidden")

            # Store layer index for button callbacks
            vis_btn.setProperty("layer_index", idx)
            vis_btn.clicked.connect(
                lambda checked, btn=vis_btn: self.toggle_visibility(
                    btn.property("layer_index")
                )
            )
            btn_layout.addWidget(vis_btn)

            # Delete button
            del_btn = QPushButton("🗑️")
            del_btn.setMaximumWidth(self.canvas.config.normal_draw_config.button_width)
            del_btn.setToolTip("Delete annotation")
            del_btn.setProperty("layer_index", idx)
            del_btn.clicked.connect(
                lambda checked=False, idx=idx: self.confirm_delete_layer(idx)
            )
            btn_layout.addWidget(del_btn)

            # a checkbox to toggle layer annotation export
            export_checkbox = QCheckBox()
            export_checkbox.setChecked(layer.allow_annotation_export)
            export_checkbox.setToolTip("Toggle annotation export")
            export_checkbox.setProperty("layer_index", idx)
            export_checkbox.stateChanged.connect(
                lambda state, idx=idx: self.toggle_annotation_export(idx, state)
            )
            btn_layout.addWidget(export_checkbox)

            layout.addWidget(btn_container)

            # Add item to list widget
            item = QListWidgetItem()
            item.setSizeHint(widget.sizeHint())
            self.list_widget.addItem(item)
            self.list_widget.setItemWidget(item, widget)

        # Restore selection if possible
        if selected_row >= 0 and selected_row < self.list_widget.count():
            self.list_widget.setCurrentRow(selected_row)

        # Update layer settings panel
        if selected_layer:
            self.layer_settings.set_selected_layer(selected_layer)
        else:
            self.layer_settings.set_selected_layer(None)
        self.update()

    def toggle_annotation_export(self, index, state):
        """Toggle annotation export for a layer by index"""
        if 0 <= index < len(self.layers):
            layer = self.layers[index]
            layer.allow_annotation_export = not layer.allow_annotation_export
            logger.info(
                f"BaseLayer annotation export toggled: {layer.layer_name}, {layer.allow_annotation_export}"
            )
            self.update_list()
            layer.update()
            self.canvas.update()

    def on_item_clicked(self, item):
        """Handle layer selection with:
        - Left click only: Toggle clicked layer and deselect others
        - Ctrl+Left click: Toggle clicked layer only (keep others selected)"""
        modifiers = QApplication.keyboardModifiers()
        current_row = self.list_widget.row(item)

        if 0 <= current_row < len(self.layers):
            current_layer = self.layers[current_row]

            if modifiers & Qt.ControlModifier:
                # Ctrl+Click: Toggle just this layer's selection
                current_layer.selected = not current_layer.selected
                selected_layers = [layer for layer in self.layers if layer.selected]
            else:
                # Normal click: Toggle this layer and deselect all others
                was_selected = current_layer.selected
                for layer in self.layers:
                    layer.selected = False
                current_layer.selected = not was_selected  # Toggle
                selected_layers = [current_layer] if current_layer.selected else []

            # Update UI
            self.layersSelected.emit(selected_layers)
            self.layer_settings.set_selected_layer(
                selected_layers[0] if selected_layers else None
            )
            self.canvas.update()
            self.update_list()

            logger.info(f"Selected layers: {[l.layer_name for l in selected_layers]}")

    def on_layer_selected(self, indices):
        """Select multiple layers by indices"""
        selected_layers = []
        for i, layer in enumerate(self.layers):
            if i in indices:
                layer.selected = True
                selected_layers.append(layer)
            else:
                layer.selected = False

        # Emit the selected layers
        self.layersSelected.emit(selected_layers)

        # Update UI
        self.layer_settings.set_selected_layer(
            selected_layers[0] if selected_layers else None
        )
        self.canvas.update()
        self.update_list()

    def confirm_delete_layer(self, index):
        """Show confirmation dialog before deleting a layer"""
        if 0 <= index < len(self.layers):
            msg_box = QMessageBox()
            msg_box.setIcon(QMessageBox.Question)
            msg_box.setWindowTitle("Confirm Deletion")
            msg_box.setText("Are you sure you want to delete this layer?")
            msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            msg_box.setDefaultButton(QMessageBox.No)

            result = msg_box.exec_()
            if result == QMessageBox.Yes:
                self.delete_layer(index)

    def list_key_press_event(self, event):
        """Handle key press events in the list widget"""
        if event.key() == Qt.Key_Delete:
            current_row = self.list_widget.currentRow()
            if current_row >= 0:
                self.confirm_delete_layer(current_row)
        else:
            # Pass other key events to the parent class
            QListWidget.keyPressEvent(self.list_widget, event)

    def delete_selected_layer(self):
        """Delete the currently selected layer with confirmation"""
        current_row = self.list_widget.currentRow()
        if current_row >= 0:
            self.confirm_delete_layer(current_row)

    def delete_layer(self, index):
        """Delete a layer by index"""
        if 0 <= index < len(self.layers):
            logger.info(f"Deleting layer: {self.layers[index].layer_name}")
            del self.layers[index]
            self.update_list()
            self.canvas.layers = self.layers
            self.canvas.update()
            logger.info(f"BaseLayer deleted: {index}")

    def toggle_visibility(self, index):
        """Toggle visibility of a layer by index"""
        if 0 <= index < len(self.layers):
            layer = self.layers[index]
            layer.visible = not layer.visible
            logger.info(
                f"BaseLayer visibility toggled: {layer.layer_name}, {layer.visible}"
            )
            self.update_list()
            self.canvas.update()

    def add_layer(self, layer: BaseLayer = None):
        """Add a new layer to the list"""
        if layer is None:
            return
        self.layers.append(layer)
        self.update_list()
        self.canvas.layers = self.layers
        # self.canvas.update()

    def select_layer(self, layer):
        """Select a specific layer"""
        logger.info(f"Selecting layer: {layer.layer_name}")
        self.update_list()

    def get_selected_layers(self):
        """Returns list of currently selected BaseLayer objects"""
        selected_items = self.list_widget.selectedItems()
        return [
            self.layers[self.list_widget.row(item)]
            for item in selected_items
            if 0 <= self.list_widget.row(item) < len(self.layers)
        ]

    def keyPressEvent(self, event):
        """Handle key presses."""
        self.selected_layer = self.canvas.selected_layer
        if self.selected_layer is None:
            return
        if event.key() == Qt.Key_Delete:
            self.canvas.delete_layer()
        elif event.key() == Qt.Key_Escape:
            self.canvas.selected_layer = None
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
            self.canvas.copy_layer()
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
            self.canvas.paste_layer()
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
            # self.selected_layer.undo()
            self.messageSignal.emit("Undo not implemented yet")

add_layer(layer=None)

Add a new layer to the list

Source code in imagebaker/list_views/layer_list.py
352
353
354
355
356
357
358
def add_layer(self, layer: BaseLayer = None):
    """Add a new layer to the list"""
    if layer is None:
        return
    self.layers.append(layer)
    self.update_list()
    self.canvas.layers = self.layers

clear_layers()

Clear all layers from the list

Source code in imagebaker/list_views/layer_list.py
74
75
76
77
def clear_layers(self):
    """Clear all layers from the list"""
    self.layers.clear()
    self.update_list()

confirm_delete_layer(index)

Show confirmation dialog before deleting a layer

Source code in imagebaker/list_views/layer_list.py
301
302
303
304
305
306
307
308
309
310
311
312
313
def confirm_delete_layer(self, index):
    """Show confirmation dialog before deleting a layer"""
    if 0 <= index < len(self.layers):
        msg_box = QMessageBox()
        msg_box.setIcon(QMessageBox.Question)
        msg_box.setWindowTitle("Confirm Deletion")
        msg_box.setText("Are you sure you want to delete this layer?")
        msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
        msg_box.setDefaultButton(QMessageBox.No)

        result = msg_box.exec_()
        if result == QMessageBox.Yes:
            self.delete_layer(index)

delete_layer(index)

Delete a layer by index

Source code in imagebaker/list_views/layer_list.py
331
332
333
334
335
336
337
338
339
def delete_layer(self, index):
    """Delete a layer by index"""
    if 0 <= index < len(self.layers):
        logger.info(f"Deleting layer: {self.layers[index].layer_name}")
        del self.layers[index]
        self.update_list()
        self.canvas.layers = self.layers
        self.canvas.update()
        logger.info(f"BaseLayer deleted: {index}")

delete_selected_layer()

Delete the currently selected layer with confirmation

Source code in imagebaker/list_views/layer_list.py
325
326
327
328
329
def delete_selected_layer(self):
    """Delete the currently selected layer with confirmation"""
    current_row = self.list_widget.currentRow()
    if current_row >= 0:
        self.confirm_delete_layer(current_row)

get_selected_layers()

Returns list of currently selected BaseLayer objects

Source code in imagebaker/list_views/layer_list.py
366
367
368
369
370
371
372
373
def get_selected_layers(self):
    """Returns list of currently selected BaseLayer objects"""
    selected_items = self.list_widget.selectedItems()
    return [
        self.layers[self.list_widget.row(item)]
        for item in selected_items
        if 0 <= self.list_widget.row(item) < len(self.layers)
    ]

keyPressEvent(event)

Handle key presses.

Source code in imagebaker/list_views/layer_list.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def keyPressEvent(self, event):
    """Handle key presses."""
    self.selected_layer = self.canvas.selected_layer
    if self.selected_layer is None:
        return
    if event.key() == Qt.Key_Delete:
        self.canvas.delete_layer()
    elif event.key() == Qt.Key_Escape:
        self.canvas.selected_layer = None
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
        self.canvas.copy_layer()
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
        self.canvas.paste_layer()
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
        # self.selected_layer.undo()
        self.messageSignal.emit("Undo not implemented yet")

list_key_press_event(event)

Handle key press events in the list widget

Source code in imagebaker/list_views/layer_list.py
315
316
317
318
319
320
321
322
323
def list_key_press_event(self, event):
    """Handle key press events in the list widget"""
    if event.key() == Qt.Key_Delete:
        current_row = self.list_widget.currentRow()
        if current_row >= 0:
            self.confirm_delete_layer(current_row)
    else:
        # Pass other key events to the parent class
        QListWidget.keyPressEvent(self.list_widget, event)

on_item_clicked(item)

Handle layer selection with: - Left click only: Toggle clicked layer and deselect others - Ctrl+Left click: Toggle clicked layer only (keep others selected)

Source code in imagebaker/list_views/layer_list.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def on_item_clicked(self, item):
    """Handle layer selection with:
    - Left click only: Toggle clicked layer and deselect others
    - Ctrl+Left click: Toggle clicked layer only (keep others selected)"""
    modifiers = QApplication.keyboardModifiers()
    current_row = self.list_widget.row(item)

    if 0 <= current_row < len(self.layers):
        current_layer = self.layers[current_row]

        if modifiers & Qt.ControlModifier:
            # Ctrl+Click: Toggle just this layer's selection
            current_layer.selected = not current_layer.selected
            selected_layers = [layer for layer in self.layers if layer.selected]
        else:
            # Normal click: Toggle this layer and deselect all others
            was_selected = current_layer.selected
            for layer in self.layers:
                layer.selected = False
            current_layer.selected = not was_selected  # Toggle
            selected_layers = [current_layer] if current_layer.selected else []

        # Update UI
        self.layersSelected.emit(selected_layers)
        self.layer_settings.set_selected_layer(
            selected_layers[0] if selected_layers else None
        )
        self.canvas.update()
        self.update_list()

        logger.info(f"Selected layers: {[l.layer_name for l in selected_layers]}")

on_layer_selected(indices)

Select multiple layers by indices

Source code in imagebaker/list_views/layer_list.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def on_layer_selected(self, indices):
    """Select multiple layers by indices"""
    selected_layers = []
    for i, layer in enumerate(self.layers):
        if i in indices:
            layer.selected = True
            selected_layers.append(layer)
        else:
            layer.selected = False

    # Emit the selected layers
    self.layersSelected.emit(selected_layers)

    # Update UI
    self.layer_settings.set_selected_layer(
        selected_layers[0] if selected_layers else None
    )
    self.canvas.update()
    self.update_list()

on_rows_moved(parent, start, end, destination, row)

Handle rows being moved in the list widget via drag and drop

Source code in imagebaker/list_views/layer_list.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def on_rows_moved(self, parent, start, end, destination, row):
    """Handle rows being moved in the list widget via drag and drop"""
    # Calculate the source and destination indices
    source_index = start
    dest_index = row

    # If moving down, we need to adjust the destination index
    if dest_index > source_index:
        dest_index -= 1

    # Reorder the layers accordingly
    if 0 <= source_index < len(self.layers) and 0 <= dest_index < len(self.layers):
        # Move the layer in our internal list
        layer = self.layers.pop(source_index)
        self.layers.insert(dest_index, layer)
        layer.order = dest_index

        # Update the canvas with the new layer order
        self.canvas.layers = self.layers
        self.canvas.update()

        # Update the UI
        self.update_list()
        logger.info(
            f"BaseLayer: {layer.layer_name} moved from {source_index} to {dest_index}"
        )

select_layer(layer)

Select a specific layer

Source code in imagebaker/list_views/layer_list.py
361
362
363
364
def select_layer(self, layer):
    """Select a specific layer"""
    logger.info(f"Selecting layer: {layer.layer_name}")
    self.update_list()

toggle_annotation_export(index, state)

Toggle annotation export for a layer by index

Source code in imagebaker/list_views/layer_list.py
237
238
239
240
241
242
243
244
245
246
247
def toggle_annotation_export(self, index, state):
    """Toggle annotation export for a layer by index"""
    if 0 <= index < len(self.layers):
        layer = self.layers[index]
        layer.allow_annotation_export = not layer.allow_annotation_export
        logger.info(
            f"BaseLayer annotation export toggled: {layer.layer_name}, {layer.allow_annotation_export}"
        )
        self.update_list()
        layer.update()
        self.canvas.update()

toggle_visibility(index)

Toggle visibility of a layer by index

Source code in imagebaker/list_views/layer_list.py
341
342
343
344
345
346
347
348
349
350
def toggle_visibility(self, index):
    """Toggle visibility of a layer by index"""
    if 0 <= index < len(self.layers):
        layer = self.layers[index]
        layer.visible = not layer.visible
        logger.info(
            f"BaseLayer visibility toggled: {layer.layer_name}, {layer.visible}"
        )
        self.update_list()
        self.canvas.update()

update_list()

Update the list widget with current layers

Source code in imagebaker/list_views/layer_list.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def update_list(self):
    """Update the list widget with current layers"""
    # Remember current selection
    selected_row = self.list_widget.currentRow()

    # Clear the list
    self.list_widget.clear()
    selected_layer = None

    if not self.canvas:
        # No canvas, show dialog and return
        logger.warning("No canvas found")
        QDialog.critical(
            self,
            "Error",
            "No canvas found. Please create a canvas first.",
            QDialog.StandardButton.Ok,
        )

    # Add all layers to the list
    for idx, layer in enumerate(self.layers):
        if layer.selected:
            selected_layer = layer

        # Get annotation info
        ann = layer.annotations[0]
        thumbnail = layer.get_thumbnail(annotation=ann)

        # Create widget for this layer item
        widget = QWidget()
        layout = QHBoxLayout(widget)
        layout.setContentsMargins(5, 5, 5, 5)

        # Add thumbnail
        thumbnail_label = QLabel()
        thumbnail_label.setPixmap(thumbnail)
        layout.addWidget(thumbnail_label)

        # Add text info
        text_container = QWidget()
        text_layout = QVBoxLayout(text_container)

        # Main label
        main_label = QLabel(ann.label)
        text_color = "#666" if not layer.visible else "black"
        if layer.selected:
            text_color = "blue"
        main_label.setStyleSheet(f"font-weight: bold; color: {text_color};")
        text_layout.addWidget(main_label)

        # Add secondary info if available
        secondary_text = []
        score_text = (
            f"{ann.annotator}: {ann.score:.2f}"
            if ann.score is not None
            else ann.annotator
        )
        secondary_text.append(score_text)
        short_path = ann.file_path.stem
        secondary_text.append(f"<span style='color:#666;'>{short_path}</span>")

        if secondary_text:
            info_color = "#888" if not layer.visible else "#444"
            info_label = QLabel("<br>".join(secondary_text))
            info_label.setStyleSheet(f"color: {info_color}; font-size: 10px;")
            info_label.setTextFormat(Qt.RichText)
            text_layout.addWidget(info_label)

        text_layout.addStretch()
        layout.addWidget(text_container)
        layout.addStretch()

        # Add control buttons
        btn_container = QWidget()
        btn_layout = QHBoxLayout(btn_container)

        # Visibility button
        vis_btn = QPushButton("👀" if layer.visible else "👁️")
        vis_btn.setMaximumWidth(self.canvas.config.normal_draw_config.button_width)
        vis_btn.setCheckable(True)
        vis_btn.setChecked(layer.visible)
        vis_btn.setToolTip("Visible" if layer.visible else "Hidden")

        # Store layer index for button callbacks
        vis_btn.setProperty("layer_index", idx)
        vis_btn.clicked.connect(
            lambda checked, btn=vis_btn: self.toggle_visibility(
                btn.property("layer_index")
            )
        )
        btn_layout.addWidget(vis_btn)

        # Delete button
        del_btn = QPushButton("🗑️")
        del_btn.setMaximumWidth(self.canvas.config.normal_draw_config.button_width)
        del_btn.setToolTip("Delete annotation")
        del_btn.setProperty("layer_index", idx)
        del_btn.clicked.connect(
            lambda checked=False, idx=idx: self.confirm_delete_layer(idx)
        )
        btn_layout.addWidget(del_btn)

        # a checkbox to toggle layer annotation export
        export_checkbox = QCheckBox()
        export_checkbox.setChecked(layer.allow_annotation_export)
        export_checkbox.setToolTip("Toggle annotation export")
        export_checkbox.setProperty("layer_index", idx)
        export_checkbox.stateChanged.connect(
            lambda state, idx=idx: self.toggle_annotation_export(idx, state)
        )
        btn_layout.addWidget(export_checkbox)

        layout.addWidget(btn_container)

        # Add item to list widget
        item = QListWidgetItem()
        item.setSizeHint(widget.sizeHint())
        self.list_widget.addItem(item)
        self.list_widget.setItemWidget(item, widget)

    # Restore selection if possible
    if selected_row >= 0 and selected_row < self.list_widget.count():
        self.list_widget.setCurrentRow(selected_row)

    # Update layer settings panel
    if selected_layer:
        self.layer_settings.set_selected_layer(selected_layer)
    else:
        self.layer_settings.set_selected_layer(None)
    self.update()

Layer Settings

Bases: QDockWidget

Source code in imagebaker/list_views/layer_settings.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
class LayerSettings(QDockWidget):
    layerState = Signal(LayerState)
    messageSignal = Signal(str)

    def __init__(
        self,
        parent=None,
        max_xpos=1000,
        max_ypos=1000,
        max_scale=100,
        max_edge_width=10,
    ):
        super().__init__("BaseLayer Settings", parent)
        self.selected_layer: BaseLayer = None

        self._disable_updates = False
        self.last_updated_time = 0
        self.max_xpos = max_xpos
        self.max_ypos = max_ypos
        self.max_scale = max_scale
        self.max_edge_width = max_edge_width
        self.init_ui()
        self.setFeatures(
            QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
        )
        self.update_sliders()

    def init_ui(self):
        """Initialize the UI elements."""
        logger.info("Initializing LayerSettings")
        self.widget = QWidget()
        self.setWidget(self.widget)
        self.main_layout = QVBoxLayout(self.widget)
        self.main_layout.setContentsMargins(10, 10, 10, 10)  # Add some padding
        self.main_layout.setSpacing(10)

        # BaseLayer layer_name label
        self.layer_name_label = QLabel("No BaseLayer Selected")
        self.layer_name_label.setAlignment(Qt.AlignCenter)
        self.layer_name_label.setStyleSheet("font-weight: bold; font-size: 14px;")
        self.main_layout.addWidget(self.layer_name_label)

        # Opacity slider
        self.opacity_slider = self.create_slider("Opacity:", 0, 255, 255, 1)
        self.main_layout.addWidget(self.opacity_slider["widget"])
        self.x_slider = self.create_slider("X:", -self.max_xpos, self.max_xpos, 0, 1)
        self.main_layout.addWidget(self.x_slider["widget"])
        self.y_slider = self.create_slider("Y:", -self.max_ypos, self.max_ypos, 0, 1)
        self.main_layout.addWidget(self.y_slider["widget"])
        self.scale_x_slider = self.create_slider(
            "Scale X:", -self.max_scale, self.max_scale, 100, 100
        )  # 1-500 becomes 0.01-5.0
        self.main_layout.addWidget(self.scale_x_slider["widget"])
        self.scale_y_slider = self.create_slider(
            "Scale Y:", -self.max_scale, self.max_scale, 100, 100
        )
        self.main_layout.addWidget(self.scale_y_slider["widget"])
        self.rotation_slider = self.create_slider("Rotation:", 0, 360, 0, 1)
        self.main_layout.addWidget(self.rotation_slider["widget"])
        self.edge_opacity_slider = self.create_slider("Edge Opacity:", 0, 255, 255, 1)
        self.main_layout.addWidget(self.edge_opacity_slider["widget"])
        self.edge_width_slider = self.create_slider(
            "Edge Width:", 0, self.max_edge_width, 5, 1
        )
        self.main_layout.addWidget(self.edge_width_slider["widget"])

        # Add stretch to push content to the top
        self.main_layout.addStretch()

        # Ensure the dock widget resizes properly
        self.setMinimumWidth(250)  # Minimum width for usability
        self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

    def create_slider(self, label, min_val, max_val, default, scale_factor=1):
        """Create a slider with a label and value display."""
        container = QWidget()
        layout = QHBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)  # Remove inner margins

        # Label
        lbl = QLabel(label)
        lbl.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)

        # Slider
        slider = QSlider(Qt.Horizontal)
        slider.setRange(min_val, max_val)
        slider.setValue(default)
        slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

        # Value label
        value_lbl = QLabel(f"{default / scale_factor:.1f}")
        value_lbl.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)

        # Connect slider to value label (still updates during drag)
        slider.valueChanged.connect(
            lambda v: value_lbl.setText(f"{v / scale_factor:.1f}")
        )

        # Only update layer on slider release
        slider.sliderReleased.connect(self.on_slider_released)

        # Add widgets to layout
        layout.addWidget(lbl)
        layout.addWidget(slider)
        layout.addWidget(value_lbl)

        return {
            "widget": container,
            "slider": slider,
            "label": value_lbl,
            "scale_factor": scale_factor,
        }

    def on_slider_released(self):
        """Update layer only when slider is released"""
        if self._disable_updates or not self.selected_layer:
            return

        sender = self.sender()  # Get which slider was released
        value = sender.value()

        try:
            self._disable_updates = True
            if sender == self.opacity_slider["slider"]:
                self.selected_layer.opacity = value
            elif sender == self.x_slider["slider"]:
                self.selected_layer.position.setX(value / self.x_slider["scale_factor"])
            elif sender == self.y_slider["slider"]:
                self.selected_layer.position.setY(value / self.y_slider["scale_factor"])
            elif sender == self.scale_x_slider["slider"]:
                self.selected_layer.scale_x = value / 100.0
            elif sender == self.scale_y_slider["slider"]:
                self.selected_layer.scale_y = value / 100.0
            elif sender == self.rotation_slider["slider"]:
                self.selected_layer.rotation = value
            elif sender == self.edge_opacity_slider["slider"]:
                self.selected_layer.edge_opacity = value
                self.selected_layer._apply_edge_opacity()
            elif sender == self.edge_width_slider["slider"]:
                self.selected_layer.edge_width = value
                self.selected_layer._apply_edge_opacity()

            self.selected_layer.update()  # Trigger a repaint

        finally:
            self._disable_updates = False

    def emit_bake_settings(self):
        """Emit the bake settings signal."""
        bake_settings = LayerState(
            layer_id=self.selected_layer.id,
            order=self.selected_layer.order,
            layer_name=self.selected_layer.layer_name,
            position=self.selected_layer.position,
            rotation=self.selected_layer.rotation,
            scale_x=self.selected_layer.scale_x,
            scale_y=self.selected_layer.scale_y,
            opacity=self.selected_layer.opacity,
            edge_opacity=self.selected_layer.edge_opacity,
            edge_width=self.selected_layer.edge_width,
            visible=self.selected_layer.visible,
        )
        logger.info(f"Storing state {bake_settings}")
        self.messageSignal.emit(f"Stored state for {bake_settings.layer_name}")
        self.layerState.emit(bake_settings)

    def set_selected_layer(self, layer):
        """Set the currently selected layer."""
        self.selected_layer = layer
        self.update_sliders()

    def update_sliders(self):
        """Update slider values based on the selected layer."""
        self.widget.setEnabled(False)
        if self._disable_updates or not self.selected_layer:
            return

        try:
            self._disable_updates = True

            if self.selected_layer.selected:
                self.widget.setEnabled(True)
                self.layer_name_label.setText(
                    f"BaseLayer: {self.selected_layer.layer_name}"
                )
                new_max_xpos = self.selected_layer.config.max_xpos
                new_max_ypos = self.selected_layer.config.max_ypos

                if new_max_xpos - abs(self.selected_layer.position.x()) < 50:
                    new_max_xpos = abs(self.selected_layer.position.x()) + 50
                if new_max_ypos - abs(self.selected_layer.position.y()) < 50:
                    new_max_ypos = abs(self.selected_layer.position.y()) + 50

                # Update slider ranges
                self.x_slider["slider"].setRange(
                    -new_max_xpos,
                    new_max_xpos,
                )
                self.y_slider["slider"].setRange(
                    -new_max_ypos,
                    new_max_ypos,
                )

                # Update slider values
                self.opacity_slider["slider"].setValue(
                    int(self.selected_layer.opacity)  # Scale back to 0-255
                )
                self.x_slider["slider"].setValue(int(self.selected_layer.position.x()))
                self.y_slider["slider"].setValue(int(self.selected_layer.position.y()))
                self.scale_x_slider["slider"].setValue(
                    int(self.selected_layer.scale_x * 100)
                )
                self.scale_y_slider["slider"].setValue(
                    int(self.selected_layer.scale_y * 100)
                )
                self.rotation_slider["slider"].setValue(
                    int(self.selected_layer.rotation)
                )
                self.edge_opacity_slider["slider"].setValue(
                    int(self.selected_layer.edge_opacity)
                )
                self.edge_width_slider["slider"].setValue(
                    int(self.selected_layer.edge_width)
                )
            else:
                self.widget.setEnabled(False)
                self.layer_name_label.setText("No BaseLayer")
        finally:
            self._disable_updates = False
        self.update()

create_slider(label, min_val, max_val, default, scale_factor=1)

Create a slider with a label and value display.

Source code in imagebaker/list_views/layer_settings.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def create_slider(self, label, min_val, max_val, default, scale_factor=1):
    """Create a slider with a label and value display."""
    container = QWidget()
    layout = QHBoxLayout(container)
    layout.setContentsMargins(0, 0, 0, 0)  # Remove inner margins

    # Label
    lbl = QLabel(label)
    lbl.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)

    # Slider
    slider = QSlider(Qt.Horizontal)
    slider.setRange(min_val, max_val)
    slider.setValue(default)
    slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)

    # Value label
    value_lbl = QLabel(f"{default / scale_factor:.1f}")
    value_lbl.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)

    # Connect slider to value label (still updates during drag)
    slider.valueChanged.connect(
        lambda v: value_lbl.setText(f"{v / scale_factor:.1f}")
    )

    # Only update layer on slider release
    slider.sliderReleased.connect(self.on_slider_released)

    # Add widgets to layout
    layout.addWidget(lbl)
    layout.addWidget(slider)
    layout.addWidget(value_lbl)

    return {
        "widget": container,
        "slider": slider,
        "label": value_lbl,
        "scale_factor": scale_factor,
    }

emit_bake_settings()

Emit the bake settings signal.

Source code in imagebaker/list_views/layer_settings.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def emit_bake_settings(self):
    """Emit the bake settings signal."""
    bake_settings = LayerState(
        layer_id=self.selected_layer.id,
        order=self.selected_layer.order,
        layer_name=self.selected_layer.layer_name,
        position=self.selected_layer.position,
        rotation=self.selected_layer.rotation,
        scale_x=self.selected_layer.scale_x,
        scale_y=self.selected_layer.scale_y,
        opacity=self.selected_layer.opacity,
        edge_opacity=self.selected_layer.edge_opacity,
        edge_width=self.selected_layer.edge_width,
        visible=self.selected_layer.visible,
    )
    logger.info(f"Storing state {bake_settings}")
    self.messageSignal.emit(f"Stored state for {bake_settings.layer_name}")
    self.layerState.emit(bake_settings)

init_ui()

Initialize the UI elements.

Source code in imagebaker/list_views/layer_settings.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def init_ui(self):
    """Initialize the UI elements."""
    logger.info("Initializing LayerSettings")
    self.widget = QWidget()
    self.setWidget(self.widget)
    self.main_layout = QVBoxLayout(self.widget)
    self.main_layout.setContentsMargins(10, 10, 10, 10)  # Add some padding
    self.main_layout.setSpacing(10)

    # BaseLayer layer_name label
    self.layer_name_label = QLabel("No BaseLayer Selected")
    self.layer_name_label.setAlignment(Qt.AlignCenter)
    self.layer_name_label.setStyleSheet("font-weight: bold; font-size: 14px;")
    self.main_layout.addWidget(self.layer_name_label)

    # Opacity slider
    self.opacity_slider = self.create_slider("Opacity:", 0, 255, 255, 1)
    self.main_layout.addWidget(self.opacity_slider["widget"])
    self.x_slider = self.create_slider("X:", -self.max_xpos, self.max_xpos, 0, 1)
    self.main_layout.addWidget(self.x_slider["widget"])
    self.y_slider = self.create_slider("Y:", -self.max_ypos, self.max_ypos, 0, 1)
    self.main_layout.addWidget(self.y_slider["widget"])
    self.scale_x_slider = self.create_slider(
        "Scale X:", -self.max_scale, self.max_scale, 100, 100
    )  # 1-500 becomes 0.01-5.0
    self.main_layout.addWidget(self.scale_x_slider["widget"])
    self.scale_y_slider = self.create_slider(
        "Scale Y:", -self.max_scale, self.max_scale, 100, 100
    )
    self.main_layout.addWidget(self.scale_y_slider["widget"])
    self.rotation_slider = self.create_slider("Rotation:", 0, 360, 0, 1)
    self.main_layout.addWidget(self.rotation_slider["widget"])
    self.edge_opacity_slider = self.create_slider("Edge Opacity:", 0, 255, 255, 1)
    self.main_layout.addWidget(self.edge_opacity_slider["widget"])
    self.edge_width_slider = self.create_slider(
        "Edge Width:", 0, self.max_edge_width, 5, 1
    )
    self.main_layout.addWidget(self.edge_width_slider["widget"])

    # Add stretch to push content to the top
    self.main_layout.addStretch()

    # Ensure the dock widget resizes properly
    self.setMinimumWidth(250)  # Minimum width for usability
    self.widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

on_slider_released()

Update layer only when slider is released

Source code in imagebaker/list_views/layer_settings.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def on_slider_released(self):
    """Update layer only when slider is released"""
    if self._disable_updates or not self.selected_layer:
        return

    sender = self.sender()  # Get which slider was released
    value = sender.value()

    try:
        self._disable_updates = True
        if sender == self.opacity_slider["slider"]:
            self.selected_layer.opacity = value
        elif sender == self.x_slider["slider"]:
            self.selected_layer.position.setX(value / self.x_slider["scale_factor"])
        elif sender == self.y_slider["slider"]:
            self.selected_layer.position.setY(value / self.y_slider["scale_factor"])
        elif sender == self.scale_x_slider["slider"]:
            self.selected_layer.scale_x = value / 100.0
        elif sender == self.scale_y_slider["slider"]:
            self.selected_layer.scale_y = value / 100.0
        elif sender == self.rotation_slider["slider"]:
            self.selected_layer.rotation = value
        elif sender == self.edge_opacity_slider["slider"]:
            self.selected_layer.edge_opacity = value
            self.selected_layer._apply_edge_opacity()
        elif sender == self.edge_width_slider["slider"]:
            self.selected_layer.edge_width = value
            self.selected_layer._apply_edge_opacity()

        self.selected_layer.update()  # Trigger a repaint

    finally:
        self._disable_updates = False

set_selected_layer(layer)

Set the currently selected layer.

Source code in imagebaker/list_views/layer_settings.py
185
186
187
188
def set_selected_layer(self, layer):
    """Set the currently selected layer."""
    self.selected_layer = layer
    self.update_sliders()

update_sliders()

Update slider values based on the selected layer.

Source code in imagebaker/list_views/layer_settings.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def update_sliders(self):
    """Update slider values based on the selected layer."""
    self.widget.setEnabled(False)
    if self._disable_updates or not self.selected_layer:
        return

    try:
        self._disable_updates = True

        if self.selected_layer.selected:
            self.widget.setEnabled(True)
            self.layer_name_label.setText(
                f"BaseLayer: {self.selected_layer.layer_name}"
            )
            new_max_xpos = self.selected_layer.config.max_xpos
            new_max_ypos = self.selected_layer.config.max_ypos

            if new_max_xpos - abs(self.selected_layer.position.x()) < 50:
                new_max_xpos = abs(self.selected_layer.position.x()) + 50
            if new_max_ypos - abs(self.selected_layer.position.y()) < 50:
                new_max_ypos = abs(self.selected_layer.position.y()) + 50

            # Update slider ranges
            self.x_slider["slider"].setRange(
                -new_max_xpos,
                new_max_xpos,
            )
            self.y_slider["slider"].setRange(
                -new_max_ypos,
                new_max_ypos,
            )

            # Update slider values
            self.opacity_slider["slider"].setValue(
                int(self.selected_layer.opacity)  # Scale back to 0-255
            )
            self.x_slider["slider"].setValue(int(self.selected_layer.position.x()))
            self.y_slider["slider"].setValue(int(self.selected_layer.position.y()))
            self.scale_x_slider["slider"].setValue(
                int(self.selected_layer.scale_x * 100)
            )
            self.scale_y_slider["slider"].setValue(
                int(self.selected_layer.scale_y * 100)
            )
            self.rotation_slider["slider"].setValue(
                int(self.selected_layer.rotation)
            )
            self.edge_opacity_slider["slider"].setValue(
                int(self.selected_layer.edge_opacity)
            )
            self.edge_width_slider["slider"].setValue(
                int(self.selected_layer.edge_width)
            )
        else:
            self.widget.setEnabled(False)
            self.layer_name_label.setText("No BaseLayer")
    finally:
        self._disable_updates = False
    self.update()

Layers

Base Layer

Bases: QWidget

Source code in imagebaker/layers/base_layer.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
class BaseLayer(QWidget):
    messageSignal = Signal(str)
    zoomChanged = Signal(float)
    mouseMoved = Signal(QPointF)
    annotationCleared = Signal()
    layerRemoved = Signal(int)
    layersChanged = Signal()
    layerSignal = Signal(object)

    def __init__(self, parent: QWidget, config: LayerConfig | CanvasConfig):
        """
        BaseLayer is an abstract class that represents a single layer in the canvas.
        It provides functionality for managing layer properties, handling user interactions,
        and rendering the layer's content. This class is designed to be extended by
        subclasses that implement specific layer behaviors.

        Attributes:
            id (int): Unique identifier for the layer.
            layer_state (LayerState): The current state of the layer, including properties
                like position, scale, rotation, and visibility.
            previous_state (LayerState): The previous state of the layer, used for undo operations.
            layers (list[BaseLayer]): A list of child layers associated with this layer.
            annotations (list[Annotation]): A list of annotations associated with the layer.
            selected_annotation (Optional[Annotation]): The currently selected annotation.
            current_annotation (Optional[Annotation]): The annotation currently being created or edited.
            copied_annotation (Optional[Annotation]): A copied annotation for pasting.
            image (QPixmap): The image associated with the layer.
            scale (float): The current scale of the layer.
            pan_offset (QPointF): The current pan offset of the layer.
            mouse_mode (MouseMode): The current mouse interaction mode (e.g., DRAW, PAN, IDLE).
            states (dict[int, list[LayerState]]): A dictionary of saved states for the layer,
                indexed by step.
            current_step (int): The current step in the layer's state history.
            drawing_color (QColor): The color used for drawing operations.
            brush_size (int): The size of the brush used for drawing operations.
            config (LayerConfig | CanvasConfig): Configuration settings for the layer.
            file_path (Path): The file path associated with the layer's image.
            visible (bool): Whether the layer is visible.
            selected (bool): Whether the layer is selected.
            opacity (float): The opacity of the layer (0.0 to 1.0).
            transform_origin (QPointF): The origin point for transformations (e.g., rotation, scaling).
            playing (bool): Whether the layer is currently in a "playing" state (e.g., animation).
            allow_annotation_export (bool): Whether annotations can be exported for this layer.

        Signals:
            messageSignal (str): Emitted when a message needs to be displayed.
            zoomChanged (float): Emitted when the zoom level changes.
            mouseMoved (QPointF): Emitted when the mouse moves over the layer.
            annotationCleared (): Emitted when annotations are cleared.
            layerRemoved (int): Emitted when a layer is removed.
            layersChanged (): Emitted when the layer list changes.
            layerSignal (object): Emitted with a layer-related signal.

        Methods:
            save_current_state(steps: int = 1):
                Save the current state of the layer, including intermediate states
                calculated between the previous and current states.

            set_image(image_path: Path | QPixmap | QImage):
                Set the image for the layer from a file path, QPixmap, or QImage.

            get_layer(id: str) -> "BaseLayer":
                Retrieve a child layer by its ID.

            reset_view():
                Reset the view of the layer, including scale and offset.

            clear_annotations():
                Clear all annotations associated with the layer.

            update_cursor():
                Update the cursor based on the current mouse mode.

            undo():
                Undo the last change to the layer's state.

            set_mode(mode: MouseMode):
                Set the mouse interaction mode for the layer.

            widget_to_image_pos(pos: QPointF) -> QPointF:
                Convert a widget position to an image position.

            get_thumbnail(annotation: Annotation = None) -> QPixmap:
                Generate a thumbnail for the layer or a specific annotation.

            copy() -> "BaseLayer":
                Create a copy of the layer, including its properties and annotations.

            paintEvent(event):
                Handle the paint event for the layer.

            paint_layer(painter: QPainter):
                Abstract method to paint the layer's content. Must be implemented by subclasses.

            handle_mouse_press(event: QMouseEvent):
                Abstract method to handle mouse press events. Must be implemented by subclasses.

            handle_mouse_move(event: QMouseEvent):
                Abstract method to handle mouse move events. Must be implemented by subclasses.

            handle_mouse_release(event: QMouseEvent):
                Abstract method to handle mouse release events. Must be implemented by subclasses.

            handle_wheel(event: QWheelEvent):
                Abstract method to handle wheel events. Must be implemented by subclasses.

            handle_key_press(event: QKeyEvent):
                Abstract method to handle key press events. Must be implemented by subclasses.

            handle_key_release(event: QKeyEvent):
                Abstract method to handle key release events. Must be implemented by subclasses.

        Notes:
            - This class is designed to be extended by subclasses that implement specific
            layer behaviors (e.g., drawing, annotation, image manipulation).
            - The `paint_layer` method must be implemented by subclasses to define how
            the layer's content is rendered.

        """
        super().__init__(parent)
        self.id = id(self)
        self.layer_state = LayerState(layer_id=self.id)
        self._previous_state = None
        self.thumbnails = {}
        self.label_rects = []
        self._last_state = None
        self.config = config
        self.parent_obj = parent
        self.mouse_mode = MouseMode.IDLE
        self.file_path: Path = Path("Runtime")
        self.layersChanged.connect(self.update)

        self.drag_start: QPointF = None
        self.drag_offset: QPointF = None
        self.offset: QPointF = QPointF(0, 0)
        self.pan_start: QPointF = None
        self.pan_offset: QPointF = None
        self._image = QPixmap()
        self._original_image = QPixmap()
        self.annotations: list[Annotation] = []
        self.current_annotation: Optional[Annotation] = None
        self.copied_annotation: Optional[Annotation] = None
        self.selected_annotation: Optional[Annotation] = None

        self.layers: list[BaseLayer] = []
        self.layer_masks = []
        self._back_buffer = QPixmap()
        self.current_label: str = None
        self.current_color: QColor = QColor(255, 255, 255)

        self.scale = 1.0
        self.pan_offset = QPointF(0, 0)
        self.last_pan_point = None
        self._dragging_layer = None
        self._drag_offset = QPointF(0, 0)
        self._current_hover = None
        self._active_handle = None
        self._transform_start = None
        self._is_panning = False
        self.offset = QPointF(0, 0)
        self.copied_layer: BaseLayer = None
        self.selected_layer: BaseLayer = None
        self.mouse_mode = MouseMode.IDLE
        self.prev_mouse_mode = MouseMode.IDLE
        self.states: dict[str, list[LayerState]] = dict()

        self.states: dict[int, list[LayerState]] = dict()
        self.previous_state = None
        self.current_step = 0
        self.drawing_color = QColor(Qt.red)  # Default drawing color
        self.brush_size = 5  # Default brush size

        if isinstance(config, LayerConfig):
            self.current_label = self.config.predefined_labels[0].name
            self.current_color = self.config.predefined_labels[0].color

        self.setMouseTracking(True)
        self.setFocusPolicy(Qt.StrongFocus)

    def get_layer(self, id: str) -> "BaseLayer":
        """
        Get a child layer by its ID.

        Args:
            id (str): The ID of the layer to retrieve.

        Returns:
            Child of BaseLayer: The child layer with the specified ID, or None if not found
        """
        for layer in self.layers:
            if layer.layer_id == id:
                return layer
        return None

    def save_current_state(self, steps: int = 1):
        """
        Save the current state of the layer, including intermediate states
        calculated between the previous and current states.

        Args:
            steps (int): The number of intermediate steps to calculate between

        Returns:
            None
        """
        curr_states = {}
        mode = self.mouse_mode

        for layer in self.layers:
            # Calculate intermediate states between previous_state and current_state
            intermediate_states = calculate_intermediate_states(
                layer.previous_state, layer.layer_state.copy(), steps
            )
            is_selected = layer.selected

            for step, state in enumerate(intermediate_states):
                step += self.current_step

                logger.info(f"Saving state {step} for layer {layer.layer_id}")
                state.selected = False
                if step not in curr_states:
                    curr_states[step] = []

                # Deep copy the drawing states to avoid unintended modifications
                state.drawing_states = [
                    DrawingState(
                        position=d.position,
                        color=d.color,
                        size=d.size,
                    )
                    for d in layer.layer_state.drawing_states
                ]
                curr_states[step].append(state)

            # Update the layer's previous_state to the current state
            layer.previous_state = layer.layer_state.copy()
            layer.selected = is_selected

        # Save the calculated states in self.states
        for step, states in curr_states.items():
            self.states[step] = states
            self.current_step = step

        # Save the current layer's state
        self.previous_state = self.layer_state.copy()
        self.layer_state.drawing_states = [
            DrawingState(
                position=d.position,
                color=d.color,
                size=d.size,
            )
            for d in self.layer_state.drawing_states
        ]

        # Emit a message signal indicating the state has been saved
        self.messageSignal.emit(f"Saved state {self.current_step}")
        self.mouse_mode = mode

        self.update()

    def minimumSizeHint(self):
        """Return the minimum size hint for the widget."""
        return QSize(100, 100)

    def widget_to_image_pos(self, pos: QPointF) -> QPointF:
        """
        Convert a widget position to an image position.
        """
        return QPointF(
            (pos.x() - self.offset.x()) / self.scale,
            (pos.y() - self.offset.y()) / self.scale,
        )

    def update_cursor(self):
        """
        Update the cursor based on the current mouse mode.
        """
        if MouseMode.POINT == self.mouse_mode:
            self.setCursor(CursorDef.POINT_CURSOR)
        elif MouseMode.RECTANGLE == self.mouse_mode:
            self.setCursor(CursorDef.RECTANGLE_CURSOR)
        elif MouseMode.POLYGON == self.mouse_mode:
            self.setCursor(CursorDef.POLYGON_CURSOR)
        elif MouseMode.PAN == self.mouse_mode:
            self.setCursor(CursorDef.PAN_CURSOR)
        elif MouseMode.IDLE == self.mouse_mode:
            self.setCursor(CursorDef.IDLE_CURSOR)
        elif MouseMode.RESIZE == self.mouse_mode:
            self.setCursor(CursorDef.RECTANGLE_CURSOR)
        elif MouseMode.RESIZE_HEIGHT == self.mouse_mode:
            self.setCursor(CursorDef.TRANSFORM_UPDOWN)
        elif MouseMode.RESIZE_WIDTH == self.mouse_mode:
            self.setCursor(CursorDef.TRANSFORM_LEFTRIGHT)
        elif MouseMode.GRAB == self.mouse_mode:
            self.setCursor(CursorDef.GRAB_CURSOR)
        elif self.mouse_mode == MouseMode.DRAW:
            # Create a custom cursor for drawing (circle representing brush size)
            self.setCursor(self._create_custom_cursor(self.drawing_color, "circle"))

        elif self.mouse_mode == MouseMode.ERASE:
            # Create a custom cursor for erasing (square representing eraser size)
            self.setCursor(self._create_custom_cursor(Qt.white, "square"))

        else:
            # Reset to default cursor
            self.setCursor(Qt.ArrowCursor)

    def _create_custom_cursor(self, color: QColor, shape: str) -> QCursor:
        """Create a custom cursor with the given color and shape."""
        pixmap = QPixmap(self.brush_size * 2, self.brush_size * 2)
        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHints(QPainter.Antialiasing)
        painter.setPen(QPen(Qt.black, 1))  # Border color for the cursor
        painter.setBrush(color)

        if shape == "circle":
            painter.drawEllipse(
                pixmap.rect().center(), self.brush_size, self.brush_size
            )
        elif shape == "square":
            painter.drawRect(pixmap.rect().adjusted(1, 1, -1, -1))

        painter.end()
        return QCursor(pixmap)

    def set_image(self, image_path: Path | QPixmap | QImage):
        """
        Set the image for the layer from a file path, QPixmap, or QImage.
        """
        if isinstance(image_path, Path):
            self.file_path = image_path

            if image_path.exists():
                self.image.load(str(image_path))
                self.reset_view()
                self.update()
        elif isinstance(image_path, QPixmap):
            self.image = image_path
            self.reset_view()
            self.update()
        elif isinstance(image_path, QImage):
            self.image = QPixmap.fromImage(image_path)
            self.reset_view()
            self.update()

        self._original_image = self.image.copy()  # Store a copy of the original image
        self.original_size = QSizeF(self.image.size())  # Store original size

    @property
    def image(self):
        """
        Get the current image of the canvas layer.

        Returns:
            QPixmap: The current image of the canvas layer.
        """
        return self._image

    @image.setter
    def image(self, value: QPixmap):
        """
        Set the image of the canvas layer.

        Args:
            value (QPixmap): The new image for the canvas layer.
        """
        self._image = value

    def _apply_edge_opacity(self):
        """
        Apply edge opacity to the image. This function modifies the edges of the image
        to have reduced opacity based on the configuration.
        """
        logger.debug("Applying edge opacity to the image.")
        edge_width = self.edge_width
        edge_opacity = self.edge_opacity

        # Convert QPixmap to QImage for pixel manipulation
        image = self._original_image.toImage()
        image = image.convertToFormat(
            QImage.Format_ARGB32
        )  # Ensure format supports alpha

        width = image.width()
        height = image.height()
        annotation = self.annotations[0] if self.annotations else None
        if annotation is None:
            return

        if annotation.rectangle:
            for x in range(width):
                for y in range(height):
                    color = image.pixelColor(x, y)
                    if color.alpha() != 0:  # If the pixel is not fully transparent
                        # Calculate horizontal and vertical distances to the edges
                        horizontal_distance = min(x, width - x - 1)
                        vertical_distance = min(y, height - y - 1)

                        # If the pixel is within the edge region
                        if (
                            horizontal_distance < edge_width
                            or vertical_distance < edge_width
                        ):
                            distance_to_edge = min(
                                horizontal_distance, vertical_distance
                            )
                            # Calculate the new alpha based on the distance to the edge
                            factor = (edge_width - distance_to_edge) / edge_width
                            new_alpha = int(
                                color.alpha()
                                * ((1 - factor) + (factor * (edge_opacity / 255.0)))
                            )
                            color.setAlpha(new_alpha)
                            image.setPixelColor(x, y, color)

        elif annotation.polygon:
            # Extract alpha channel and find contours
            alpha_image = image.convertToFormat(QImage.Format_Alpha8)
            bytes_per_line = (
                alpha_image.bytesPerLine()
            )  # Get the stride (bytes per line)
            alpha_data = alpha_image.bits().tobytes()

            # Extract only the valid data (remove padding)
            alpha_array = np.frombuffer(alpha_data, dtype=np.uint8).reshape(
                (alpha_image.height(), bytes_per_line)
            )[
                :, : alpha_image.width()
            ]  # Remove padding to match the actual width

            # Use OpenCV to find contours
            contours, _ = cv2.findContours(
                alpha_array, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
            )

            # Iterate over each pixel and apply edge opacity
            for x in range(width):
                for y in range(height):
                    color = image.pixelColor(x, y)
                    if color.alpha() != 0:  # If the pixel is not fully transparent
                        # Calculate distance to the nearest contour
                        distance_to_edge = cv2.pointPolygonTest(
                            contours[0], (x, y), True
                        )  # True for distance calculation

                        # If the pixel is within the edge region
                        if 0 <= distance_to_edge < edge_width:
                            # Calculate the new alpha based on the distance to the edge
                            factor = (edge_width - distance_to_edge) / edge_width
                            new_alpha = int(
                                color.alpha()
                                * ((1 - factor) + (factor * (edge_opacity / 255.0)))
                            )
                            color.setAlpha(new_alpha)
                            image.setPixelColor(x, y, color)

        # Convert the modified QImage back to QPixmap
        self.image = QPixmap.fromImage(image)

    def get_thumbnail(self, annotation: Annotation = None):
        """
        Generate a thumbnail for the layer or a specific annotation.
        """
        image = QPixmap(*self.config.normal_draw_config.thumbnail_size)
        image.fill(Qt.transparent)

        if annotation:
            if annotation.rectangle:
                image = self.image.copy(annotation.rectangle.toRect())
            elif annotation.polygon:
                image = self.image.copy(annotation.polygon.boundingRect().toRect())
            elif annotation.points:
                # Create a small thumbnail around the point
                thumbnail_size = 100
                thumbnail = QPixmap(thumbnail_size, thumbnail_size)
                thumbnail.fill(Qt.transparent)
                painter = QPainter(thumbnail)
                painter.setRenderHint(QPainter.Antialiasing)
                painter.setBrush(annotation.color)
                painter.setPen(Qt.NoPen)
                painter.drawEllipse(thumbnail.rect().center() + QPoint(-5, -5), 10, 10)
                painter.end()
                image = thumbnail
        else:
            if self.image:
                image = self.image.copy(
                    0, 0, *self.config.normal_draw_config.thumbnail_size
                )
            elif len(self.layers) > 0:
                image = self.layers[0].get_thumbnail()
            else:
                image = QPixmap(*self.config.normal_draw_config.thumbnail_size)
                image.fill(Qt.transparent)

        return image.scaled(*self.config.normal_draw_config.thumbnail_size)

    def copy(self):
        """
        Create a copy of the layer, including its properties and annotations.
        Should be overridden by subclasses to copy additional properties.
        """
        layer = self.__class__(self.parent_obj, self.config)
        layer.set_image(self.image)
        layer.annotations = [ann.copy() for ann in self.annotations]
        layer.layers = [layer.copy() for layer in self.layers]
        layer.layer_name = self.layer_name
        layer.position = self.position
        layer.rotation = self.rotation
        layer.scale = self.scale
        layer.scale_x = self.scale_x
        layer.scale_y = self.scale_y
        layer.opacity = self.opacity
        layer.visible = self.visible
        layer.selected = False
        layer.is_annotable = self.is_annotable
        return layer

    def set_mode(self, mode: MouseMode):
        """
        Set the mouse interaction mode for the layer.
        """
        # Preserve current annotation when changing modes
        if mode == self.mouse_mode:
            return

        # Only reset if switching to a different annotation mode
        if mode not in [MouseMode.POLYGON, MouseMode.RECTANGLE, MouseMode.POINT]:
            self.current_annotation = None

        self.mouse_mode = mode
        logger.debug(f"Layer {self.layer_id}: Mode set to {mode}")
        self.update()

    def mouseDoubleClickEvent(self, event: QMouseEvent):
        # return super().mouseDoubleClickEvent(event)
        pos = event.pos()
        self.handle_mouse_double_click(event, pos)
        self.update()
        super().mouseDoubleClickEvent(event)

    def mousePressEvent(self, event):
        self.handle_mouse_press(event)

        self.update()
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QMouseEvent):

        self.handle_mouse_move(event)
        self.update()
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event: QMouseEvent):

        self.handle_mouse_release(event)
        self.update()
        super().mouseReleaseEvent(event)

    def wheelEvent(self, event):
        self.handle_wheel(event)
        self.update()
        super().wheelEvent(event)

    def keyPressEvent(self, event: QKeyEvent):
        self.handle_key_press(event)
        self.update()
        super().keyPressEvent(event)

    def keyReleaseEvent(self, event):
        if self.is_annotable:
            self.handle_key_release(event)
        else:
            self.handle_key_release(event)
        self.update()

    def handle_mouse_double_click(self, event, pos):
        raise NotImplementedError

    def handle_mouse_press(self, event):
        """
        Handle mouse press events for selecting layers, initiating transformations,
        or starting drawing/erasing operations.

        Args:
            event (QMouseEvent): The mouse press event.
        """
        raise NotImplementedError

    def handle_mouse_move(self, event):
        """
        Handle mouse move events for panning, drawing, erasing, or transforming layers.

        Args:
            event (QMouseEvent): The mouse move event.
        """
        raise NotImplementedError

    def handle_mouse_release(self, event):
        """
        Handle mouse release events, such as resetting the active handle or stopping
        drawing/erasing operations.

        Args:
            event (QMouseEvent): The mouse release event.
        """
        raise NotImplementedError

    def handle_wheel(self, event):
        """
        Handle mouse wheel events for adjusting the brush size or zooming the canvas.

        Args:
            event (QWheelEvent): The wheel event.
        """
        raise NotImplementedError

    def handle_key_press(self, event):
        raise NotImplementedError

    def handle_key_release(self, event):
        raise NotImplementedError

    def reset_view(self):
        self.scale = 1.0
        self.offset = QPointF(0, 0)

    def clear_annotations(self):
        self.annotations.clear()
        self.selected_annotation = None
        self.current_annotation = None
        self.annotationCleared.emit()
        self.update()

    def paintEvent(self, event):
        self.paint_event()

    def paint_event(self):
        painter = QPainter(self)

        painter.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)

        painter.fillRect(
            self.rect(),
            QColor(
                self.config.normal_draw_config.background_color.red(),
                self.config.normal_draw_config.background_color.green(),
                self.config.normal_draw_config.background_color.blue(),
            ),
        )
        self.paint_layer(painter)

    def paint_layer(self, painter: QPainter):
        raise NotImplementedError

    def __del__(self):
        logger.debug(f"Layer {self.layer_id}: {self.layer_name} deleted.")

    # override update to store last state
    def update(self):
        self._last_state = self.layer_state
        self.update_cursor()
        super().update()

    def undo(self):
        if self._last_state is not None:
            self.layer_state = self._last_state
            self.update()

    # Layer ID Property
    @property
    def layer_id(self) -> int:
        return self.layer_state.layer_id

    @layer_id.setter
    def layer_id(self, value: int):
        self.layer_state.layer_id = value

    @property
    def is_annotable(self) -> bool:
        return self.layer_state.is_annotable

    @is_annotable.setter
    def is_annotable(self, value: bool):
        self.layer_state.is_annotable = value

    # Layer Name Property
    @property
    def layer_name(self) -> str:
        return self.layer_state.layer_name

    @layer_name.setter
    def layer_name(self, value: str):
        self.layer_state.layer_name = value

    # Position Property
    @property
    def position(self) -> QPointF:
        return self.layer_state.position

    @position.setter
    def position(self, value: QPointF):
        self.layer_state.position = value

    # Rotation Property
    @property
    def rotation(self) -> float:
        return self.layer_state.rotation

    @rotation.setter
    def rotation(self, value: float):
        self.layer_state.rotation = value

    # Scale Property
    @property
    def scale(self) -> float:
        return self.layer_state.scale

    @scale.setter
    def scale(self, value: float):
        self.layer_state.scale = value

    # Scale X Property
    @property
    def scale_x(self) -> float:
        return self.layer_state.scale_x

    @scale_x.setter
    def scale_x(self, value: float):
        self.layer_state.scale_x = value

    # Scale Y Property
    @property
    def scale_y(self) -> float:
        return self.layer_state.scale_y

    @scale_y.setter
    def scale_y(self, value: float):
        self.layer_state.scale_y = value

    # Transform Origin Property
    @property
    def transform_origin(self) -> QPointF:
        return self.layer_state.transform_origin

    @transform_origin.setter
    def transform_origin(self, value: QPointF):
        self.layer_state.transform_origin = value

    # Order Property
    @property
    def order(self) -> int:
        return self.layer_state.order

    @order.setter
    def order(self, value: int):
        self.layer_state.order = value

    # Visibility Property
    @property
    def visible(self) -> bool:
        return self.layer_state.visible

    @visible.setter
    def visible(self, value: bool):
        self.layer_state.visible = value

    # Annotation Export Property
    @property
    def allow_annotation_export(self) -> bool:
        return self.layer_state.allow_annotation_export

    @allow_annotation_export.setter
    def allow_annotation_export(self, value: bool):
        self.layer_state.allow_annotation_export = value

    @property
    def playing(self) -> bool:
        return self.layer_state.playing

    @playing.setter
    def playing(self, value: bool):
        self.layer_state.playing = value

    @property
    def selected(self) -> bool:
        return self.layer_state.selected

    @selected.setter
    def selected(self, value: bool):
        self.layer_state.selected = value

    @property
    def opacity(self) -> float:
        return self.layer_state.opacity

    @opacity.setter
    def opacity(self, value: float):
        self.layer_state.opacity = value

    @property
    def status(self) -> str:
        return self.layer_state.status

    @status.setter
    def status(self, value: str):
        self.layer_state.status = value

    @property
    def drawing_states(self) -> list[DrawingState]:
        return self.layer_state.drawing_states

    @drawing_states.setter
    def drawing_states(self, value: list[DrawingState]):
        self.layer_state.drawing_states = value

    @property
    def edge_opacity(self) -> int:
        return self.layer_state.edge_opacity

    @edge_opacity.setter
    def edge_opacity(self, value: int):
        self.layer_state.edge_opacity = value

    @property
    def edge_width(self) -> int:
        return self.layer_state.edge_width

    @edge_width.setter
    def edge_width(self, value: int):
        self.layer_state.edge_width = value

image property writable

Get the current image of the canvas layer.

Returns:

Name Type Description
QPixmap

The current image of the canvas layer.

copy()

Create a copy of the layer, including its properties and annotations. Should be overridden by subclasses to copy additional properties.

Source code in imagebaker/layers/base_layer.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
def copy(self):
    """
    Create a copy of the layer, including its properties and annotations.
    Should be overridden by subclasses to copy additional properties.
    """
    layer = self.__class__(self.parent_obj, self.config)
    layer.set_image(self.image)
    layer.annotations = [ann.copy() for ann in self.annotations]
    layer.layers = [layer.copy() for layer in self.layers]
    layer.layer_name = self.layer_name
    layer.position = self.position
    layer.rotation = self.rotation
    layer.scale = self.scale
    layer.scale_x = self.scale_x
    layer.scale_y = self.scale_y
    layer.opacity = self.opacity
    layer.visible = self.visible
    layer.selected = False
    layer.is_annotable = self.is_annotable
    return layer

get_layer(id)

Get a child layer by its ID.

Parameters:

Name Type Description Default
id str

The ID of the layer to retrieve.

required

Returns:

Type Description
BaseLayer

Child of BaseLayer: The child layer with the specified ID, or None if not found

Source code in imagebaker/layers/base_layer.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_layer(self, id: str) -> "BaseLayer":
    """
    Get a child layer by its ID.

    Args:
        id (str): The ID of the layer to retrieve.

    Returns:
        Child of BaseLayer: The child layer with the specified ID, or None if not found
    """
    for layer in self.layers:
        if layer.layer_id == id:
            return layer
    return None

get_thumbnail(annotation=None)

Generate a thumbnail for the layer or a specific annotation.

Source code in imagebaker/layers/base_layer.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
def get_thumbnail(self, annotation: Annotation = None):
    """
    Generate a thumbnail for the layer or a specific annotation.
    """
    image = QPixmap(*self.config.normal_draw_config.thumbnail_size)
    image.fill(Qt.transparent)

    if annotation:
        if annotation.rectangle:
            image = self.image.copy(annotation.rectangle.toRect())
        elif annotation.polygon:
            image = self.image.copy(annotation.polygon.boundingRect().toRect())
        elif annotation.points:
            # Create a small thumbnail around the point
            thumbnail_size = 100
            thumbnail = QPixmap(thumbnail_size, thumbnail_size)
            thumbnail.fill(Qt.transparent)
            painter = QPainter(thumbnail)
            painter.setRenderHint(QPainter.Antialiasing)
            painter.setBrush(annotation.color)
            painter.setPen(Qt.NoPen)
            painter.drawEllipse(thumbnail.rect().center() + QPoint(-5, -5), 10, 10)
            painter.end()
            image = thumbnail
    else:
        if self.image:
            image = self.image.copy(
                0, 0, *self.config.normal_draw_config.thumbnail_size
            )
        elif len(self.layers) > 0:
            image = self.layers[0].get_thumbnail()
        else:
            image = QPixmap(*self.config.normal_draw_config.thumbnail_size)
            image.fill(Qt.transparent)

    return image.scaled(*self.config.normal_draw_config.thumbnail_size)

handle_mouse_move(event)

Handle mouse move events for panning, drawing, erasing, or transforming layers.

Parameters:

Name Type Description Default
event QMouseEvent

The mouse move event.

required
Source code in imagebaker/layers/base_layer.py
614
615
616
617
618
619
620
621
def handle_mouse_move(self, event):
    """
    Handle mouse move events for panning, drawing, erasing, or transforming layers.

    Args:
        event (QMouseEvent): The mouse move event.
    """
    raise NotImplementedError

handle_mouse_press(event)

Handle mouse press events for selecting layers, initiating transformations, or starting drawing/erasing operations.

Parameters:

Name Type Description Default
event QMouseEvent

The mouse press event.

required
Source code in imagebaker/layers/base_layer.py
604
605
606
607
608
609
610
611
612
def handle_mouse_press(self, event):
    """
    Handle mouse press events for selecting layers, initiating transformations,
    or starting drawing/erasing operations.

    Args:
        event (QMouseEvent): The mouse press event.
    """
    raise NotImplementedError

handle_mouse_release(event)

Handle mouse release events, such as resetting the active handle or stopping drawing/erasing operations.

Parameters:

Name Type Description Default
event QMouseEvent

The mouse release event.

required
Source code in imagebaker/layers/base_layer.py
623
624
625
626
627
628
629
630
631
def handle_mouse_release(self, event):
    """
    Handle mouse release events, such as resetting the active handle or stopping
    drawing/erasing operations.

    Args:
        event (QMouseEvent): The mouse release event.
    """
    raise NotImplementedError

handle_wheel(event)

Handle mouse wheel events for adjusting the brush size or zooming the canvas.

Parameters:

Name Type Description Default
event QWheelEvent

The wheel event.

required
Source code in imagebaker/layers/base_layer.py
633
634
635
636
637
638
639
640
def handle_wheel(self, event):
    """
    Handle mouse wheel events for adjusting the brush size or zooming the canvas.

    Args:
        event (QWheelEvent): The wheel event.
    """
    raise NotImplementedError

minimumSizeHint()

Return the minimum size hint for the widget.

Source code in imagebaker/layers/base_layer.py
285
286
287
def minimumSizeHint(self):
    """Return the minimum size hint for the widget."""
    return QSize(100, 100)

save_current_state(steps=1)

Save the current state of the layer, including intermediate states calculated between the previous and current states.

Parameters:

Name Type Description Default
steps int

The number of intermediate steps to calculate between

1

Returns:

Type Description

None

Source code in imagebaker/layers/base_layer.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def save_current_state(self, steps: int = 1):
    """
    Save the current state of the layer, including intermediate states
    calculated between the previous and current states.

    Args:
        steps (int): The number of intermediate steps to calculate between

    Returns:
        None
    """
    curr_states = {}
    mode = self.mouse_mode

    for layer in self.layers:
        # Calculate intermediate states between previous_state and current_state
        intermediate_states = calculate_intermediate_states(
            layer.previous_state, layer.layer_state.copy(), steps
        )
        is_selected = layer.selected

        for step, state in enumerate(intermediate_states):
            step += self.current_step

            logger.info(f"Saving state {step} for layer {layer.layer_id}")
            state.selected = False
            if step not in curr_states:
                curr_states[step] = []

            # Deep copy the drawing states to avoid unintended modifications
            state.drawing_states = [
                DrawingState(
                    position=d.position,
                    color=d.color,
                    size=d.size,
                )
                for d in layer.layer_state.drawing_states
            ]
            curr_states[step].append(state)

        # Update the layer's previous_state to the current state
        layer.previous_state = layer.layer_state.copy()
        layer.selected = is_selected

    # Save the calculated states in self.states
    for step, states in curr_states.items():
        self.states[step] = states
        self.current_step = step

    # Save the current layer's state
    self.previous_state = self.layer_state.copy()
    self.layer_state.drawing_states = [
        DrawingState(
            position=d.position,
            color=d.color,
            size=d.size,
        )
        for d in self.layer_state.drawing_states
    ]

    # Emit a message signal indicating the state has been saved
    self.messageSignal.emit(f"Saved state {self.current_step}")
    self.mouse_mode = mode

    self.update()

set_image(image_path)

Set the image for the layer from a file path, QPixmap, or QImage.

Source code in imagebaker/layers/base_layer.py
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def set_image(self, image_path: Path | QPixmap | QImage):
    """
    Set the image for the layer from a file path, QPixmap, or QImage.
    """
    if isinstance(image_path, Path):
        self.file_path = image_path

        if image_path.exists():
            self.image.load(str(image_path))
            self.reset_view()
            self.update()
    elif isinstance(image_path, QPixmap):
        self.image = image_path
        self.reset_view()
        self.update()
    elif isinstance(image_path, QImage):
        self.image = QPixmap.fromImage(image_path)
        self.reset_view()
        self.update()

    self._original_image = self.image.copy()  # Store a copy of the original image
    self.original_size = QSizeF(self.image.size())  # Store original size

set_mode(mode)

Set the mouse interaction mode for the layer.

Source code in imagebaker/layers/base_layer.py
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def set_mode(self, mode: MouseMode):
    """
    Set the mouse interaction mode for the layer.
    """
    # Preserve current annotation when changing modes
    if mode == self.mouse_mode:
        return

    # Only reset if switching to a different annotation mode
    if mode not in [MouseMode.POLYGON, MouseMode.RECTANGLE, MouseMode.POINT]:
        self.current_annotation = None

    self.mouse_mode = mode
    logger.debug(f"Layer {self.layer_id}: Mode set to {mode}")
    self.update()

update_cursor()

Update the cursor based on the current mouse mode.

Source code in imagebaker/layers/base_layer.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def update_cursor(self):
    """
    Update the cursor based on the current mouse mode.
    """
    if MouseMode.POINT == self.mouse_mode:
        self.setCursor(CursorDef.POINT_CURSOR)
    elif MouseMode.RECTANGLE == self.mouse_mode:
        self.setCursor(CursorDef.RECTANGLE_CURSOR)
    elif MouseMode.POLYGON == self.mouse_mode:
        self.setCursor(CursorDef.POLYGON_CURSOR)
    elif MouseMode.PAN == self.mouse_mode:
        self.setCursor(CursorDef.PAN_CURSOR)
    elif MouseMode.IDLE == self.mouse_mode:
        self.setCursor(CursorDef.IDLE_CURSOR)
    elif MouseMode.RESIZE == self.mouse_mode:
        self.setCursor(CursorDef.RECTANGLE_CURSOR)
    elif MouseMode.RESIZE_HEIGHT == self.mouse_mode:
        self.setCursor(CursorDef.TRANSFORM_UPDOWN)
    elif MouseMode.RESIZE_WIDTH == self.mouse_mode:
        self.setCursor(CursorDef.TRANSFORM_LEFTRIGHT)
    elif MouseMode.GRAB == self.mouse_mode:
        self.setCursor(CursorDef.GRAB_CURSOR)
    elif self.mouse_mode == MouseMode.DRAW:
        # Create a custom cursor for drawing (circle representing brush size)
        self.setCursor(self._create_custom_cursor(self.drawing_color, "circle"))

    elif self.mouse_mode == MouseMode.ERASE:
        # Create a custom cursor for erasing (square representing eraser size)
        self.setCursor(self._create_custom_cursor(Qt.white, "square"))

    else:
        # Reset to default cursor
        self.setCursor(Qt.ArrowCursor)

widget_to_image_pos(pos)

Convert a widget position to an image position.

Source code in imagebaker/layers/base_layer.py
289
290
291
292
293
294
295
296
def widget_to_image_pos(self, pos: QPointF) -> QPointF:
    """
    Convert a widget position to an image position.
    """
    return QPointF(
        (pos.x() - self.offset.x()) / self.scale,
        (pos.y() - self.offset.y()) / self.scale,
    )

Canvas Layer

Bases: BaseLayer

Source code in imagebaker/layers/canvas_layer.py
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
class CanvasLayer(BaseLayer):
    layersChanged = Signal()
    layerSelected = Signal(BaseLayer)
    annotationAdded = Signal(Annotation)
    annotationUpdated = Signal(Annotation)
    bakingResult = Signal(BakingResult)
    thumbnailsAvailable = Signal(int)

    def __init__(self, parent=None, config=CanvasConfig()):
        """
        Initialize the CanvasLayer with a parent widget and configuration.

        Args:
            parent (QWidget, optional): The parent widget for this layer. Defaults to None.
            config (CanvasConfig): Configuration settings for the canvas layer.
        """
        super().__init__(parent, config)
        self.is_annotable = False
        self.last_pan_point = None
        self.state_thumbnail = dict()

        self._last_draw_point = None  # Track the last point for smooth drawing

    def init_ui(self):
        """
        Initialize the user interface for the canvas layer, including size policies
        and storing the original size of the layer.
        """
        logger.info(f"Initializing Layer UI of {self.layer_name}")
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.original_size = QSizeF(self.image.size())  # Store original size

    def handle_key_release(self, event: QKeyEvent):
        """
        Handle key release events, such as resetting the mouse mode when the Control key is released.

        Args:
            event (QKeyEvent): The key release event.
        """
        if event.key() == Qt.Key_Control:
            if self.mouse_mode not in [MouseMode.DRAW, MouseMode.ERASE]:
                self.mouse_mode = MouseMode.IDLE

    def _update_back_buffer(self):
        """
        Update the back buffer for the canvas layer by rendering all visible layers
        with their transformations and opacity settings.
        """
        # Initialize the back buffer
        self._back_buffer = QPixmap(self.size())
        self._back_buffer.fill(Qt.GlobalColor.transparent)

        # Initialize the layer masks dictionary if it doesn't exist
        if not hasattr(self, "layer_masks"):
            self.layer_masks = {}

        painter = QPainter(self._back_buffer)
        try:
            painter.setRenderHints(
                QPainter.RenderHint.Antialiasing
                | QPainter.RenderHint.SmoothPixmapTransform
            )

            for layer in self.layers:
                if layer.visible and not layer.image.isNull():
                    # Save the painter state
                    painter.save()

                    # Apply layer transformations
                    painter.translate(layer.position)
                    painter.rotate(layer.rotation)
                    painter.scale(layer.scale, layer.scale)

                    # Draw the layer onto the back buffer
                    painter.setOpacity(layer.opacity)
                    painter.drawPixmap(QPoint(0, 0), layer.image)

                    # Restore the painter state
                    painter.restore()
        finally:
            painter.end()

        self.image = self._back_buffer

    ## Helper functions ##
    def handle_key_press(self, event: QKeyEvent):

        # Handle Delete key
        if event.key() == Qt.Key_Delete:
            self._delete_layer()
            return  # Important: return after handling

        # Handle Ctrl key
        if event.key() == Qt.Key_Control:
            if self.mouse_mode not in [MouseMode.DRAW, MouseMode.ERASE]:
                self.mouse_mode = MouseMode.PAN

            return  # Important: return after handling

        # Handle Ctrl+C
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
            self._copy_layer()
            return  # Important: return after handling

        # Handle Ctrl+V
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
            self._paste_layer()
            return  # Important: return after handling

    def paint_layer(self, painter: QPainter):
        """
        Paint the canvas layer, including all visible layers, their transformations,
        and any drawing states or selection indicators.

        Args:
            painter (QPainter): The painter object used for rendering.
        """
        painter.translate(self.pan_offset)
        painter.scale(self.scale, self.scale)
        for layer in self.layers:
            if layer.visible and not layer.image.isNull():
                painter.save()
                painter.translate(layer.position)
                painter.rotate(layer.rotation)
                painter.scale(layer.scale_x, layer.scale_y)

                # painter.drawPixmap(0, 0, layer.image)
                # painter.setOpacity(layer.opacity / 255)
                # Create a new pixmap with adjusted opacity
                pixmap_with_alpha = QPixmap(layer.image.size())
                pixmap_with_alpha.fill(Qt.transparent)  # Ensure transparency

                # Use QPainter to apply opacity to the pixmap
                temp_painter = QPainter(pixmap_with_alpha)
                opacity = layer.opacity / 255.0
                temp_painter.setOpacity(opacity)  # Scale opacity to 0.0-1.0
                temp_painter.drawPixmap(0, 0, layer.image)

                temp_painter.end()

                # Draw the modified pixmap
                painter.drawPixmap(0, 0, pixmap_with_alpha)

                if layer.selected:
                    painter.setPen(
                        QPen(
                            self.config.selected_draw_config.color,
                            self.config.selected_draw_config.line_width,
                        )
                    )
                    painter.setBrush(
                        QBrush(
                            QColor(
                                self.config.selected_draw_config.color.red(),
                                self.config.selected_draw_config.color.green(),
                                self.config.selected_draw_config.color.blue(),
                                self.config.selected_draw_config.brush_alpha,
                            )
                        )
                    )
                    painter.drawRect(QRectF(QPointF(0, 0), layer.original_size))
                painter.restore()

                if layer.selected:
                    self._draw_transform_handles(painter, layer)
                if layer.layer_state.drawing_states:
                    painter.save()
                    painter.translate(layer.position)
                    painter.rotate(layer.rotation)
                    painter.scale(layer.scale_x, layer.scale_y)

                    for state in layer.layer_state.drawing_states:
                        painter.setRenderHints(QPainter.Antialiasing)
                        painter.setPen(
                            QPen(
                                state.color,
                                state.size,
                                Qt.SolidLine,
                                Qt.RoundCap,
                                Qt.RoundJoin,
                            )
                        )
                        # Draw the point after applying transformations
                        painter.drawPoint(state.position)

                    painter.restore()
        if self.layer_state.drawing_states:
            painter.save()
            painter.translate(self.position)
            painter.rotate(self.rotation)
            painter.scale(self.scale_x, self.scale_y)

            for state in self.layer_state.drawing_states:
                painter.setRenderHints(QPainter.Antialiasing)
                painter.setPen(
                    QPen(
                        state.color,
                        state.size,
                        Qt.SolidLine,
                        Qt.RoundCap,
                        Qt.RoundJoin,
                    )
                )
                painter.drawPoint(state.position)

            painter.restore()
        painter.end()

    def _draw_transform_handles(self, painter, layer):
        """
        Draw rotation and scaling handles for the selected layer.

        Args:
            painter (QPainter): The painter object used for rendering.
            layer (BaseLayer): The layer for which the handles are drawn.
        """
        # Create transform including both scales
        transform = QTransform()
        transform.translate(layer.position.x(), layer.position.y())
        transform.rotate(layer.rotation)
        transform.scale(layer.scale_x, layer.scale_y)

        # Get transformed rect
        rect = transform.mapRect(QRectF(QPointF(0, 0), layer.original_size))

        # Adjust handle positions to stay on edges
        handle_size = 10 / self.scale
        rotation_pos = rect.center()

        # Scale handles (directly on corners/edges)
        corners = [
            rect.topLeft(),
            rect.topRight(),
            rect.bottomLeft(),
            rect.bottomRight(),
        ]
        edges = [
            QPointF(rect.center().x(), rect.top()),
            QPointF(rect.center().x(), rect.bottom()),
            QPointF(rect.left(), rect.center().y()),
            QPointF(rect.right(), rect.center().y()),
        ]

        # Draw rotation handle at the center and fill it
        painter.setPen(
            QPen(
                self.config.selected_draw_config.handle_color,
                self.config.selected_draw_config.handle_width / self.scale,
            )
        )
        painter.setBrush(self.config.selected_draw_config.handle_color)
        painter.drawEllipse(
            rotation_pos,
            self.config.selected_draw_config.handle_point_size * 1.1 / self.scale,
            self.config.selected_draw_config.handle_point_size * 1.1 / self.scale,
        )
        # now draw rotation symbol
        painter.setPen(
            QPen(
                self.config.selected_draw_config.handle_color,
                self.config.selected_draw_config.handle_width / self.scale,
            )
        )
        painter.drawLine(
            rotation_pos,
            rotation_pos + QPointF(0, -handle_size),
        )
        painter.drawLine(
            rotation_pos,
            rotation_pos + QPointF(0, handle_size),
        )
        painter.drawLine(
            rotation_pos,
            rotation_pos + QPointF(-handle_size, 0),
        )
        painter.drawLine(
            rotation_pos,
            rotation_pos + QPointF(handle_size, 0),
        )

        # Draw scale handles
        handle_color = self.config.selected_draw_config.handle_color
        painter.setPen(
            QPen(
                handle_color, self.config.selected_draw_config.handle_width / self.scale
            )
        )
        painter.setBrush(self.config.selected_draw_config.handle_color)
        for corner in corners:
            painter.drawEllipse(
                corner,
                self.config.selected_draw_config.handle_point_size / self.scale,
                self.config.selected_draw_config.handle_point_size / self.scale,
            )
        for edge in edges:
            # draw small circles on the edges
            painter.drawEllipse(
                edge,
                self.config.selected_draw_config.handle_edge_size / self.scale,
                self.config.selected_draw_config.handle_edge_size / self.scale,
            )
            # draw sides
            painter.drawLine(
                edge + QPointF(-handle_size, 0),
                edge + QPointF(handle_size, 0),
            )
            painter.drawLine(
                edge + QPointF(0, -handle_size),
                edge + QPointF(0, handle_size),
            )

    def _add_drawing_state(self, pos: QPointF):
        """
        Add a new drawing state to the selected layer or the canvas layer itself,
        based on the current mouse mode (DRAW or ERASE).

        Args:
            pos (QPointF): The position where the drawing state is added.
        """
        """Add a new drawing state."""
        self.selected_layer = self._get_selected_layer()
        layer = self.selected_layer if self.selected_layer else self

        # Convert the position to be relative to the layer
        relative_pos = pos - layer.position

        if self.mouse_mode == MouseMode.ERASE:
            # Remove drawing states within the eraser's area
            layer.layer_state.drawing_states = [
                state
                for state in layer.layer_state.drawing_states
                if (state.position - relative_pos).manhattanLength() > self.brush_size
            ]
        elif self.mouse_mode == MouseMode.DRAW:
            # Add a new drawing state only if the position has changed
            # if self._last_draw_point is None or self._last_draw_point != relative_pos:
            drawing_state = DrawingState(
                position=relative_pos,  # Store relative position
                color=self.drawing_color,
                size=self.brush_size,
            )
            layer.layer_state.drawing_states.append(drawing_state)
            self._last_draw_point = relative_pos  # Update the last draw point
            # logger.debug(f"Added drawing state at position: {relative_pos}")

        self.update()  # Refresh the canvas to show the new drawing

    def handle_wheel(self, event: QWheelEvent):
        if self.mouse_mode == MouseMode.DRAW or self.mouse_mode == MouseMode.ERASE:
            # Adjust the brush size using the mouse wheel
            delta = event.angleDelta().y() / 120  # Each step is 120 units
            self.brush_size = max(
                1, self.brush_size + int(delta)
            )  # Ensure size is >= 1
            self.messageSignal.emit(f"Brush size: {self.brush_size}")
            self.update()  # Refresh the canvas to show the updated brush cursor
            return
        if event.modifiers() & Qt.ControlModifier:
            # Get mouse position in widget coordinates
            mouse_pos = event.position()

            # Calculate zoom factor
            zoom_factor = 1.25 if event.angleDelta().y() > 0 else 0.8
            old_scale = self.scale
            new_scale = max(0.1, min(old_scale * zoom_factor, 10.0))

            # Calculate the image point under the cursor before zooming
            before_zoom_img_pos = (mouse_pos - self.pan_offset) / old_scale

            # Update scale
            self.scale = new_scale

            # Calculate the new position of the same image point after zooming
            after_zoom_widget_pos = before_zoom_img_pos * new_scale + self.pan_offset

            # Adjust pan offset to keep the image point under the cursor fixed
            self.pan_offset += mouse_pos - after_zoom_widget_pos

            # Update mouse mode based on zoom direction
            self.mouse_mode = (
                MouseMode.ZOOM_IN if event.angleDelta().y() > 0 else MouseMode.ZOOM_OUT
            )

            self.zoomChanged.emit(self.scale)
            self.update()

    def handle_mouse_release(self, event: QMouseEvent):

        if event.button() == Qt.LeftButton:
            self._active_handle = None
            self._dragging_layer = None

            # Reset drawing state
            if self.mouse_mode in [MouseMode.DRAW, MouseMode.ERASE]:
                self._last_draw_point = None
                self.update()  # Refresh the canvas to show the updated brush cursor

    def handle_mouse_move(self, event: QMouseEvent):
        pos = (event.position() - self.pan_offset) / self.scale
        # logger.info(f"Drawing states: {self.layer_state.drawing_states}")

        # Update cursor position for the brush
        self._cursor_position = event.position()

        if event.buttons() & Qt.LeftButton:
            # Handle drawing or erasing
            if self.mouse_mode in [MouseMode.DRAW, MouseMode.ERASE]:
                self._add_drawing_state(pos)
                # self._last_draw_point = pos
                return

        if self.mouse_mode == MouseMode.PAN:
            if (
                event.modifiers() & Qt.ControlModifier
                and event.buttons() & Qt.LeftButton
            ):
                if self.last_pan_point:
                    delta = event.position() - self.last_pan_point
                    self.pan_offset += delta
                    self.last_pan_point = event.position()
                    self.update()
                    return
            else:
                self.last_pan_point = None
                self.mouse_mode = MouseMode.IDLE

        if self._active_handle:
            handle_type, layer = self._active_handle
            start = self._drag_start
            if "rotate" in handle_type:
                start = self._drag_start
                center = start["center"]

                # Calculate rotation delta from the initial angle
                current_vector = pos - center
                current_angle = math.atan2(current_vector.y(), current_vector.x())
                angle_delta = math.degrees(current_angle - start["initial_angle"])

                new_transform = QTransform()
                new_transform.translate(center.x(), center.y())
                new_transform.rotate(angle_delta)
                new_transform.translate(-center.x(), -center.y())

                new_position = new_transform.map(start["position"])

                # Update the layer using the original reference data
                layer.rotation = (start["rotation"] + angle_delta) % 360
                layer.position = new_position

                logger.info(
                    f"Rotating layer {layer.layer_name} around center to {layer.rotation:.2f} degrees"
                )
                self.messageSignal.emit(
                    f"Rotating layer {layer.layer_name} to {layer.rotation:.2f} degrees"
                )
                layer.selected = True
                self.layersChanged.emit()

                return
            elif "scale" in handle_type:
                # Improved scaling logic
                handle_index = int(handle_type.split("_")[-1])
                original_size = layer.original_size
                delta = pos - start["pos"]

                # Calculate new scale factors
                new_scale_x = layer.scale_x
                new_scale_y = layer.scale_y

                # Calculate position offset (for handles that move the layer)
                pos_offset = QPointF(0, 0)

                # Handle all 8 scale handles
                if handle_index in [0]:  # Top-left
                    new_scale_x = start["scale_x"] - delta.x() / original_size.width()
                    new_scale_y = start["scale_y"] - delta.y() / original_size.height()
                    pos_offset = delta
                    self.setCursor(CursorDef.TRANSFORM_ALL)

                elif handle_index in [1]:  # Top-right
                    new_scale_x = start["scale_x"] + delta.x() / original_size.width()
                    new_scale_y = start["scale_y"] - delta.y() / original_size.height()
                    pos_offset = QPointF(0, delta.y())
                    self.mouse_mode = MouseMode.RESIZE
                elif handle_index in [2]:  # Bottom-left
                    new_scale_x = start["scale_x"] - delta.x() / original_size.width()
                    new_scale_y = start["scale_y"] + delta.y() / original_size.height()
                    pos_offset = QPointF(delta.x(), 0)
                    self.mouse_mode = MouseMode.RESIZE
                elif handle_index in [3]:  # Bottom-right
                    new_scale_x = start["scale_x"] + delta.x() / original_size.width()
                    new_scale_y = start["scale_y"] + delta.y() / original_size.height()
                    self.mouse_mode = MouseMode.RESIZE
                elif handle_index in [4]:  # Top-center
                    new_scale_y = start["scale_y"] - delta.y() / original_size.height()
                    pos_offset = QPointF(0, delta.y())
                    self.mouse_mode = MouseMode.RESIZE_HEIGHT
                elif handle_index in [5]:  # Bottom-center
                    new_scale_y = start["scale_y"] + delta.y() / original_size.height()
                    self.mouse_mode = MouseMode.RESIZE_HEIGHT
                elif handle_index in [6]:  # Left-center
                    new_scale_x = start["scale_x"] - delta.x() / original_size.width()
                    self.mouse_mode = MouseMode.RESIZE_WIDTH
                    pos_offset = QPointF(delta.x(), 0)
                elif handle_index in [7]:  # Right-center
                    new_scale_x = start["scale_x"] + delta.x() / original_size.width()
                    self.mouse_mode = MouseMode.RESIZE_WIDTH

                # Apply scale limits
                new_scale_x = max(0.1, min(new_scale_x, 5.0))
                new_scale_y = max(0.1, min(new_scale_y, 5.0))

                # Update layer properties
                layer.scale_x = new_scale_x
                layer.scale_y = new_scale_y

                # Adjust position for handles that move the layer
                if handle_index in [0, 1, 2, 4, 6]:
                    layer.position = start["position"] + pos_offset

                logger.info(
                    f"Scaling layer {layer.layer_name} to {layer.scale_x:.2f}, {layer.scale_y:.2f}"
                )
                self.messageSignal.emit(
                    f"Scaling layer {layer.layer_name} to {layer.scale_x:.2f}, {layer.scale_y:.2f}"
                )

                self.layersChanged.emit()
            self.update()
        elif self._dragging_layer:
            self._dragging_layer.position = pos - self._drag_offset
            self._dragging_layer.selected = True
            self._dragging_layer.update()
            # set all other layers to not selected
            for layer in self.layers:
                if layer != self._dragging_layer:
                    layer.selected = False

            self.layersChanged.emit()
            self.update()

    def handle_mouse_press(self, event: QMouseEvent):
        if event.button() == Qt.LeftButton:
            pos = (event.position() - self.pan_offset) / self.scale
            if self.mouse_mode in [MouseMode.DRAW, MouseMode.ERASE]:
                logger.info(f"Drawing mode: {self.mouse_mode} at position: {pos}")
                # Add a drawing state immediately on mouse press
                self._last_draw_point = pos
                self._add_drawing_state(pos)  # Add the drawing state here
                return
            if event.modifiers() & Qt.ControlModifier:
                self.mouse_mode = MouseMode.PAN
                self.last_pan_point = event.position()
                return
            # Check handles first
            for layer in reversed(self.layers):
                if layer.selected and layer.visible:
                    # Compute visual center (ignoring rotation for the pivot)
                    handle_size = 10 / self.scale
                    transform = QTransform()
                    transform.translate(layer.position.x(), layer.position.y())
                    transform.rotate(layer.rotation)  # now includes rotation!
                    transform.scale(layer.scale_x, layer.scale_y)
                    visual_rect = transform.mapRect(
                        QRectF(QPointF(0, 0), layer.original_size)
                    )
                    visual_center = visual_rect.center()

                    handle_size = 10 / self.scale
                    if QLineF(pos, visual_center).length() < handle_size:
                        vec = pos - visual_center
                        initial_angle = math.atan2(vec.y(), vec.x())
                        self._active_handle = ("rotate", layer)
                        self._drag_start = {
                            "pos": pos,
                            "rotation": layer.rotation,
                            "center": visual_center,
                            "initial_angle": initial_angle,
                            "position": layer.position,  # Store the initial position
                        }
                        return

                    # Check scale handles (using fully transformed rect)
                    full_transform = QTransform()
                    full_transform.translate(layer.position.x(), layer.position.y())
                    full_transform.rotate(layer.rotation)
                    full_transform.scale(layer.scale_x, layer.scale_y)
                    full_rect = full_transform.mapRect(
                        QRectF(QPointF(0, 0), layer.original_size)
                    )
                    full_center = full_rect.center()

                    scale_handles = [
                        full_rect.topLeft(),
                        full_rect.topRight(),
                        full_rect.bottomLeft(),
                        full_rect.bottomRight(),
                        QPointF(full_center.x(), full_rect.top()),
                        QPointF(full_center.x(), full_rect.bottom()),
                        QPointF(full_rect.left(), full_center.y()),
                        QPointF(full_rect.right(), full_center.y()),
                    ]
                    for i, handle_pos in enumerate(scale_handles):
                        if QLineF(pos, handle_pos).length() < handle_size:
                            self._active_handle = (f"scale_{i}", layer)
                            self._drag_start = {
                                "pos": pos,
                                "scale_x": layer.scale_x,
                                "scale_y": layer.scale_y,
                                "position": layer.position,
                            }
                            return

            # Check layer selection
            for layer in reversed(self.layers):
                if layer.visible:
                    transform = QTransform()
                    transform.translate(layer.position.x(), layer.position.y())
                    transform.rotate(layer.rotation)
                    transform.scale(layer.scale_x, layer.scale_y)
                    rect = transform.mapRect(QRectF(QPointF(0, 0), layer.original_size))

                    if rect.contains(pos):
                        self._dragging_layer = layer
                        self._drag_offset = pos - layer.position
                        layer.selected = True
                        # then set all other layers to not selected
                        for other_layer in self.layers:
                            if other_layer != layer:
                                other_layer.selected = False
                        layer.update()
                        self.layersChanged.emit()

                        break
        # if right click, deselect all layers
        elif event.button() == Qt.RightButton:
            for layer in self.layers:
                layer.selected = False
            self.mouse_mode = MouseMode.IDLE
            self.layersChanged.emit()
            self.update()

    def handle_mouse_double_click(self, event: QMouseEvent, pos: QPoint):
        # was just trying to select/deselect layers with double click
        # if left double click
        # if event.button() == Qt.LeftButton:
        #     # did we click on a layer?
        #     pos = (event.position() - self.pan_offset) / self.scale
        #     selected_layer = None
        #     # Find clicked layer
        #     for layer in reversed(self.layers):
        #         if layer.visible:
        #             # Create transform including scale
        #             transform = QTransform()
        #             transform.translate(layer.position.x(), layer.position.y())
        #             transform.rotate(layer.rotation)
        #             transform.scale(layer.scale_x, layer.scale_y)
        #             rect = transform.mapRect(QRectF(QPointF(0, 0), layer.original_size))
        #             if rect.contains(pos):
        #                 selected_layer = layer
        #                 break

        #     if selected_layer:
        #         # toggle selection
        #         selected_layer.selected = not selected_layer.selected
        #         # make all other layers unselected
        #         for layer in self.layers:
        #             if layer != selected_layer:
        #                 layer.selected = False
        #     else:
        #         # we clicked on the background
        #         # make all layers unselected
        #         for layer in self.layers:
        #             layer.selected = False
        self.update()

    def _get_selected_layer(self):
        for layer in self.layers:
            if layer.selected:
                return layer
        return None

    def add_layer(self, layer: BaseLayer, index=-1):
        """
        This function adds a new layer to the canvas layer.

        Args:
            layer (BaseLayer): The layer to add.
            index (int, optional): The index at which to add the layer. Defaults to -1.

        Raises:
            ValueError: If the layer is not a BaseLayer instance
        """
        layer.layer_name = f"{len(self.layers) + 1}_" + layer.layer_name
        if index >= 0:
            self.layers.append(layer)
        else:
            self.layers.insert(0, layer)

        self._update_back_buffer()
        self.update()
        self.messageSignal.emit(f"Added layer {layer.layer_name}")

    def clear_layers(self):
        """
        Clear all layers from the canvas layer.
        """
        self.layers.clear()
        self._update_back_buffer()
        self.update()
        self.messageSignal.emit("Cleared all layers")

    def _copy_layer(self):
        """
        Copy the selected layer to the clipboard.
        """
        self.selected_layer = self._get_selected_layer()
        if self.selected_layer:
            self.copied_layer = self.selected_layer.copy()
            self.messageSignal.emit(f"Copied layer {self.selected_layer.layer_name}.")
        else:
            self.messageSignal.emit("No layer selected to copy.")

    def _paste_layer(self):
        """
        Paste the copied layer to the canvas layer.
        """
        if self.copied_layer:
            new_layer = self.copied_layer.copy()
            new_layer.position += QPointF(10, 10)
            self.add_layer(new_layer, index=0)
            self.update()
            self.layerSelected.emit(new_layer)
            self.messageSignal.emit(f"Pasted layer {new_layer.layer_name}.")
        else:
            self.messageSignal.emit("No layer copied to paste.")

    def _delete_layer(self):
        self.selected_layer = self._get_selected_layer()
        # now handled from bakertab
        # if self.selected_layer:
        #     remaining_layers = []
        #     removed = False
        #     for layer in self.layers:
        #         if layer.selected:
        #             removed = True
        #             self.messageSignal.emit(f"Deleted {layer.layer_name} layer.")
        #         else:
        #             remaining_layers.append(layer)

        #     if removed:
        #         self.layers = remaining_layers
        #         self._update_back_buffer()
        #         self.layerRemoved.emit(self.selected_layer)
        #         self.update()

    def export_current_state(self, export_to_annotation_tab=False):
        """
        Export the current state of the canvas layer to an image file or annotation tab.

        Args:
            export_to_annotation_tab (bool, optional): Whether to export the image to the annotation tab. Defaults to False.

        Raises:
            ValueError: If the layer is not a BaseLayer instance
        """
        if not self.layers:
            QMessageBox.warning(
                self,
                "Operation Not Possible",
                "No layers are available to export. Please add layers before exporting.",
                QMessageBox.Ok,
            )
            return
        filename = self.config.filename_format.format(
            project_name=self.config.project_name,
            timestamp=datetime.now().strftime("%Y%m%d_%H%M%S"),
        )
        filename = self.config.export_folder / f"{filename}.png"
        logger.info(f"Exporting baked image to {filename}")
        self.states = {0: [layer.layer_state for layer in self.layers]}

        self.loading_dialog = QProgressDialog(
            "Baking Please wait...", "Cancel", 0, 0, self.parentWidget()
        )

        self.loading_dialog.setWindowTitle("Please Wait")
        self.loading_dialog.setWindowModality(Qt.WindowModal)
        self.loading_dialog.setCancelButton(None)  # Remove cancel button if not needed
        self.loading_dialog.show()

        # Force UI update
        QApplication.processEvents()

        # Setup worker thread
        self.worker_thread = QThread()
        self.worker = BakerWorker(
            layers=self.layers,
            states=self.states,
            filename=filename,
        )
        self.worker.moveToThread(self.worker_thread)

        # Connect signals
        self.worker_thread.started.connect(self.worker.process)
        self.worker.finished.connect(
            lambda results, export_to_annotation_tab=export_to_annotation_tab: self.handle_baker_results(
                results, export_to_annotation_tab=export_to_annotation_tab
            )
        )
        self.worker.finished.connect(self.worker_thread.quit)
        self.worker.error.connect(self.handle_baker_error)

        # Cleanup connections
        self.worker.finished.connect(self.worker.deleteLater)
        self.worker_thread.finished.connect(self.worker_thread.deleteLater)
        self.worker_thread.finished.connect(self.loading_dialog.close)

        # Start processing
        self.worker_thread.start()

    def handle_baker_error(self, error_msg):
        """
        To handle any errors that occur during the baking process.
        """
        self.loading_dialog.close()
        QMessageBox.critical(
            self.parentWidget(), "Error", f"Processing failed: {error_msg}"
        )

    def predict_state(self):
        """
        To send the current state to the prediction tab.
        """
        self.export_current_state(export_to_annotation_tab=True)

    def seek_state(self, step):
        """Seek to a specific state using the timeline slider."""
        self.messageSignal.emit(f"Seeking to step {step}")
        logger.info(f"Seeking to step {step}")

        # Get the states for the selected step
        if step in self.states:
            states = self.states[step]
            for state in states:
                layer = self.get_layer(state.layer_id)
                if layer:
                    # Update the layer's state
                    update_opacities = False
                    logger.debug(
                        f"Updating layer {layer.layer_name} with state: {state}"
                    )

                    if (
                        layer.edge_width != state.edge_width
                        or layer.edge_opacity != state.edge_opacity
                    ):
                        update_opacities = True
                    layer.layer_state = state
                    if update_opacities:
                        layer._apply_edge_opacity()
                    layer.update()

    def play_states(self):
        """Play all the states stored in self.states."""
        if len(self.states) == 0:
            logger.warning("No states to play")
            self.messageSignal.emit("No states to play")
            return

        for step, states in sorted(
            self.states.items()
        ):  # Ensure states are played in order
            self.messageSignal.emit(f"Playing step {step}")
            logger.info(f"Playing step {step}")

            # Update the slider position
            self.parentWidget().timeline_slider.setValue(step)
            # Clear the current drawing states

            for state in states:
                # Get the layer corresponding to the state
                layer = self.get_layer(state.layer_id)
                if layer:
                    # Update the layer's state
                    update_opacities = False
                    logger.debug(
                        f"Updating layer {layer.layer_name} with state: {state}"
                    )

                    if (
                        layer.edge_width != state.edge_width
                        or layer.edge_opacity != state.edge_opacity
                    ):
                        update_opacities = True
                    layer.layer_state = state
                    if update_opacities:
                        layer._apply_edge_opacity()
                    layer.update()

            # Update the UI to reflect the changes
            self.update()  # Update the current widget

            QApplication.processEvents()  # Process pending events to refresh the UI

            # Wait for the next frame
            QThread.msleep(int(1000 / self.config.fps))  # Convert FPS to milliseconds

        logger.info("Finished playing states")
        self.messageSignal.emit("Finished playing states")

    def export_baked_states(self, export_to_annotation_tab=False):
        """Export all the states stored in self.states."""
        if len(self.states) == 0:
            msg = "No states to export. Creating a single image."
            logger.warning(msg)
            self.messageSignal.emit(msg)
            self.states = {0: [layer.layer_state for layer in self.layers]}

        filename = self.config.filename_format.format(
            project_name=self.config.project_name,
            timestamp=datetime.now().strftime("%Y%m%d_%H%M%S"),
        )
        filename = self.config.export_folder / f"{filename}.png"

        self.loading_dialog = QProgressDialog(
            "Exporting states, please wait...", "Cancel", 0, 0, self.parentWidget()
        )
        self.loading_dialog.setWindowTitle("Please Wait")
        self.loading_dialog.setWindowModality(Qt.WindowModal)
        self.loading_dialog.setCancelButton(None)
        self.loading_dialog.show()

        QApplication.processEvents()

        # Setup worker thread
        self.worker_thread = QThread()
        self.worker = BakerWorker(
            states=self.states, layers=self.layers, filename=filename
        )
        self.worker.moveToThread(self.worker_thread)

        # Connect signals
        self.worker_thread.started.connect(self.worker.process)
        self.worker.finished.connect(
            lambda results, export_to_annotation_tab=export_to_annotation_tab: self.handle_baker_results(
                results, export_to_annotation_tab
            )
        )  # Handle multiple results
        self.worker.finished.connect(self.worker_thread.quit)
        self.worker.error.connect(self.handle_baker_error)

        # Cleanup connections
        self.worker.finished.connect(self.worker.deleteLater)
        self.worker_thread.finished.connect(self.worker_thread.deleteLater)
        self.worker_thread.finished.connect(self.loading_dialog.close)

        # Start processing
        self.worker_thread.start()

    def handle_baker_results(
        self,
        baking_results: list[BakingResult],
        export_to_annotation_tab=False,
    ):
        logger.info("Baking completed.")
        for baking_result in baking_results:

            filename, image = baking_result.filename, baking_result.image
            masks = baking_result.masks
            mask_names = baking_result.mask_names
            annotations = baking_result.annotations

            if not export_to_annotation_tab:
                image.save(str(filename))
                logger.info(f"Saved annotated image to annotated_{filename}")

                if self.config.is_debug:
                    if self.config.write_masks:
                        for i, mask in enumerate(masks):
                            mask_name = mask_names[i]
                            write_to = filename.parent / f"{mask_name}_{filename.name}"

                            cv2.imwrite(write_to, mask)

                            logger.info(f"Saved mask for {mask_name}")
                    logger.info(f"Saved baked image to {filename}")
                    if self.config.write_annotations:
                        image = qpixmap_to_numpy(image.copy())
                        image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR)
                        drawn = draw_annotations(image, annotations)
                        write_to = filename.parent / f"annotated_{filename.name}"

                        cv2.imwrite(str(write_to), drawn)

                        logger.info(f"Saved annotated image to annotated_{filename}")

                Annotation.save_as_json(
                    annotations, f"{filename.parent/filename.stem}.json"
                )
                logger.info(f"Saved annotations to {filename}.json")
            else:
                self.bakingResult.emit(baking_result)

    def export_states_to_predict(self):
        self.export_baked_states(export_to_annotation_tab=True)

add_layer(layer, index=-1)

This function adds a new layer to the canvas layer.

Parameters:

Name Type Description Default
layer BaseLayer

The layer to add.

required
index int

The index at which to add the layer. Defaults to -1.

-1

Raises:

Type Description
ValueError

If the layer is not a BaseLayer instance

Source code in imagebaker/layers/canvas_layer.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
def add_layer(self, layer: BaseLayer, index=-1):
    """
    This function adds a new layer to the canvas layer.

    Args:
        layer (BaseLayer): The layer to add.
        index (int, optional): The index at which to add the layer. Defaults to -1.

    Raises:
        ValueError: If the layer is not a BaseLayer instance
    """
    layer.layer_name = f"{len(self.layers) + 1}_" + layer.layer_name
    if index >= 0:
        self.layers.append(layer)
    else:
        self.layers.insert(0, layer)

    self._update_back_buffer()
    self.update()
    self.messageSignal.emit(f"Added layer {layer.layer_name}")

clear_layers()

Clear all layers from the canvas layer.

Source code in imagebaker/layers/canvas_layer.py
747
748
749
750
751
752
753
754
def clear_layers(self):
    """
    Clear all layers from the canvas layer.
    """
    self.layers.clear()
    self._update_back_buffer()
    self.update()
    self.messageSignal.emit("Cleared all layers")

export_baked_states(export_to_annotation_tab=False)

Export all the states stored in self.states.

Source code in imagebaker/layers/canvas_layer.py
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
def export_baked_states(self, export_to_annotation_tab=False):
    """Export all the states stored in self.states."""
    if len(self.states) == 0:
        msg = "No states to export. Creating a single image."
        logger.warning(msg)
        self.messageSignal.emit(msg)
        self.states = {0: [layer.layer_state for layer in self.layers]}

    filename = self.config.filename_format.format(
        project_name=self.config.project_name,
        timestamp=datetime.now().strftime("%Y%m%d_%H%M%S"),
    )
    filename = self.config.export_folder / f"{filename}.png"

    self.loading_dialog = QProgressDialog(
        "Exporting states, please wait...", "Cancel", 0, 0, self.parentWidget()
    )
    self.loading_dialog.setWindowTitle("Please Wait")
    self.loading_dialog.setWindowModality(Qt.WindowModal)
    self.loading_dialog.setCancelButton(None)
    self.loading_dialog.show()

    QApplication.processEvents()

    # Setup worker thread
    self.worker_thread = QThread()
    self.worker = BakerWorker(
        states=self.states, layers=self.layers, filename=filename
    )
    self.worker.moveToThread(self.worker_thread)

    # Connect signals
    self.worker_thread.started.connect(self.worker.process)
    self.worker.finished.connect(
        lambda results, export_to_annotation_tab=export_to_annotation_tab: self.handle_baker_results(
            results, export_to_annotation_tab
        )
    )  # Handle multiple results
    self.worker.finished.connect(self.worker_thread.quit)
    self.worker.error.connect(self.handle_baker_error)

    # Cleanup connections
    self.worker.finished.connect(self.worker.deleteLater)
    self.worker_thread.finished.connect(self.worker_thread.deleteLater)
    self.worker_thread.finished.connect(self.loading_dialog.close)

    # Start processing
    self.worker_thread.start()

export_current_state(export_to_annotation_tab=False)

Export the current state of the canvas layer to an image file or annotation tab.

Parameters:

Name Type Description Default
export_to_annotation_tab bool

Whether to export the image to the annotation tab. Defaults to False.

False

Raises:

Type Description
ValueError

If the layer is not a BaseLayer instance

Source code in imagebaker/layers/canvas_layer.py
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
def export_current_state(self, export_to_annotation_tab=False):
    """
    Export the current state of the canvas layer to an image file or annotation tab.

    Args:
        export_to_annotation_tab (bool, optional): Whether to export the image to the annotation tab. Defaults to False.

    Raises:
        ValueError: If the layer is not a BaseLayer instance
    """
    if not self.layers:
        QMessageBox.warning(
            self,
            "Operation Not Possible",
            "No layers are available to export. Please add layers before exporting.",
            QMessageBox.Ok,
        )
        return
    filename = self.config.filename_format.format(
        project_name=self.config.project_name,
        timestamp=datetime.now().strftime("%Y%m%d_%H%M%S"),
    )
    filename = self.config.export_folder / f"{filename}.png"
    logger.info(f"Exporting baked image to {filename}")
    self.states = {0: [layer.layer_state for layer in self.layers]}

    self.loading_dialog = QProgressDialog(
        "Baking Please wait...", "Cancel", 0, 0, self.parentWidget()
    )

    self.loading_dialog.setWindowTitle("Please Wait")
    self.loading_dialog.setWindowModality(Qt.WindowModal)
    self.loading_dialog.setCancelButton(None)  # Remove cancel button if not needed
    self.loading_dialog.show()

    # Force UI update
    QApplication.processEvents()

    # Setup worker thread
    self.worker_thread = QThread()
    self.worker = BakerWorker(
        layers=self.layers,
        states=self.states,
        filename=filename,
    )
    self.worker.moveToThread(self.worker_thread)

    # Connect signals
    self.worker_thread.started.connect(self.worker.process)
    self.worker.finished.connect(
        lambda results, export_to_annotation_tab=export_to_annotation_tab: self.handle_baker_results(
            results, export_to_annotation_tab=export_to_annotation_tab
        )
    )
    self.worker.finished.connect(self.worker_thread.quit)
    self.worker.error.connect(self.handle_baker_error)

    # Cleanup connections
    self.worker.finished.connect(self.worker.deleteLater)
    self.worker_thread.finished.connect(self.worker_thread.deleteLater)
    self.worker_thread.finished.connect(self.loading_dialog.close)

    # Start processing
    self.worker_thread.start()

handle_baker_error(error_msg)

To handle any errors that occur during the baking process.

Source code in imagebaker/layers/canvas_layer.py
865
866
867
868
869
870
871
872
def handle_baker_error(self, error_msg):
    """
    To handle any errors that occur during the baking process.
    """
    self.loading_dialog.close()
    QMessageBox.critical(
        self.parentWidget(), "Error", f"Processing failed: {error_msg}"
    )

handle_key_release(event)

Handle key release events, such as resetting the mouse mode when the Control key is released.

Parameters:

Name Type Description Default
event QKeyEvent

The key release event.

required
Source code in imagebaker/layers/canvas_layer.py
76
77
78
79
80
81
82
83
84
85
def handle_key_release(self, event: QKeyEvent):
    """
    Handle key release events, such as resetting the mouse mode when the Control key is released.

    Args:
        event (QKeyEvent): The key release event.
    """
    if event.key() == Qt.Key_Control:
        if self.mouse_mode not in [MouseMode.DRAW, MouseMode.ERASE]:
            self.mouse_mode = MouseMode.IDLE

init_ui()

Initialize the user interface for the canvas layer, including size policies and storing the original size of the layer.

Source code in imagebaker/layers/canvas_layer.py
67
68
69
70
71
72
73
74
def init_ui(self):
    """
    Initialize the user interface for the canvas layer, including size policies
    and storing the original size of the layer.
    """
    logger.info(f"Initializing Layer UI of {self.layer_name}")
    self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
    self.original_size = QSizeF(self.image.size())  # Store original size

paint_layer(painter)

Paint the canvas layer, including all visible layers, their transformations, and any drawing states or selection indicators.

Parameters:

Name Type Description Default
painter QPainter

The painter object used for rendering.

required
Source code in imagebaker/layers/canvas_layer.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
def paint_layer(self, painter: QPainter):
    """
    Paint the canvas layer, including all visible layers, their transformations,
    and any drawing states or selection indicators.

    Args:
        painter (QPainter): The painter object used for rendering.
    """
    painter.translate(self.pan_offset)
    painter.scale(self.scale, self.scale)
    for layer in self.layers:
        if layer.visible and not layer.image.isNull():
            painter.save()
            painter.translate(layer.position)
            painter.rotate(layer.rotation)
            painter.scale(layer.scale_x, layer.scale_y)

            # painter.drawPixmap(0, 0, layer.image)
            # painter.setOpacity(layer.opacity / 255)
            # Create a new pixmap with adjusted opacity
            pixmap_with_alpha = QPixmap(layer.image.size())
            pixmap_with_alpha.fill(Qt.transparent)  # Ensure transparency

            # Use QPainter to apply opacity to the pixmap
            temp_painter = QPainter(pixmap_with_alpha)
            opacity = layer.opacity / 255.0
            temp_painter.setOpacity(opacity)  # Scale opacity to 0.0-1.0
            temp_painter.drawPixmap(0, 0, layer.image)

            temp_painter.end()

            # Draw the modified pixmap
            painter.drawPixmap(0, 0, pixmap_with_alpha)

            if layer.selected:
                painter.setPen(
                    QPen(
                        self.config.selected_draw_config.color,
                        self.config.selected_draw_config.line_width,
                    )
                )
                painter.setBrush(
                    QBrush(
                        QColor(
                            self.config.selected_draw_config.color.red(),
                            self.config.selected_draw_config.color.green(),
                            self.config.selected_draw_config.color.blue(),
                            self.config.selected_draw_config.brush_alpha,
                        )
                    )
                )
                painter.drawRect(QRectF(QPointF(0, 0), layer.original_size))
            painter.restore()

            if layer.selected:
                self._draw_transform_handles(painter, layer)
            if layer.layer_state.drawing_states:
                painter.save()
                painter.translate(layer.position)
                painter.rotate(layer.rotation)
                painter.scale(layer.scale_x, layer.scale_y)

                for state in layer.layer_state.drawing_states:
                    painter.setRenderHints(QPainter.Antialiasing)
                    painter.setPen(
                        QPen(
                            state.color,
                            state.size,
                            Qt.SolidLine,
                            Qt.RoundCap,
                            Qt.RoundJoin,
                        )
                    )
                    # Draw the point after applying transformations
                    painter.drawPoint(state.position)

                painter.restore()
    if self.layer_state.drawing_states:
        painter.save()
        painter.translate(self.position)
        painter.rotate(self.rotation)
        painter.scale(self.scale_x, self.scale_y)

        for state in self.layer_state.drawing_states:
            painter.setRenderHints(QPainter.Antialiasing)
            painter.setPen(
                QPen(
                    state.color,
                    state.size,
                    Qt.SolidLine,
                    Qt.RoundCap,
                    Qt.RoundJoin,
                )
            )
            painter.drawPoint(state.position)

        painter.restore()
    painter.end()

play_states()

Play all the states stored in self.states.

Source code in imagebaker/layers/canvas_layer.py
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
def play_states(self):
    """Play all the states stored in self.states."""
    if len(self.states) == 0:
        logger.warning("No states to play")
        self.messageSignal.emit("No states to play")
        return

    for step, states in sorted(
        self.states.items()
    ):  # Ensure states are played in order
        self.messageSignal.emit(f"Playing step {step}")
        logger.info(f"Playing step {step}")

        # Update the slider position
        self.parentWidget().timeline_slider.setValue(step)
        # Clear the current drawing states

        for state in states:
            # Get the layer corresponding to the state
            layer = self.get_layer(state.layer_id)
            if layer:
                # Update the layer's state
                update_opacities = False
                logger.debug(
                    f"Updating layer {layer.layer_name} with state: {state}"
                )

                if (
                    layer.edge_width != state.edge_width
                    or layer.edge_opacity != state.edge_opacity
                ):
                    update_opacities = True
                layer.layer_state = state
                if update_opacities:
                    layer._apply_edge_opacity()
                layer.update()

        # Update the UI to reflect the changes
        self.update()  # Update the current widget

        QApplication.processEvents()  # Process pending events to refresh the UI

        # Wait for the next frame
        QThread.msleep(int(1000 / self.config.fps))  # Convert FPS to milliseconds

    logger.info("Finished playing states")
    self.messageSignal.emit("Finished playing states")

predict_state()

To send the current state to the prediction tab.

Source code in imagebaker/layers/canvas_layer.py
874
875
876
877
878
def predict_state(self):
    """
    To send the current state to the prediction tab.
    """
    self.export_current_state(export_to_annotation_tab=True)

seek_state(step)

Seek to a specific state using the timeline slider.

Source code in imagebaker/layers/canvas_layer.py
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
def seek_state(self, step):
    """Seek to a specific state using the timeline slider."""
    self.messageSignal.emit(f"Seeking to step {step}")
    logger.info(f"Seeking to step {step}")

    # Get the states for the selected step
    if step in self.states:
        states = self.states[step]
        for state in states:
            layer = self.get_layer(state.layer_id)
            if layer:
                # Update the layer's state
                update_opacities = False
                logger.debug(
                    f"Updating layer {layer.layer_name} with state: {state}"
                )

                if (
                    layer.edge_width != state.edge_width
                    or layer.edge_opacity != state.edge_opacity
                ):
                    update_opacities = True
                layer.layer_state = state
                if update_opacities:
                    layer._apply_edge_opacity()
                layer.update()

Annotable Layer

Bases: BaseLayer

Source code in imagebaker/layers/annotable_layer.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
class AnnotableLayer(BaseLayer):
    annotationAdded = Signal(Annotation)
    annotationRemoved = Signal()
    annotationUpdated = Signal(Annotation)
    annotationCleared = Signal()
    annotationMoved = Signal()
    layersChanged = Signal()

    def __init__(self, parent, config: LayerConfig, canvas_config: CanvasConfig):
        super().__init__(parent, config)
        self.canvas_config = canvas_config

        self.image = QPixmap()
        self.mouse_mode = MouseMode.POINT

        self.label_rects = []
        self.file_path: Path = Path("Runtime")
        self.layers: list[BaseLayer] = []
        self.is_annotable = True
        self.handle_zoom: float = 1

    def init_ui(self):
        logger.info(f"Initializing Layer UI of {self.layer_name}")
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)

    def clear_annotations(self):
        self.annotations.clear()
        self.selected_annotation = None
        self.current_annotation = None
        self.annotationCleared.emit()
        self.update()

    def handle_key_press(self, event: QKeyEvent):
        # Handle Ctrl key for panning
        if event.key() == Qt.Key_Control:
            if (
                self.mouse_mode != MouseMode.POLYGON
            ):  # Only activate pan mode when not drawing polygons

                self.mouse_mode = MouseMode.PAN

        # Handle Ctrl+C for copy
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
            self._copy_annotation()

        # Handle Ctrl+V for paste
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
            self._paste_annotation()

    def handle_key_release(self, event):
        if event.key() == Qt.Key_Control:
            if self.mouse_mode == MouseMode.PAN:
                self.mouse_mode = MouseMode.IDLE

    def apply_opacity(self):
        """Apply opacity to the QPixmap image."""
        if self.image and self.opacity < 255:
            # Create a new transparent pixmap with the same size
            transparent_pixmap = QPixmap(self.image.size())
            transparent_pixmap.fill(Qt.transparent)

            # Create a painter to draw on the new pixmap
            painter = QPainter(transparent_pixmap)
            try:
                # Set the opacity
                painter.setOpacity(self.opacity / 255.0)

                # Draw the original image onto the new pixmap
                painter.drawPixmap(0, 0, self.image)
            finally:
                # Ensure the painter is properly ended
                painter.end()

            # Replace the original image with the transparent version
            self.image = transparent_pixmap

    def paint_layer(self, painter: QPainter):
        with QPainter(self) as painter:
            painter.fillRect(
                self.rect(),
                self.config.normal_draw_config.background_color,
            )
            painter.setRenderHints(
                QPainter.Antialiasing | QPainter.SmoothPixmapTransform
            )

            if not self.image.isNull():
                painter.save()
                painter.translate(self.offset)
                painter.scale(self.scale, self.scale)
                painter.drawPixmap(0, 0, self.image)

                # Draw all annotations
                for annotation in self.annotations:
                    self.draw_annotation(painter, annotation)

                # Draw current annotation
                if self.current_annotation:
                    self.draw_annotation(painter, self.current_annotation, is_temp=True)

                painter.restore()

    def draw_annotation(self, painter, annotation: Annotation, is_temp=False):
        """
        Draw annotation on the image.
        """
        if not annotation.visible:
            return
        painter.save()
        base_color = annotation.color
        pen_color = QColor(
            base_color.red(),
            base_color.green(),
            base_color.blue(),
            self.config.normal_draw_config.pen_alpha,
        )
        brush_color = QColor(
            base_color.red(),
            base_color.green(),
            base_color.blue(),
            self.config.normal_draw_config.brush_alpha,
        )

        pen = QPen(pen_color, self.config.normal_draw_config.line_width / self.scale)
        brush = QBrush(brush_color, Qt.DiagCrossPattern)

        if annotation.selected:
            painter.setPen(
                QPen(
                    self.config.selected_draw_config.color,
                    self.config.selected_draw_config.line_width / self.scale,
                )
            )
            painter.setBrush(
                QBrush(
                    QColor(
                        self.config.selected_draw_config.color.red(),
                        self.config.selected_draw_config.color.green(),
                        self.config.selected_draw_config.color.blue(),
                        self.config.selected_draw_config.brush_alpha,
                    )
                )
            )
            if annotation.rectangle:
                painter.drawRect(annotation.rectangle)
            elif annotation.polygon:
                painter.drawPolygon(annotation.polygon)
            elif annotation.points:
                painter.drawEllipse(
                    annotation.points[0],
                    self.config.selected_draw_config.ellipse_size / self.scale,
                    self.config.selected_draw_config.ellipse_size / self.scale,
                )

        if is_temp:
            pen.setStyle(Qt.DashLine)
            brush.setStyle(Qt.Dense4Pattern)

        painter.setPen(pen)
        painter.setBrush(brush)

        # Draw main shape
        if annotation.points:
            for point in annotation.points:
                painter.drawEllipse(
                    point,
                    self.config.normal_draw_config.point_size / self.scale,
                    self.config.normal_draw_config.point_size / self.scale,
                )
        elif annotation.rectangle:
            painter.drawRect(annotation.rectangle)
        elif annotation.polygon:
            if len(annotation.polygon) > 1:
                if annotation.is_complete:
                    painter.drawPolygon(annotation.polygon)
                else:
                    painter.drawPolyline(annotation.polygon)

        # Draw control points
        if annotation.rectangle:
            rect = annotation.rectangle
            corners = [
                rect.topLeft(),
                rect.topRight(),
                rect.bottomLeft(),
                rect.bottomRight(),
            ]
            painter.save()
            painter.setPen(
                QPen(
                    Qt.black,
                    self.config.normal_draw_config.control_point_size / self.scale,
                )
            )
            painter.setBrush(QBrush(Qt.white))
            for corner in corners:
                painter.drawEllipse(
                    corner,
                    self.config.normal_draw_config.point_size / self.scale,
                    self.config.normal_draw_config.point_size / self.scale,
                )
            painter.restore()

        if annotation.polygon and len(annotation.polygon) > 0:
            painter.save()
            painter.setPen(
                QPen(
                    Qt.white,
                    self.config.normal_draw_config.control_point_size / self.scale,
                )
            )
            painter.setBrush(QBrush(Qt.darkGray))
            for point in annotation.polygon:
                painter.drawEllipse(
                    point,
                    self.config.normal_draw_config.point_size / self.scale,
                    self.config.normal_draw_config.point_size / self.scale,
                )
            painter.restore()

        # Draw labels
        if annotation.is_complete and annotation.label:
            painter.save()
            label_pos = self.get_label_position(annotation)
            text = annotation.label

            # Convert to widget coordinates
            widget_pos = QPointF(
                label_pos.x() * self.scale + self.offset.x(),
                label_pos.y() * self.scale + self.offset.y(),
            )

            if annotation.points:
                widget_pos += QPointF(10, 10)

            # Set up font
            font = painter.font()
            font.setPixelSize(
                self.config.normal_draw_config.label_font_size * self.scale
            )  # Fixed screen size
            painter.setFont(font)

            # Calculate text size
            metrics = painter.fontMetrics()
            text_width = metrics.horizontalAdvance(text)
            text_height = metrics.height()

            # Draw background
            bg_rect = QRectF(
                widget_pos.x() - text_width / 2 - 2,
                widget_pos.y() - text_height / 2 - 2,
                text_width + 4,
                text_height + 4,
            )
            painter.resetTransform()
            painter.setBrush(self.config.normal_draw_config.label_font_background_color)
            painter.setPen(Qt.NoPen)
            painter.drawRect(bg_rect)

            # Draw text
            painter.setPen(Qt.white)
            painter.drawText(bg_rect, Qt.AlignCenter, text)
            painter.restore()
            self.label_rects.append((bg_rect, annotation))

        painter.restore()

        # Draw transformation handles for selected annotations
        if annotation.selected and annotation.is_complete:
            painter.save()
            handle_color = self.config.selected_draw_config.handle_color
            painter.setPen(
                QPen(
                    handle_color,
                    self.config.selected_draw_config.handle_width / self.scale,
                )
            )
            painter.setBrush(QBrush(handle_color))

            if annotation.rectangle:
                rect = annotation.rectangle
                # Draw corner handles
                for corner in [
                    rect.topLeft(),
                    rect.topRight(),
                    rect.bottomLeft(),
                    rect.bottomRight(),
                ]:
                    painter.drawEllipse(
                        corner,
                        self.config.selected_draw_config.handle_point_size / self.scale,
                        self.config.selected_draw_config.handle_point_size / self.scale,
                    )
                # Draw edge handles
                for edge in [
                    QPointF(rect.center().x(), rect.top()),
                    QPointF(rect.center().x(), rect.bottom()),
                    QPointF(rect.left(), rect.center().y()),
                    QPointF(rect.right(), rect.center().y()),
                ]:
                    painter.drawEllipse(
                        edge,
                        self.config.selected_draw_config.handle_edge_size / self.scale,
                        self.config.selected_draw_config.handle_edge_size / self.scale,
                    )

            elif annotation.polygon:
                # Draw vertex handles
                for point in annotation.polygon:
                    painter.drawEllipse(
                        point,
                        self.config.selected_draw_config.handle_point_size / self.scale,
                        self.config.selected_draw_config.handle_point_size / self.scale,
                    )

            painter.restore()

    def get_label_position(self, annotation: Annotation):
        if annotation.points:
            return annotation.points[0]
        if annotation.rectangle:
            return annotation.rectangle.center()
        if annotation.polygon:
            return annotation.polygon.boundingRect().center()
        return QPointF()

    def handle_wheel(self, event: QWheelEvent):
        if event.modifiers() & Qt.ControlModifier:
            # Get mouse position before zoom
            old_pos = self.widget_to_image_pos(event.position())

            # Calculate zoom factor
            zoom_factor = (
                self.config.zoom_in_factor
                if event.angleDelta().y() > 0
                else self.config.zoom_out_factor
            )
            new_scale = max(0.1, min(self.scale * zoom_factor, 10.0))

            # Calculate position shift to keep cursor over same image point
            self.offset += old_pos * self.scale - old_pos * new_scale
            self.scale = new_scale

            # is wheel going forward or backward
            if event.angleDelta().y() > 0:
                self.mouse_mode = MouseMode.ZOOM_IN
            else:
                self.mouse_mode = MouseMode.ZOOM_OUT

            self.zoomChanged.emit(self.scale)

    def handle_mouse_release(self, event: QMouseEvent):
        if event.button() == Qt.LeftButton:
            if self.mouse_mode == MouseMode.RECTANGLE and self.current_annotation:
                self.finalize_annotation()
            elif self.mouse_mode == MouseMode.POLYGON and self.current_annotation:
                pass
            elif self.mouse_mode in [
                MouseMode.PAN,
                MouseMode.ZOOM_IN,
                MouseMode.ZOOM_OUT,
            ]:
                self.mouse_mode = MouseMode.IDLE

        # Clean up transformation state
        if hasattr(self, "selected_annotation"):
            self.selected_annotation = None
        if hasattr(self, "active_handle"):
            del self.active_handle
        if hasattr(self, "active_point_index"):
            del self.active_point_index
        if hasattr(self, "initial_rect"):
            del self.initial_rect
        if hasattr(self, "initial_polygon"):
            del self.initial_polygon

        self.pan_start = None
        self.drag_start = None

    def handle_mouse_move(self, event: QMouseEvent):
        # logger.info(f"Mouse move event: {event.position()} with {self.mouse_mode}")
        img_pos = self.widget_to_image_pos(event.position())
        clamped_pos = QPointF(
            max(0, min(self.image.width(), img_pos.x())),
            max(0, min(self.image.height(), img_pos.y())),
        )
        self.mouseMoved.emit(img_pos)
        self.messageSignal.emit(f"X: {img_pos.x()}, Y: {img_pos.y()}")

        # if we are not clicking
        if not event.buttons():
            annotation, handle = self.find_annotation_and_handle_at(img_pos)
            if annotation and handle and self.mouse_mode == MouseMode.IDLE:
                if "point_" in handle or handle in [
                    "top_left",
                    "top_right",
                    "bottom_left",
                    "bottom_right",
                ]:
                    self.mouse_mode = MouseMode.RESIZE
                elif "center" in handle:
                    if "top" in handle or "bottom" in handle:
                        self.mouse_mode = MouseMode.RESIZE_HEIGHT
                    else:
                        self.mouse_mode = MouseMode.RESIZE_WIDTH
                elif handle == "move":
                    self.mouse_mode = MouseMode.GRAB

            elif not handle and self.mouse_mode in [
                MouseMode.RESIZE,
                MouseMode.RESIZE_HEIGHT,
                MouseMode.RESIZE_WIDTH,
                MouseMode.GRAB,
            ]:
                self.mouse_mode = MouseMode.IDLE
                # self.mouse_mode = MouseMode.IDLE
                pass
            self.update_cursor()
        else:
            if (
                event.buttons() & Qt.LeftButton
                and self.selected_annotation
                and self.active_handle
            ):
                if self.active_handle == "move":
                    self.setCursor(CursorDef.GRABBING_CURSOR)
                    new_pos = img_pos - self.drag_offset
                    self.move_annotation(self.selected_annotation, new_pos)
                elif self.selected_annotation.rectangle:
                    rect = QRectF(self.initial_rect)

                    if "top" in self.active_handle:
                        rect.setTop(img_pos.y())
                    if "bottom" in self.active_handle:
                        rect.setBottom(img_pos.y())
                    if "left" in self.active_handle:
                        rect.setLeft(img_pos.x())
                    if "right" in self.active_handle:
                        rect.setRight(img_pos.x())

                    self.selected_annotation.rectangle = rect.normalized()
                elif self.selected_annotation.polygon and hasattr(
                    self, "active_point_index"
                ):
                    self.selected_annotation.polygon[self.active_point_index] = (
                        clamped_pos
                    )
                elif self.selected_annotation.points:
                    self.selected_annotation.points[0] = clamped_pos
                self.annotationMoved.emit()
                self.annotationUpdated.emit(self.selected_annotation)
                self.update()
                return

            if self.mouse_mode == MouseMode.PAN and event.buttons() & Qt.LeftButton:
                if self.pan_start:
                    delta = event.position() - self.pan_start
                    self.offset += delta
                    self.pan_start = event.position()
                    self.update()
            elif self.mouse_mode == MouseMode.RECTANGLE and self.drag_start:
                self.current_annotation.rectangle = QRectF(
                    self.drag_start, clamped_pos
                ).normalized()
                self.update()
            elif self.mouse_mode == MouseMode.POLYGON and self.current_annotation:
                if self.current_annotation.polygon:
                    temp_points = QPolygonF(self.current_annotation.polygon)
                    if temp_points:
                        temp_points[-1] = clamped_pos
                        self.current_annotation.polygon = temp_points
                        self.update()

    def move_annotation(self, annotation, new_pos: QPointF):
        delta = new_pos - self.get_annotation_position(annotation)

        if annotation.rectangle:
            annotation.rectangle.translate(delta)
        elif annotation.polygon:
            annotation.polygon.translate(delta)
        elif annotation.points:
            annotation.points = [p + delta for p in annotation.points]

    def handle_mouse_press(self, event: QMouseEvent):
        # logger.info(f"Mouse press event: {event.position()} with {self.mouse_mode}")
        img_pos = self.widget_to_image_pos(event.position())
        clamped_pos = QPointF(
            max(0, min(self.image.width(), img_pos.x())),
            max(0, min(self.image.height(), img_pos.y())),
        )

        # If right-clicked
        if event.button() == Qt.RightButton:
            # If polygon drawing, remove the last point
            if self.current_annotation and self.mouse_mode == MouseMode.POLYGON:
                if len(self.current_annotation.polygon) > 0:
                    self.current_annotation.polygon = QPolygonF(
                        [p for p in self.current_annotation.polygon][:-1]
                    )
                    self.update()

                # If the polygon is now empty, reset to idle mode
                if len(self.current_annotation.polygon) == 0:
                    self.current_annotation = None
                    self.mouse_mode = MouseMode.IDLE
                    self.update()

            # If not drawing a polygon, go to idle mode
            if not self.current_annotation:
                self.mouse_mode = MouseMode.IDLE
                for ann in self.annotations:
                    ann.selected = False
                    self.annotationUpdated.emit(ann)
                self.update()

        # If left-clicked
        if event.button() == Qt.LeftButton:
            self.selected_annotation, self.active_handle = (
                self.find_annotation_and_handle_at(img_pos)
            )
            # Handle dragging later on
            if self.selected_annotation:
                self.drag_offset = img_pos - self.get_annotation_position(
                    self.selected_annotation
                )
                self.selected_annotation.selected = True

                # Make all other annotations unselected
                for ann in self.annotations:
                    if ann != self.selected_annotation:
                        ann.selected = False
                    self.annotationUpdated.emit(ann)

                if self.selected_annotation.rectangle:
                    self.initial_rect = QRectF(self.selected_annotation.rectangle)
                elif self.selected_annotation.polygon:
                    self.initial_polygon = QPolygonF(self.selected_annotation.polygon)
                    if "point_" in self.active_handle:
                        self.active_point_index = int(self.active_handle.split("_")[1])
                elif self.selected_annotation.points:
                    self.active_point_index = 0

            # If pan mode
            if self.mouse_mode == MouseMode.PAN:
                self.pan_start = event.position()
                return

            # If drawing mode
            if self.mouse_mode == MouseMode.POINT:
                self.current_annotation = Annotation(
                    label=self.current_label,
                    annotation_id=len(self.annotations),
                    points=[clamped_pos],
                )
                self.finalize_annotation()
            elif self.mouse_mode == MouseMode.RECTANGLE:
                # The incomplete annotation
                self.current_annotation = Annotation(
                    file_path=self.file_path,
                    annotation_id=len(self.annotations),
                    label="Incomplete",
                    color=self.current_color,
                    rectangle=QRectF(clamped_pos, clamped_pos),
                )
                self.drag_start = clamped_pos
            elif self.mouse_mode == MouseMode.POLYGON:
                # If not double-click
                if not self.current_annotation:
                    self.current_annotation = Annotation(
                        file_path=self.file_path,
                        annotation_id=len(self.annotations),
                        label="Incomplete",
                        color=self.current_color,
                        polygon=QPolygonF([clamped_pos]),
                    )
                else:
                    logger.info(f"Adding point to polygon: {clamped_pos}")
                    # Add point to polygon
                    self.current_annotation.polygon.append(clamped_pos)

            self.update()

    def get_annotation_position(self, annotation: Annotation):
        if annotation.rectangle:
            return annotation.rectangle.center()
        elif annotation.polygon:
            return annotation.polygon.boundingRect().center()
        elif annotation.points:
            return annotation.points[0]
        return QPointF()

    def find_annotation_and_handle_at(self, pos: QPointF, margin=10.0):
        """Find annotation and specific handle at given position"""
        for annotation in reversed(self.annotations):
            if not annotation.visible or not annotation.is_complete:
                continue

            # Check rectangle handles
            if annotation.rectangle:
                rect = annotation.rectangle
                handles = {
                    "top_left": rect.topLeft(),
                    "top_right": rect.topRight(),
                    "bottom_left": rect.bottomLeft(),
                    "bottom_right": rect.bottomRight(),
                    "top_center": QPointF(rect.center().x(), rect.top()),
                    "bottom_center": QPointF(rect.center().x(), rect.bottom()),
                    "left_center": QPointF(rect.left(), rect.center().y()),
                    "right_center": QPointF(rect.right(), rect.center().y()),
                }

                for handle_name, handle_pos in handles.items():
                    if (handle_pos - pos).manhattanLength() < margin:
                        return annotation, handle_name

                if rect.contains(pos):
                    return annotation, "move"

            # Check polygon points
            elif annotation.polygon:
                for i, point in enumerate(annotation.polygon):
                    if (point - pos).manhattanLength() < margin:
                        return annotation, f"point_{i}"

                if annotation.polygon.containsPoint(pos, Qt.OddEvenFill):
                    return annotation, "move"

            # Check points
            elif annotation.points:
                if (annotation.points[0] - pos).manhattanLength() < margin:
                    return annotation, "point_0"

        return None, None

    def handle_mouse_double_click(self, event: QMouseEvent, pos: QPoint):
        pos = event.position()
        for rect, annotation in self.label_rects:
            if rect.contains(pos):
                self.edit_annotation_label(annotation)
                break
        # if left double click
        if event.button() == Qt.LeftButton:
            img_pos = self.widget_to_image_pos(event.position())

            self.selected_annotation, self.active_handle = (
                self.find_annotation_and_handle_at(img_pos)
            )

            if self.selected_annotation:
                if self.selected_annotation.polygon and self.active_handle:
                    if "point_" in self.active_handle:
                        index = int(self.active_handle.split("_")[1])
                        # Remove the point at the clicked index
                        polygon = self.selected_annotation.polygon
                        polygon = QPolygonF(
                            [p for i, p in enumerate(polygon) if i != index]
                        )

                        self.selected_annotation.polygon = polygon
                        self.annotationUpdated.emit(self.selected_annotation)
                        self.update()
                        logger.info(f"Removed point at index {index}")
                        return

                # Check if an edge was double-clicked
                polygon = self.selected_annotation.polygon
                if polygon:
                    for i in range(len(polygon)):
                        start_point = polygon[i]
                        end_point = polygon[
                            (i + 1) % len(polygon)
                        ]  # Wrap around to the first point

                        # Calculate the vector along the edge and the vector from the start point to the clicked position
                        line_vector = end_point - start_point
                        point_vector = img_pos - start_point

                        # Calculate the length of the edge
                        line_length_squared = (
                            line_vector.x() ** 2 + line_vector.y() ** 2
                        )
                        if line_length_squared == 0:
                            continue  # Avoid division by zero for degenerate edges

                        # Project the point onto the line (normalized)
                        projection = (
                            point_vector.x() * line_vector.x()
                            + point_vector.y() * line_vector.y()
                        ) / line_length_squared

                        # Clamp the projection to the range [0, 1] to ensure it lies on the segment
                        projection = max(0, min(1, projection))

                        # Calculate the projection point on the edge
                        projection_point = start_point + projection * line_vector

                        # Calculate the perpendicular distance from the clicked position to the edge
                        perpendicular_distance = (
                            img_pos - projection_point
                        ).manhattanLength()

                        # Check if the perpendicular distance is within the margin
                        if perpendicular_distance < 10:  # Margin of 10
                            # Insert a new point at the projection point
                            polygon.insert(i + 1, projection_point)
                            self.annotationUpdated.emit(self.selected_annotation)
                            self.update()
                            return

            # if drawing a polygon, close the polygon
            if (
                self.current_annotation
                and self.mouse_mode == MouseMode.POLYGON
                and len(self.current_annotation.polygon) >= 3
            ):
                self.current_annotation.is_complete = True
                self.finalize_annotation()
                self.annotationAdded.emit(self.current_annotation)
                self.current_annotation = None

                return

            # did we click on an annotation?
            # annotation = self.find_annotation_at(self.widget_to_image_pos(pos))
            # if annotation:
            #     # toggle selection
            #     annotation.selected = not annotation.selected

            #     # make all other annotations unselected
            #     for ann in self.annotations:
            #         if ann != annotation:
            #             ann.selected = False
            # else:
            #     # we clicked on the background
            #     # make all annotations unselected
            #     for ann in self.annotations:
            #         ann.selected = False
            # update the view
            for ann in self.annotations:
                self.annotationUpdated.emit(ann)
            self.update()

    def find_annotation_at(self, pos: QPointF):
        for ann in reversed(self.annotations):
            if ann.rectangle and ann.rectangle.contains(pos):
                return ann
            elif ann.polygon and ann.polygon.containsPoint(pos, Qt.OddEvenFill):
                return ann
            elif ann.points:
                for p in ann.points:
                    if QLineF(pos, p).length() < 5:
                        return ann
        return None

    def edit_annotation_label(self, annotation: Annotation):
        new_label, ok = QInputDialog.getText(
            self, "Edit Label", "Enter new label:", text=annotation.label
        )
        if ok and new_label:
            annotation.label = new_label
            self.annotationUpdated.emit(annotation)  # Emit signal
            self.update()

    def finalize_annotation(self):
        if self.current_label:
            # Use predefined label
            self.current_annotation.annotation_id = len(self.annotations)
            self.current_annotation.label = self.current_label
            self.current_annotation.color = self.current_color
            self.current_annotation.is_complete = True
            self.annotations.append(self.current_annotation)

            self.thumbnails[self.current_annotation.annotation_id] = self.get_thumbnail(
                self.current_annotation
            )
            self.annotationAdded.emit(self.current_annotation)
            self.current_annotation = None
            self.update()
        else:
            # Show custom label dialog
            label, ok = QInputDialog.getText(self, "Label", "Enter label name:")
            if ok:
                if self.current_annotation:
                    self.current_annotation.annotation_id = len(self.annotations)
                    self.current_annotation.label = label or "Unlabeled"
                    self.current_annotation.is_complete = True
                    self.annotations.append(self.current_annotation)
                    self.thumbnails[self.current_annotation.annotation_id] = (
                        self.get_thumbnail(self.current_annotation)
                    )
                    self.annotationAdded.emit(self.current_annotation)
                    self.current_annotation.annotation_id = len(self.annotations)
                    self.current_annotation = None
                    self.update()

    # in update, update cursor

    def _copy_annotation(self):
        self.selected_annotation = self._get_selected_annotation()
        if self.selected_annotation:
            self.copied_annotation = self.selected_annotation
            self.messageSignal.emit(
                f"Copied annotation: {self.selected_annotation.label}"
            )
            self.mouse_mode = MouseMode.IDLE
        else:
            self.messageSignal.emit("No annotation selected to copy.")

    def _paste_annotation(self):
        if self.copied_annotation:
            new_annotation = self.copied_annotation.copy()
            new_annotation.annotation_id = len(self.annotations)
            self.annotations.append(new_annotation)
            self.annotationAdded.emit(new_annotation)
            self.thumbnails[new_annotation.annotation_id] = self.get_thumbnail(
                new_annotation
            )
            self.messageSignal.emit(f"Annotation {new_annotation.label} pasted")
            self.update()
        else:
            self.messageSignal.emit("No annotation copied to paste.")

    def _get_selected_annotation(self):
        for annotation in self.annotations:
            if annotation.selected:
                return annotation

    def layerify_annotation(self, annotations: list[Annotation]):
        annotations = [ann for ann in annotations if ann.visible]

        if len(annotations) == 0:
            QMessageBox.information(
                self.parentWidget(), "Info", "No visible annotations to layerify"
            )
            return
        # Create and configure loading dialog
        self.loading_dialog = QProgressDialog(
            "Processing annotation...",
            "Cancel",  # Optional cancel button
            0,
            0,
            self.parentWidget(),
        )
        self.loading_dialog.setWindowTitle("Please Wait")
        self.loading_dialog.setWindowModality(Qt.WindowModal)
        # self.loading_dialog.setCancelButton()
        self.loading_dialog.show()

        # Force UI update
        QApplication.processEvents()

        # Setup worker thread
        self.worker_thread = QThread()
        self.worker = LayerifyWorker(self.image, annotations, self.config)
        self.worker.moveToThread(self.worker_thread)

        # Connect signals
        self.worker_thread.started.connect(self.worker.process)
        self.worker.finished.connect(self.handle_layerify_result)
        self.worker.finished.connect(self.worker_thread.quit)
        self.worker.error.connect(self.handle_layerify_error)

        # Cleanup connections
        self.worker.finished.connect(self.worker.deleteLater)
        self.worker_thread.finished.connect(self.worker_thread.deleteLater)
        self.worker_thread.finished.connect(self.loading_dialog.close)

        # Start processing
        self.worker_thread.start()

    def handle_layerify_result(self, annotation: Annotation, cropped_image: QPixmap):
        # Create new canvas with results
        new_layer = CanvasLayer(parent=self.parent_obj, config=self.canvas_config)
        # get top left corner of the annotation

        new_layer.set_image(cropped_image)
        new_layer.annotations = [annotation]
        new_layer.layer_name = (
            f"{annotation.label} {annotation.annotation_id} {annotation.annotator}"
        )

        new_layer._apply_edge_opacity()
        new_layer.update()
        self.messageSignal.emit(f"Layerified: {new_layer.layer_name}")
        logger.info(f"Num annotations: {len(self.annotations)}")

        self.layerSignal.emit(new_layer)

    def handle_layerify_error(self, error_msg: str):
        self.loading_dialog.close()
        QMessageBox.critical(
            self.parentWidget(), "Error", f"Processing failed: {error_msg}"
        )

    @property
    def selected_annotation_index(self):
        for idx, annotation in enumerate(self.annotations):
            if annotation.selected:
                return idx
        return -1

apply_opacity()

Apply opacity to the QPixmap image.

Source code in imagebaker/layers/annotable_layer.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def apply_opacity(self):
    """Apply opacity to the QPixmap image."""
    if self.image and self.opacity < 255:
        # Create a new transparent pixmap with the same size
        transparent_pixmap = QPixmap(self.image.size())
        transparent_pixmap.fill(Qt.transparent)

        # Create a painter to draw on the new pixmap
        painter = QPainter(transparent_pixmap)
        try:
            # Set the opacity
            painter.setOpacity(self.opacity / 255.0)

            # Draw the original image onto the new pixmap
            painter.drawPixmap(0, 0, self.image)
        finally:
            # Ensure the painter is properly ended
            painter.end()

        # Replace the original image with the transparent version
        self.image = transparent_pixmap

draw_annotation(painter, annotation, is_temp=False)

Draw annotation on the image.

Source code in imagebaker/layers/annotable_layer.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
def draw_annotation(self, painter, annotation: Annotation, is_temp=False):
    """
    Draw annotation on the image.
    """
    if not annotation.visible:
        return
    painter.save()
    base_color = annotation.color
    pen_color = QColor(
        base_color.red(),
        base_color.green(),
        base_color.blue(),
        self.config.normal_draw_config.pen_alpha,
    )
    brush_color = QColor(
        base_color.red(),
        base_color.green(),
        base_color.blue(),
        self.config.normal_draw_config.brush_alpha,
    )

    pen = QPen(pen_color, self.config.normal_draw_config.line_width / self.scale)
    brush = QBrush(brush_color, Qt.DiagCrossPattern)

    if annotation.selected:
        painter.setPen(
            QPen(
                self.config.selected_draw_config.color,
                self.config.selected_draw_config.line_width / self.scale,
            )
        )
        painter.setBrush(
            QBrush(
                QColor(
                    self.config.selected_draw_config.color.red(),
                    self.config.selected_draw_config.color.green(),
                    self.config.selected_draw_config.color.blue(),
                    self.config.selected_draw_config.brush_alpha,
                )
            )
        )
        if annotation.rectangle:
            painter.drawRect(annotation.rectangle)
        elif annotation.polygon:
            painter.drawPolygon(annotation.polygon)
        elif annotation.points:
            painter.drawEllipse(
                annotation.points[0],
                self.config.selected_draw_config.ellipse_size / self.scale,
                self.config.selected_draw_config.ellipse_size / self.scale,
            )

    if is_temp:
        pen.setStyle(Qt.DashLine)
        brush.setStyle(Qt.Dense4Pattern)

    painter.setPen(pen)
    painter.setBrush(brush)

    # Draw main shape
    if annotation.points:
        for point in annotation.points:
            painter.drawEllipse(
                point,
                self.config.normal_draw_config.point_size / self.scale,
                self.config.normal_draw_config.point_size / self.scale,
            )
    elif annotation.rectangle:
        painter.drawRect(annotation.rectangle)
    elif annotation.polygon:
        if len(annotation.polygon) > 1:
            if annotation.is_complete:
                painter.drawPolygon(annotation.polygon)
            else:
                painter.drawPolyline(annotation.polygon)

    # Draw control points
    if annotation.rectangle:
        rect = annotation.rectangle
        corners = [
            rect.topLeft(),
            rect.topRight(),
            rect.bottomLeft(),
            rect.bottomRight(),
        ]
        painter.save()
        painter.setPen(
            QPen(
                Qt.black,
                self.config.normal_draw_config.control_point_size / self.scale,
            )
        )
        painter.setBrush(QBrush(Qt.white))
        for corner in corners:
            painter.drawEllipse(
                corner,
                self.config.normal_draw_config.point_size / self.scale,
                self.config.normal_draw_config.point_size / self.scale,
            )
        painter.restore()

    if annotation.polygon and len(annotation.polygon) > 0:
        painter.save()
        painter.setPen(
            QPen(
                Qt.white,
                self.config.normal_draw_config.control_point_size / self.scale,
            )
        )
        painter.setBrush(QBrush(Qt.darkGray))
        for point in annotation.polygon:
            painter.drawEllipse(
                point,
                self.config.normal_draw_config.point_size / self.scale,
                self.config.normal_draw_config.point_size / self.scale,
            )
        painter.restore()

    # Draw labels
    if annotation.is_complete and annotation.label:
        painter.save()
        label_pos = self.get_label_position(annotation)
        text = annotation.label

        # Convert to widget coordinates
        widget_pos = QPointF(
            label_pos.x() * self.scale + self.offset.x(),
            label_pos.y() * self.scale + self.offset.y(),
        )

        if annotation.points:
            widget_pos += QPointF(10, 10)

        # Set up font
        font = painter.font()
        font.setPixelSize(
            self.config.normal_draw_config.label_font_size * self.scale
        )  # Fixed screen size
        painter.setFont(font)

        # Calculate text size
        metrics = painter.fontMetrics()
        text_width = metrics.horizontalAdvance(text)
        text_height = metrics.height()

        # Draw background
        bg_rect = QRectF(
            widget_pos.x() - text_width / 2 - 2,
            widget_pos.y() - text_height / 2 - 2,
            text_width + 4,
            text_height + 4,
        )
        painter.resetTransform()
        painter.setBrush(self.config.normal_draw_config.label_font_background_color)
        painter.setPen(Qt.NoPen)
        painter.drawRect(bg_rect)

        # Draw text
        painter.setPen(Qt.white)
        painter.drawText(bg_rect, Qt.AlignCenter, text)
        painter.restore()
        self.label_rects.append((bg_rect, annotation))

    painter.restore()

    # Draw transformation handles for selected annotations
    if annotation.selected and annotation.is_complete:
        painter.save()
        handle_color = self.config.selected_draw_config.handle_color
        painter.setPen(
            QPen(
                handle_color,
                self.config.selected_draw_config.handle_width / self.scale,
            )
        )
        painter.setBrush(QBrush(handle_color))

        if annotation.rectangle:
            rect = annotation.rectangle
            # Draw corner handles
            for corner in [
                rect.topLeft(),
                rect.topRight(),
                rect.bottomLeft(),
                rect.bottomRight(),
            ]:
                painter.drawEllipse(
                    corner,
                    self.config.selected_draw_config.handle_point_size / self.scale,
                    self.config.selected_draw_config.handle_point_size / self.scale,
                )
            # Draw edge handles
            for edge in [
                QPointF(rect.center().x(), rect.top()),
                QPointF(rect.center().x(), rect.bottom()),
                QPointF(rect.left(), rect.center().y()),
                QPointF(rect.right(), rect.center().y()),
            ]:
                painter.drawEllipse(
                    edge,
                    self.config.selected_draw_config.handle_edge_size / self.scale,
                    self.config.selected_draw_config.handle_edge_size / self.scale,
                )

        elif annotation.polygon:
            # Draw vertex handles
            for point in annotation.polygon:
                painter.drawEllipse(
                    point,
                    self.config.selected_draw_config.handle_point_size / self.scale,
                    self.config.selected_draw_config.handle_point_size / self.scale,
                )

        painter.restore()

find_annotation_and_handle_at(pos, margin=10.0)

Find annotation and specific handle at given position

Source code in imagebaker/layers/annotable_layer.py
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
def find_annotation_and_handle_at(self, pos: QPointF, margin=10.0):
    """Find annotation and specific handle at given position"""
    for annotation in reversed(self.annotations):
        if not annotation.visible or not annotation.is_complete:
            continue

        # Check rectangle handles
        if annotation.rectangle:
            rect = annotation.rectangle
            handles = {
                "top_left": rect.topLeft(),
                "top_right": rect.topRight(),
                "bottom_left": rect.bottomLeft(),
                "bottom_right": rect.bottomRight(),
                "top_center": QPointF(rect.center().x(), rect.top()),
                "bottom_center": QPointF(rect.center().x(), rect.bottom()),
                "left_center": QPointF(rect.left(), rect.center().y()),
                "right_center": QPointF(rect.right(), rect.center().y()),
            }

            for handle_name, handle_pos in handles.items():
                if (handle_pos - pos).manhattanLength() < margin:
                    return annotation, handle_name

            if rect.contains(pos):
                return annotation, "move"

        # Check polygon points
        elif annotation.polygon:
            for i, point in enumerate(annotation.polygon):
                if (point - pos).manhattanLength() < margin:
                    return annotation, f"point_{i}"

            if annotation.polygon.containsPoint(pos, Qt.OddEvenFill):
                return annotation, "move"

        # Check points
        elif annotation.points:
            if (annotation.points[0] - pos).manhattanLength() < margin:
                return annotation, "point_0"

    return None, None

Workers

Baker Worker

Bases: QObject

Source code in imagebaker/workers/baker_worker.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
class BakerWorker(QObject):
    finished = Signal(list)  # Emit a list of BakingResult objects
    error = Signal(str)

    def __init__(
        self,
        states: dict[int, list["LayerState"]],
        layers: list["Layer"],
        filename: Path,
    ):
        """
        Worker to bake the images and masks for a given set of states.

        Args:
            states: Dictionary of step -> list of LayerState objects
            layers: List of Layer objects
            filename: Path to the output file
        """
        super().__init__()
        self.states = states  # Dictionary of step -> list of states
        self.layers = layers
        self.filename = filename

    def process(self):
        results = []
        try:
            for step, states in sorted(self.states.items()):
                logger.info(f"Processing step {step}")

                # Calculate bounding box for all layers in this step
                top_left = QPointF(sys.maxsize, sys.maxsize)
                bottom_right = QPointF(-sys.maxsize, -sys.maxsize)

                # contains all states in currenct step
                for state in states:
                    layer = self._get_layer(state.layer_id)
                    if layer and layer.visible and not layer.image.isNull():
                        update_opacities = False
                        logger.debug(
                            f"Updating layer {layer.layer_name} with state: {state}"
                        )

                        if (
                            layer.edge_width != state.edge_width
                            or layer.edge_opacity != state.edge_opacity
                        ):
                            update_opacities = True
                        layer.layer_state = state
                        if update_opacities:
                            layer._apply_edge_opacity()
                        layer.update()

                        transform = QTransform()
                        transform.translate(layer.position.x(), layer.position.y())
                        transform.rotate(layer.rotation)
                        transform.scale(layer.scale_x, layer.scale_y)

                        original_rect = QRectF(QPointF(0, 0), layer.image.size())
                        transformed_rect = transform.mapRect(original_rect)

                        top_left.setX(min(top_left.x(), transformed_rect.left()))
                        top_left.setY(min(top_left.y(), transformed_rect.top()))
                        bottom_right.setX(
                            max(bottom_right.x(), transformed_rect.right())
                        )
                        bottom_right.setY(
                            max(bottom_right.y(), transformed_rect.bottom())
                        )

                # Create the output image for this step
                width = int(bottom_right.x() - top_left.x())
                height = int(bottom_right.y() - top_left.y())
                if width <= 0 or height <= 0:
                    continue

                image = QImage(width, height, QImage.Format_ARGB32)
                image.fill(Qt.transparent)
                masks = []
                mask_names = []
                new_annotations = []

                painter = QPainter(image)
                try:
                    painter.setRenderHints(
                        QPainter.Antialiasing | QPainter.SmoothPixmapTransform
                    )
                    for state in states:
                        layer = self._get_layer(state.layer_id)

                        if layer and layer.visible and not layer.image.isNull():
                            # Draw the layer image with transformations
                            painter.save()
                            try:
                                painter.translate(layer.position - top_left)
                                painter.rotate(layer.rotation)
                                painter.scale(layer.scale_x, layer.scale_y)
                                pixmap_with_alpha = QPixmap(layer.image.size())
                                pixmap_with_alpha.fill(Qt.transparent)

                                temp_painter = QPainter(pixmap_with_alpha)
                                try:
                                    opacity = layer.opacity / 255.0
                                    temp_painter.setOpacity(opacity)
                                    temp_painter.drawPixmap(0, 0, layer.image)
                                finally:
                                    temp_painter.end()

                                painter.drawPixmap(0, 0, pixmap_with_alpha)
                            finally:
                                painter.restore()

                            # Draw the drawing states
                            if state.drawing_states:
                                painter.save()
                                try:
                                    painter.translate(layer.position - top_left)
                                    painter.rotate(layer.rotation)
                                    painter.scale(layer.scale_x, layer.scale_y)
                                    for drawing_state in state.drawing_states:
                                        painter.setPen(
                                            QPen(
                                                drawing_state.color,
                                                drawing_state.size,
                                                Qt.SolidLine,
                                                Qt.RoundCap,
                                                Qt.RoundJoin,
                                            )
                                        )
                                        painter.drawPoint(
                                            drawing_state.position - top_left
                                        )
                                finally:
                                    painter.restore()

                            # Generate the layer mask
                            layer_mask = QImage(width, height, QImage.Format_ARGB32)
                            layer_mask.fill(Qt.transparent)
                            mask_painter = QPainter(layer_mask)
                            try:
                                mask_painter.setRenderHints(
                                    QPainter.Antialiasing
                                    | QPainter.SmoothPixmapTransform
                                )
                                mask_painter.translate(layer.position - top_left)
                                mask_painter.rotate(layer.rotation)
                                mask_painter.scale(layer.scale_x, layer.scale_y)
                                mask_painter.drawPixmap(QPoint(0, 0), layer.image)

                                if state.drawing_states:
                                    mask_painter.save()
                                    try:
                                        for drawing_state in state.drawing_states:
                                            mask_painter.setPen(
                                                QPen(
                                                    Qt.black,
                                                    drawing_state.size,
                                                    Qt.SolidLine,
                                                    Qt.RoundCap,
                                                    Qt.RoundJoin,
                                                )
                                            )
                                            mask_painter.drawPoint(
                                                drawing_state.position
                                            )
                                    finally:
                                        mask_painter.restore()
                            finally:
                                mask_painter.end()

                            # Convert mask to 8-bit
                            mask_arr = qpixmap_to_numpy(layer_mask)
                            alpha_channel = mask_arr[:, :, 3].copy()  # Extract alpha

                            # Binarize the mask (0 or 255)
                            alpha_channel[alpha_channel > 0] = 255

                            masks.append(alpha_channel)
                            mask_names.append(layer.layer_name)

                            # Generate annotations
                            if layer.allow_annotation_export:
                                ann: Annotation = layer.annotations[0]
                                new_annotation = self._generate_annotation(
                                    ann, alpha_channel
                                )
                                new_annotations.append(new_annotation)
                finally:
                    painter.end()

                # Save the image
                filename = self.filename.parent / f"{self.filename.stem}_{step}.png"

                # Append the result
                results.append(
                    BakingResult(
                        filename=filename,
                        step=step,
                        image=image,
                        masks=masks,
                        mask_names=mask_names,
                        annotations=new_annotations,
                    )
                )

            # Emit all results
            self.finished.emit(results)

        except Exception as e:
            logger.error(f"Error in BakerWorker: {e}")
            self.error.emit(str(e))
            traceback.print_exc()

    def _get_layer(self, layer_id):
        for layer in self.layers:
            if layer.layer_id == layer_id:
                return layer
        return None

    def _generate_annotation(self, ann: Annotation, alpha_channel):
        """Generate an annotation based on the alpha channel."""
        new_annotation = Annotation(
            label=ann.label,
            color=ann.color,
            annotation_id=ann.annotation_id,
            is_complete=True,
            visible=True,
        )

        if ann.points:
            new_annotation.points = ann.points
        elif ann.rectangle:
            xywhs = mask_to_rectangles(alpha_channel, merge_rectangles=True)
            new_annotation.rectangle = QRectF(
                xywhs[0][0], xywhs[0][1], xywhs[0][2], xywhs[0][3]
            )
        elif ann.polygon:
            polygon = mask_to_polygons(alpha_channel, merge_polygons=True)
            poly = QPolygonF([QPointF(p[0], p[1]) for p in polygon[0]])
            new_annotation.polygon = poly
        else:
            logger.info("No annotation found")
        return new_annotation

Layerify Worker

Bases: QObject

Source code in imagebaker/workers/layerify_worker.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class LayerifyWorker(QObject):
    finished = Signal(Annotation, QPixmap)
    error = Signal(str)

    def __init__(self, image, annotations, config):
        """
        Worker to layerify an image based on annotations.

        Args:
            image (QPixmap): Image to layerify.
            annotations (List[Annotation]): List of annotations to layerify.
            config (Config): Config object containing settings.
        """
        super().__init__()
        self.image = image.copy()
        self.annotations = annotations
        self.config = config

    def process(self):
        try:
            for annotation in self.annotations:
                logger.info(f"Layerifying annotation {annotation}")
                if annotation.rectangle:
                    cropped_image = self.image.copy(annotation.rectangle.toRect())
                elif annotation.polygon:
                    # Get bounding box and crop
                    bounding_rect = annotation.polygon.boundingRect().toRect()
                    cropped_pixmap = self.image.copy(bounding_rect)

                    # Convert to ARGB32 format to ensure alpha channel support
                    cropped_image = cropped_pixmap.toImage().convertToFormat(
                        QImage.Format_ARGB32
                    )

                    # Create mask with sharp edges
                    mask = QImage(cropped_image.size(), QImage.Format_ARGB32)
                    mask.fill(Qt.transparent)

                    # Translate polygon coordinates
                    translated_poly = annotation.polygon.translated(
                        -bounding_rect.topLeft()
                    )

                    # Draw mask without anti-aliasing
                    painter = QPainter(mask)
                    painter.setRenderHint(QPainter.Antialiasing, False)
                    painter.setBrush(QColor(255, 255, 255, 255))  # Opaque white
                    painter.setPen(Qt.NoPen)
                    painter.drawPolygon(translated_poly)
                    painter.end()

                    # Apply mask to image
                    for y in range(cropped_image.height()):
                        for x in range(cropped_image.width()):
                            mask_alpha = mask.pixelColor(x, y).alpha()
                            color = cropped_image.pixelColor(x, y)

                            # Set alpha to 0 outside polygon, 255 inside
                            color.setAlpha(255 if mask_alpha > 0 else 0)
                            cropped_image.setPixelColor(x, y, color)

                    # Convert back to pixmap with proper alpha
                    cropped_image = QPixmap.fromImage(cropped_image)
                else:
                    cropped_image = self.image

                self.finished.emit(annotation, cropped_image)

        except Exception as e:
            print(e)
            import traceback

            traceback.print_exc()
            self.error.emit(str(e))

Model Prediction Worker

Bases: QObject

Source code in imagebaker/workers/model_worker.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class ModelPredictionWorker(QObject):
    finished = Signal(list)
    error = Signal(str)

    def __init__(
        self,
        model: BaseModel,
        image: np.ndarray,
        points: list[int],
        polygons: list[list[int]],
        rectangles: list[list[int]],
        label_hints: list[int],
    ):
        """
        A worker that runs the model prediction in a separate thread.

        Args:
            model (BaseModel): The model to use for prediction.
            image (np.ndarray): The image to predict on.
            points (list[int]): The points to predict on.
            polygons (list[list[int]]): The polygons to predict on.
            rectangles (list[list[int]]): The rectangles to predict on.
            label_hints (list[int]): The label hints to use.
        """
        super().__init__()
        self.model = model
        self.image = image
        self.points = points
        self.polygons = polygons
        self.rectangles = rectangles
        self.label_hints = label_hints

    def process(self):
        try:
            result = self.model.predict(
                self.image,
                self.points,
                self.rectangles,
                self.polygons,
                self.label_hints,
            )
            self.finished.emit(result)
        except Exception as e:
            self.error.emit(str(e))
            traceback.print_exc()
            logger.error(f"Model error: {e}")
            return

Utilities

Image Utilities

draw_annotations(image, annotations)

Draw annotations on an image.

Parameters:

Name Type Description Default
image ndarray

Image to draw on.

required
annotations list[Annotation]

List of annotations to draw.

required

Returns:

Type Description
ndarray

np.ndarray: Image with annotations drawn.

Source code in imagebaker/utils/image.py
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def draw_annotations(image: np.ndarray, annotations: list[Annotation]) -> np.ndarray:
    """
    Draw annotations on an image.

    Args:
        image (np.ndarray): Image to draw on.
        annotations (list[Annotation]): List of annotations to draw.

    Returns:
        np.ndarray: Image with annotations drawn.
    """
    for i, ann in enumerate(annotations):
        if ann.rectangle:
            cv2.rectangle(
                image,
                (int(ann.rectangle.x()), int(ann.rectangle.y())),
                (
                    int(ann.rectangle.x() + ann.rectangle.width()),
                    int(ann.rectangle.y() + ann.rectangle.height()),
                ),
                (0, 255, 0),
                2,
            )
            rect_center = ann.rectangle.center()

            cv2.putText(
                image,
                ann.label,
                (int(rect_center.x()), int(rect_center.y())),
                cv2.FONT_HERSHEY_SIMPLEX,
                1,
                (0, 255, 0),
                2,
            )
        elif ann.polygon:
            cv2.polylines(
                image,
                [np.array([[int(p.x()), int(p.y())] for p in ann.polygon])],
                True,
                (0, 255, 0),
                2,
            )
            polygon_center = ann.polygon.boundingRect().center()
            cv2.putText(
                image,
                ann.label,
                (int(polygon_center.x()), int(polygon_center.y())),
                cv2.FONT_HERSHEY_SIMPLEX,
                1,
                (0, 255, 0),
                2,
            )
        elif ann.points:
            for p in ann.points:
                cv2.circle(image, (int(p.x()), int(p.y())), 5, (0, 255, 0), -1)
            cv2.putText(
                image,
                ann.label,
                (int(ann.points[0].x()), int(ann.points[0].y())),
                cv2.FONT_HERSHEY_SIMPLEX,
                1,
                (0, 255, 0),
                2,
            )
    return image

qpixmap_to_numpy(pixmap)

Convert QPixmap to RGBA numpy array.

Parameters:

Name Type Description Default
pixmap QPixmap | QImage

The QPixmap to convert

required

Returns:

Type Description
ndarray

numpy.ndarray: Array with shape (height, width, 4) containing RGBA values

Source code in imagebaker/utils/image.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def qpixmap_to_numpy(pixmap: QPixmap | QImage) -> np.ndarray:
    """
    Convert QPixmap to RGBA numpy array.

    Args:
        pixmap: The QPixmap to convert

    Returns:
        numpy.ndarray: Array with shape (height, width, 4) containing RGBA values
    """

    if isinstance(pixmap, QPixmap):
        # Convert QPixmap to QImage first
        image = pixmap.toImage()
    else:
        image = pixmap
    # Convert to Format_RGBA8888 for consistent channel ordering
    if image.format() != QImage.Format_RGBA8888:
        image = image.convertToFormat(QImage.Format_RGBA8888)

    width = image.width()
    height = image.height()

    # Get the bytes directly from the QImage
    ptr = image.constBits()

    # Convert memoryview to bytes and then to numpy array
    bytes_data = bytes(ptr)
    arr = np.frombuffer(bytes_data, dtype=np.uint8).reshape((height, width, 4))

    return arr

State Utilities

calculate_intermediate_states(previous_state, current_state, steps)

Calculate intermediate states between previous_state and current_state for a layer. Append the current_state to the list of states after calculating intermediates.

Parameters:

Name Type Description Default
previous_state LayerState

Previous state of the layer.

required
current_state LayerState

Current state of the layer.

required
steps int

Number of intermediate states to calculate.

required
Source code in imagebaker/utils/state_utils.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def calculate_intermediate_states(
    previous_state: LayerState | None, current_state: LayerState | None, steps: int
):
    """
    Calculate intermediate states between previous_state and current_state for a layer.
    Append the current_state to the list of states after calculating intermediates.

    Args:
        previous_state (LayerState): Previous state of the layer.
        current_state (LayerState): Current state of the layer.
        steps (int): Number of intermediate states to calculate.
    """
    if not previous_state or not current_state:
        return [current_state]  # If no previous state, return only the current state

    intermediate_states = []
    for i in range(1, steps + 1):
        # Interpolate attributes between previous_state and current_state
        interpolated_state = LayerState(
            layer_id=current_state.layer_id,
            layer_name=current_state.layer_name,
            opacity=previous_state.opacity
            + (current_state.opacity - previous_state.opacity) * (i / steps),
            position=QPointF(
                previous_state.position.x()
                + (current_state.position.x() - previous_state.position.x())
                * (i / steps),
                previous_state.position.y()
                + (current_state.position.y() - previous_state.position.y())
                * (i / steps),
            ),
            rotation=previous_state.rotation
            + (current_state.rotation - previous_state.rotation) * (i / steps),
            scale=previous_state.scale
            + (current_state.scale - previous_state.scale) * (i / steps),
            scale_x=previous_state.scale_x
            + (current_state.scale_x - previous_state.scale_x) * (i / steps),
            scale_y=previous_state.scale_y
            + (current_state.scale_y - previous_state.scale_y) * (i / steps),
            transform_origin=QPointF(
                previous_state.transform_origin.x()
                + (
                    current_state.transform_origin.x()
                    - previous_state.transform_origin.x()
                )
                * (i / steps),
                previous_state.transform_origin.y()
                + (
                    current_state.transform_origin.y()
                    - previous_state.transform_origin.y()
                )
                * (i / steps),
            ),
            order=current_state.order,
            visible=current_state.visible,
            allow_annotation_export=current_state.allow_annotation_export,
            playing=current_state.playing,
            selected=False,
            is_annotable=current_state.is_annotable,
            status=current_state.status,
            edge_opacity=previous_state.edge_opacity
            + (current_state.edge_opacity - previous_state.edge_opacity) * (i / steps),
            edge_width=previous_state.edge_width
            + (current_state.edge_width - previous_state.edge_width) * (i / steps),
        )

        # Deep copy the drawing_states from the previous_state
        interpolated_state.drawing_states = [
            DrawingState(
                position=d.position,
                color=d.color,
                size=d.size,
            )
            for d in current_state.drawing_states
        ]

        intermediate_states.append(interpolated_state)

    # Append the current state as the final state
    current_state.drawing_states.extend(
        [
            DrawingState(
                position=d.position,
                color=d.color,
                size=d.size,
            )
            for d in current_state.drawing_states
        ]
    )
    intermediate_states.append(current_state)

    return intermediate_states

Transform Mask Utilities

mask_to_polygons(mask, min_polygon_area=10, merge_polygons=False, merge_distance=5)

Convert a binary mask to a list of polygons. Each polygon is a list of (x, y) coordinates.

Parameters:

Name Type Description Default
mask ndarray

Binary mask (0 or 255).

required
min_polygon_area float

Minimum area for a polygon to be included.

10
merge_polygons bool

If True, merges nearby/overlapping polygons.

False
merge_distance int

Max distance between polygons to merge (if merge_polygons=True).

5

Returns:

Type Description
List[List[Tuple[int, int]]]

List[List[Tuple[int, int]]]: List of polygons, each represented as a list of (x, y) points.

Source code in imagebaker/utils/transform_mask.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def mask_to_polygons(
    mask: np.ndarray,
    min_polygon_area: float = 10,
    merge_polygons: bool = False,
    merge_distance: int = 5,  # Max distance between polygons to merge
) -> List[List[Tuple[int, int]]]:
    """
    Convert a binary mask to a list of polygons.
    Each polygon is a list of (x, y) coordinates.

    Args:
        mask (np.ndarray): Binary mask (0 or 255).
        min_polygon_area (float): Minimum area for a polygon to be included.
        merge_polygons (bool): If True, merges nearby/overlapping polygons.
        merge_distance (int): Max distance between polygons to merge (if merge_polygons=True).

    Returns:
        List[List[Tuple[int, int]]]: List of polygons, each represented as a list of (x, y) points.
    """
    contours, _ = cv2.findContours(
        mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    polygons = []
    for contour in contours:
        area = cv2.contourArea(contour)
        if area >= min_polygon_area:
            polygons.append(contour)

    # Sort polygons by area (descending)
    polygons = sorted(
        polygons, key=lambda p: cv2.contourArea(np.array(p)), reverse=True
    )

    # Merge polygons if requested
    if merge_polygons and len(polygons) > 1:
        # Use morphological dilation to merge nearby regions
        kernel = np.ones((merge_distance, merge_distance), np.uint8)
        merged_mask = cv2.dilate(mask, kernel, iterations=1)

        # Re-extract contours after merging
        merged_contours, _ = cv2.findContours(
            merged_mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
        )

        # Filter again by area
        merged_polygons = []
        for contour in merged_contours:
            area = cv2.contourArea(contour)
            if area >= min_polygon_area:
                merged_polygons.append(contour)

        polygons = merged_polygons

    # Convert contours to list of points
    result = []
    for poly in polygons:
        points = poly.squeeze().tolist()  # Remove extra dimensions
        if len(points) >= 3:  # Ensure it's a valid polygon
            result.append([(int(x), int(y)) for x, y in points])

    return result

mask_to_rectangles(mask, merge_rectangles=False, merge_threshold=1, merge_epsilon=0.5)

Convert a binary mask to a list of rectangles. Each rectangle is a tuple of (x, y, w, h).

Parameters:

Name Type Description Default
mask ndarray

Binary mask (0 or 255).

required
merge_rectangles bool

If True, merges overlapping or nearby rectangles.

False
merge_threshold int

Min number of rectangles to merge into one.

1
merge_epsilon float

Controls how close rectangles must be to merge (0.0 to 1.0).

0.5

Returns:

Type Description
List[Tuple[int, int, int, int]]

List[Tuple[int, int, int, int]]: List of rectangles, each as (x, y, w, h).

Source code in imagebaker/utils/transform_mask.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def mask_to_rectangles(
    mask: np.ndarray,
    merge_rectangles: bool = False,
    merge_threshold: int = 1,
    merge_epsilon: float = 0.5,
) -> List[Tuple[int, int, int, int]]:
    """
    Convert a binary mask to a list of rectangles.
    Each rectangle is a tuple of (x, y, w, h).

    Args:
        mask (np.ndarray): Binary mask (0 or 255).
        merge_rectangles (bool): If True, merges overlapping or nearby rectangles.
        merge_threshold (int): Min number of rectangles to merge into one.
        merge_epsilon (float): Controls how close rectangles must be to merge (0.0 to 1.0).

    Returns:
        List[Tuple[int, int, int, int]]: List of rectangles, each as (x, y, w, h).
    """
    contours, _ = cv2.findContours(
        mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    rectangles = []
    for contour in contours:
        x, y, w, h = cv2.boundingRect(contour)
        rectangles.append((x, y, w, h))

    if merge_rectangles and len(rectangles) > 1:
        # Convert rectangles to the format expected by groupRectangles
        rects = np.array(rectangles)
        # groupRectangles requires [x, y, w, h] format
        grouped_rects, _ = cv2.groupRectangles(
            rects.tolist(), merge_threshold, merge_epsilon
        )
        rectangles = [tuple(map(int, rect)) for rect in grouped_rects]

    return rectangles