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
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
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) 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
 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
@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
  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
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
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.mode_buttons = {}
        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.annotationAdded.connect(self.on_annotation_added)
            # layer.annotationUpdated.connect(self.annotation_list.update_list)
            layer.annotationUpdated.connect(self.on_annotation_updated)
            layer.annotationRemoved.connect(self.on_annotation_removed)
            layer.modeChanged.connect(lambda _mode, self=self: self.sync_mode_buttons())
            layer.messageSignal.connect(self.messageSignal)
            layer.layerSignal.connect(self.add_layer)
            layer.labelUpdated.connect(self.on_label_update)

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

    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,
            images_per_page=self.config.deque_maxlen,
        )

        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 ensure_label_available(
        self, label_name: str, color: QColor | None = None
    ) -> bool:
        if not label_name:
            return False

        existing_names = [label.name for label in self.config.predefined_labels]
        if label_name in existing_names:
            return False

        self.config.predefined_labels.append(
            Label(name=label_name, color=color or QColor(255, 255, 255))
        )
        return True

    def sync_labels_from_annotations(self, annotations: list[Annotation]):
        added_any = False
        for annotation in annotations:
            added_any = (
                self.ensure_label_available(annotation.label, annotation.color)
                or added_any
            )

        if added_any:
            self.update_label_combo()

    @staticmethod
    def _rgb(color: QColor) -> tuple[int, int, int]:
        return color.red(), color.green(), color.blue()

    def _label_color(self, label_name: str) -> QColor:
        color = self.config.get_label_color(label_name)
        return QColor(color.red(), color.green(), color.blue())

    def normalize_annotation_colors(
        self, annotations: list[Annotation], add_missing_labels: bool = True
    ) -> bool:
        """Normalize annotation colors to always match their label color."""
        changed = False
        added_any = False

        for annotation in annotations:
            if not annotation.label:
                annotation.label = self.get_default_label().name
                changed = True

            if add_missing_labels:
                added_any = (
                    self.ensure_label_available(annotation.label, annotation.color)
                    or added_any
                )

            target_color = self._label_color(annotation.label)
            if self._rgb(annotation.color) != self._rgb(target_color):
                annotation.color = target_color
                changed = True

        if added_any:
            self.update_label_combo()

        return changed

    def _entry_file_path(self, image_entry: ImageEntry) -> Path:
        if image_entry.is_baked_result and isinstance(image_entry.data, AnnotableLayer):
            return Path(image_entry.data.file_path)
        return Path(image_entry.data)

    def _find_image_entry_index(self, image_entry: ImageEntry) -> int:
        """
        Resolve an image entry index robustly.

        Qt list item payloads can occasionally come back as equal-value objects
        that do not share identity with the original list object.
        """
        for idx, entry in enumerate(self.image_entries):
            if entry is image_entry:
                return idx

        for idx, entry in enumerate(self.image_entries):
            if entry == image_entry:
                return idx

        target_path = str(self._entry_file_path(image_entry))
        target_kind = bool(getattr(image_entry, "is_baked_result", False))
        for idx, entry in enumerate(self.image_entries):
            if bool(entry.is_baked_result) != target_kind:
                continue
            if str(self._entry_file_path(entry)) == target_path:
                return idx

        return -1

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

        # Persist current layer before remapping the visible slot.
        if (
            self.layer is not None
            and self.layer.file_path
            and Path(self.layer.file_path) != Path("Runtime")
        ):
            self.save_layer_annotations(self.layer, delete_if_empty=False)

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

        # Render selected entry into a deterministic visible slot to avoid
        # page-index/modulo remapping bugs.
        selected_layer = self.annotable_layers[0]
        self.curr_image_idx = self._find_image_entry_index(image_entry)
        if self.curr_image_idx < 0:
            # Still continue using the selected entry path so switching works.
            logger.warning("Selected image entry index not found, using direct entry path.")
            self.curr_image_idx = 0
        selected_layer.setVisible(True)

        selected_path = self._entry_file_path(image_entry)
        selected_layer.set_image(selected_path)
        selected_layer.file_path = selected_path

        self.load_layer_annotations(selected_layer)
        if self.layer:
            selected_layer.set_mode(self.layer.mouse_mode)
        self.layer = selected_layer  # Update the currently selected layer

        # Set the current label and color
        self.layer.current_label = current_label
        self.layer.current_color = current_color
        self.sync_labels_from_annotations(self.layer.annotations)
        self.annotation_list.layer = self.layer
        self.annotation_list.update_list()
        self.update_label_combo()
        self.sync_mode_buttons()
        self.sync_label_combo_to_selection()

        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._entry_file_path(self.image_entries[i]))
                    self.load_layer_annotations(layer)
                    self.sync_labels_from_annotations(layer.annotations)
                    layer.layer_name = f"Layer_{i + 1}"
                    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
                        # Select the first item in the image list panel's list widget
                        if self.image_list_panel.list_widget.count() > 0:
                            self.image_list_panel.list_widget.setCurrentRow(0)

                else:
                    layer.setVisible(False)
                    layer.file_path = Path("Runtime")

            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 save_layer_annotations(
        self,
        layer: AnnotableLayer,
        save_dir: Path | None = None,
        delete_if_empty: bool = True,
    ):
        """Save annotations for a specific layer"""
        file_path = layer.file_path
        if not file_path or Path(file_path) == Path("Runtime"):
            return
        if save_dir is None:
            # Save to the cache directory
            save_dir = self.config.cache_dir
        save_dir = self._cache_path_for_file(file_path, save_dir)

        # Keep annotation metadata aligned to the owning image.
        for index, annotation in enumerate(layer.annotations):
            annotation.annotation_id = index
            annotation.selected = False
            annotation.file_path = Path(file_path)

        # if there are annotations
        if len(layer.annotations) > 0:
            Annotation.save_as_json(layer.annotations, save_dir)
            logger.info(f"Saved annotations for {layer.layer_name} to {save_dir}")
        elif delete_if_empty and save_dir.exists():
            # Remove the existing annotation file if no annotations
            os.remove(save_dir)
            logger.info(f"Removed empty annotation file: {save_dir}")

    def _cache_path_for_file(self, file_path: Path, base_dir: Path | None = None) -> Path:
        """Create a stable cache path for an image path using full-path identity."""
        if base_dir is None:
            base_dir = self.config.cache_dir
        normalized = str(Path(file_path))
        digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
        safe_name = Path(file_path).name
        return base_dir / f"{safe_name}.{digest}.json"

    def cleanup_stale_annotation_cache(self) -> tuple[int, int]:
        """
        Remove stale/orphan cache files and migrate legacy cache names.

        Returns:
            tuple[int, int]: (removed_count, migrated_count)
        """
        cache_dir = self.config.cache_dir
        if not cache_dir.exists():
            return 0, 0

        runtime_path = Path("Runtime")
        expected_cache_paths: set[Path] = set()

        for image_entry in self.image_entries:
            try:
                expected_cache_paths.add(
                    self._cache_path_for_file(self._entry_file_path(image_entry)).resolve()
                )
            except Exception:
                continue

        for layer in self.annotable_layers:
            layer_path = Path(getattr(layer, "file_path", runtime_path))
            if layer_path == runtime_path:
                continue
            expected_cache_paths.add(self._cache_path_for_file(layer_path).resolve())

        removed = 0
        migrated = 0

        for cache_file in cache_dir.glob("*.json"):
            cache_file = cache_file.resolve()
            if cache_file.name == "all_annotations.json":
                continue
            if cache_file in expected_cache_paths:
                continue

            try:
                annotations = Annotation.load_from_json(cache_file)
            except Exception:
                # Corrupted or invalid JSON cache file.
                try:
                    cache_file.unlink()
                    removed += 1
                except Exception:
                    pass
                continue

            if not annotations:
                try:
                    cache_file.unlink()
                    removed += 1
                except Exception:
                    pass
                continue

            ann_paths = []
            for annotation in annotations:
                ann_path = Path(getattr(annotation, "file_path", runtime_path))
                if ann_path != runtime_path:
                    ann_paths.append(ann_path)

            if not ann_paths:
                try:
                    cache_file.unlink()
                    removed += 1
                except Exception:
                    pass
                continue

            existing_paths = [path for path in ann_paths if path.exists()]
            if not existing_paths:
                try:
                    cache_file.unlink()
                    removed += 1
                except Exception:
                    pass
                continue

            # Migrate legacy per-image cache filenames to current hashed format.
            unique_paths = set(existing_paths)
            if len(unique_paths) == 1:
                source_path = next(iter(unique_paths))
                target_cache = self._cache_path_for_file(source_path).resolve()
                if target_cache != cache_file:
                    for idx, annotation in enumerate(annotations):
                        annotation.annotation_id = idx
                        annotation.selected = False
                        annotation.file_path = source_path
                    if not target_cache.exists():
                        Annotation.save_as_json(annotations, target_cache)
                    try:
                        cache_file.unlink()
                        removed += 1
                        migrated += 1
                    except Exception:
                        pass

        if removed > 0:
            logger.info(
                f"Cache cleanup complete. Removed {removed} stale cache file(s), migrated {migrated}."
            )
        return removed, migrated

    def get_all_annotations(self) -> list[Annotation]:
        for layer in self.annotable_layers:
            self.save_layer_annotations(layer)

        merged_annotations = []
        seen_files = set()

        for image_entry in self.image_entries:
            file_path = self._entry_file_path(image_entry)

            if file_path in seen_files:
                continue
            seen_files.add(file_path)

            cache_path = self._cache_path_for_file(file_path)
            if cache_path.exists():
                merged_annotations.extend(Annotation.load_from_json(cache_path))

        return merged_annotations

    def load_merged_annotations(self, annotations: list[Annotation]):
        annotations_by_file = defaultdict(list)
        for annotation in annotations:
            annotations_by_file[Path(annotation.file_path)].append(annotation)

        # Persist merged annotations to cache for every image entry, not only visible layers.
        for image_entry in self.image_entries:
            file_path = self._entry_file_path(image_entry)
            cache_path = self._cache_path_for_file(file_path)
            file_annotations = [ann.copy() for ann in annotations_by_file.get(file_path, [])]
            self.normalize_annotation_colors(file_annotations)
            for index, annotation in enumerate(file_annotations):
                annotation.annotation_id = index
                annotation.selected = False
                annotation.file_path = file_path

            if file_annotations:
                Annotation.save_as_json(file_annotations, cache_path)
            elif cache_path.exists():
                os.remove(cache_path)

        # Refresh currently active canvas layers from cache.
        for layer in self.annotable_layers:
            self.load_layer_annotations(layer)
            layer.selected_annotation = None
            layer.current_annotation = None
            layer.update()

    def load_layer_annotations(
        self, layer: AnnotableLayer, load_dir: Path | None = None
    ):
        """Load annotations for a specific layer"""
        if layer.file_path:
            file_path = layer.file_path
            if load_dir is None:
                load_dir = self.config.cache_dir
            load_dir = self._cache_path_for_file(file_path, load_dir)
            if load_dir.exists():
                layer.annotations = Annotation.load_from_json(load_dir)
                self.sync_labels_from_annotations(layer.annotations)
                if self.normalize_annotation_colors(layer.annotations):
                    Annotation.save_as_json(layer.annotations, load_dir)
                logger.info(
                    f"Loaded annotations for {layer.layer_name} from {load_dir}"
                )
            else:
                layer.annotations = []
                layer.selected_annotation = None
                layer.current_annotation = None
                logger.warning(f"No annotations found for {layer.layer_name}")

    def update_active_entries(self, image_entries: list[ImageEntry]):
        """Update the active entries in the image list panel."""
        # Persist all currently mapped layers before remapping page slots.
        for layer in self.annotable_layers:
            if layer.file_path:
                self.save_layer_annotations(layer, delete_if_empty=False)
        self.curr_image_idx = 0
        page_start = self.image_list_panel.current_page * self.image_list_panel.images_per_page
        for i, layer in enumerate(self.annotable_layers):
            layer.annotations = []

            if i < len(image_entries):
                # Use deterministic global index for the current page instead of
                # value-based lookup, which can point to a wrong duplicate entry.
                idx = page_start + i
                if idx >= len(self.image_entries):
                    layer.setVisible(False)
                    continue
                entry = self.image_entries[idx]
                entry_path = self._entry_file_path(entry)
                layer.set_image(entry_path)
                layer.file_path = entry_path
                self.load_layer_annotations(layer)

                layer.layer_name = f"Layer_{idx + 1}"
                layer.setVisible(i == 0)
                if i == 0:
                    self.layer = layer
                    # update annotation list to the first layer
                    self.annotation_list.layer = self.layer
                    self.annotation_list.update_list()
            else:
                layer.setVisible(False)
                layer.file_path = Path("Runtime")
        logger.info("Updated active entries in image list panel.")

    def clear_annotations(self):
        """Safely clear all annotations"""
        try:
            # Clear layer annotations
            self.clearAnnotations.emit()
            self.messageSignal.emit("Annotations cleared")
            # clear cache annotation of layer
            annotation_path = (
                self._cache_path_for_file(self.layer.file_path)
            )
            if annotation_path.exists():
                os.remove(annotation_path)
                logger.info(f"Cleared annotations from {annotation_path}")

        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 is not in the predefined labels, add it
        skip_label_registry = getattr(annotation, "_skip_label_registry", False)
        if hasattr(annotation, "_skip_label_registry"):
            delattr(annotation, "_skip_label_registry")

        if not skip_label_registry and self.ensure_label_available(
            annotation.label, annotation.color
        ):
            logger.info(f"Label {annotation.label} created.")
            self.update_label_combo()

        annotation.color = self._label_color(annotation.label)
        logger.info(f"Added annotation: {annotation.label}")
        self.messageSignal.emit(f"Added annotation: {annotation.label}")
        self.save_layer_annotations(self.layer)
        # 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.
        """
        annotation.color = self._label_color(annotation.label)
        self.messageSignal.emit(f"Updated annotation: {annotation.label}")

        # Refresh the annotation list
        self.annotation_list.update_list()
        self.sync_label_combo_to_selection()
        self.save_layer_annotations(self.layer)

    def on_annotation_removed(self):
        """Handle annotation removed event and persist the current layer state."""
        self.messageSignal.emit("Annotation removed")
        self.save_layer_annotations(self.layer)
        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.
        """
        with QSignalBlocker(self.label_combo):
            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)
        logger.info("Updated label combo box with predefined labels.")
        self.sync_label_combo_to_selection()

    def get_default_label(self) -> Label:
        if self.config.predefined_labels:
            return self.config.predefined_labels[0]
        return self.config.default_label

    def update_annotations_for_label_rename(self, old_label: str, new_label: str):
        for layer in self.annotable_layers:
            changed = False
            for annotation in layer.annotations:
                if annotation.label == old_label:
                    annotation.label = new_label
                    changed = True
            if changed:
                self.save_layer_annotations(layer)
                layer.update()

        for image_entry in self.image_entries:
            file_path = self._entry_file_path(image_entry)
            cache_path = self._cache_path_for_file(file_path)
            if not cache_path.exists():
                continue

            annotations = Annotation.load_from_json(cache_path)
            changed = False
            for annotation in annotations:
                if annotation.label == old_label:
                    annotation.label = new_label
                    changed = True
            if changed:
                Annotation.save_as_json(annotations, cache_path)

    def update_annotations_for_label_color(self, label_name: str, color: QColor):
        for layer in self.annotable_layers:
            changed = False
            for annotation in layer.annotations:
                if annotation.label == label_name:
                    if self._rgb(annotation.color) != self._rgb(color):
                        annotation.color = QColor(color.red(), color.green(), color.blue())
                        changed = True
            if changed:
                self.save_layer_annotations(layer)
                layer.update()

        for image_entry in self.image_entries:
            file_path = self._entry_file_path(image_entry)
            cache_path = self._cache_path_for_file(file_path)
            if not cache_path.exists():
                continue

            annotations = Annotation.load_from_json(cache_path)
            changed = False
            for annotation in annotations:
                if annotation.label == label_name:
                    if self._rgb(annotation.color) != self._rgb(color):
                        annotation.color = QColor(color.red(), color.green(), color.blue())
                        changed = True
            if changed:
                Annotation.save_as_json(annotations, cache_path)

    def replace_deleted_label_in_annotations(
        self, deleted_label: str, replacement_label: Label
    ):
        for layer in self.annotable_layers:
            changed = False
            for annotation in layer.annotations:
                if annotation.label == deleted_label:
                    annotation.label = replacement_label.name
                    annotation.color = replacement_label.color
                    changed = True
            if changed:
                self.save_layer_annotations(layer)
                layer.update()

        for image_entry in self.image_entries:
            file_path = self._entry_file_path(image_entry)
            cache_path = self._cache_path_for_file(file_path)
            if not cache_path.exists():
                continue

            annotations = Annotation.load_from_json(cache_path)
            changed = False
            for annotation in annotations:
                if annotation.label == deleted_label:
                    annotation.label = replacement_label.name
                    annotation.color = replacement_label.color
                    changed = True
            if changed:
                Annotation.save_as_json(annotations, cache_path)

    def rename_label(self, current_label: str, new_name: str) -> bool:
        new_name = new_name.strip()
        if not current_label or not new_name or new_name == current_label:
            return False

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

        target_index = next(
            (
                i
                for i, label in enumerate(self.config.predefined_labels)
                if label.name == current_label
            ),
            -1,
        )
        if target_index < 0:
            return False

        self.config.predefined_labels[target_index].name = new_name
        self.update_annotations_for_label_rename(current_label, new_name)
        self.current_label = new_name
        if self.layer:
            self.layer.current_label = new_name

        self.update_label_combo()
        new_index = self.label_combo.findText(new_name)
        if new_index >= 0:
            self.label_combo.setCurrentIndex(new_index)
        self.annotation_list.update_list()
        self.messageSignal.emit(f"Renamed label {current_label} to {new_name}")
        return True

    def rename_current_label(self):
        current_label = self.label_combo.currentText()
        if not current_label:
            return

        new_name, ok = QInputDialog.getText(
            self, "Rename Label", "Enter new label name:", text=current_label
        )
        new_name = new_name.strip() if ok and new_name else ""
        if not new_name:
            return
        self.rename_label(current_label, new_name)

    def delete_labels(self, label_names: list[str], confirm: bool = True) -> bool:
        label_names = [name for name in label_names if name]
        if not label_names:
            return False

        default_label = self.get_default_label()
        protected_labels = {default_label.name}
        if any(name in protected_labels for name in label_names):
            QMessageBox.warning(
                self,
                "Protected Label",
                "The default label cannot be removed.",
            )
            return False

        if confirm:
            label_text = ", ".join(label_names[:5])
            if len(label_names) > 5:
                label_text += ", ..."
            result = QMessageBox.question(
                self,
                "Delete Labels",
                f"Delete {len(label_names)} label(s): {label_text}?\nAffected annotations will be reassigned to '{default_label.name}'.",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if result != QMessageBox.Yes:
                return False

        self.config.predefined_labels = [
            label
            for label in self.config.predefined_labels
            if label.name not in set(label_names)
        ]
        for label_name in label_names:
            self.replace_deleted_label_in_annotations(label_name, default_label)
        self.current_label = default_label.name
        if self.layer:
            self.layer.current_label = default_label.name
            self.layer.current_color = default_label.color

        self.update_label_combo()
        default_index = self.label_combo.findText(default_label.name)
        if default_index >= 0:
            self.label_combo.setCurrentIndex(default_index)
        self.annotation_list.update_list()
        self.messageSignal.emit(f"Deleted {len(label_names)} label(s)")
        return True

    def delete_current_label(self):
        current_label = self.label_combo.currentText()
        if current_label:
            self.delete_labels([current_label])

    def open_label_manager(self):
        dialog = QDialog(self)
        dialog.setWindowTitle("Manage Labels")
        dialog.setModal(True)
        dialog.resize(360, 420)

        layout = QVBoxLayout(dialog)

        label_info = QLabel(
            "Double-click a label to rename it. Select multiple labels to delete them together."
        )
        label_info.setWordWrap(True)
        layout.addWidget(label_info)

        label_list = QListWidget(dialog)
        label_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        for label in self.config.predefined_labels:
            item = QListWidgetItem(label.name)
            item.setData(Qt.UserRole, label.name)
            item.setFlags(item.flags() | Qt.ItemIsEditable)
            label_list.addItem(item)
        layout.addWidget(label_list)

        def handle_item_changed(item: QListWidgetItem):
            old_name = item.data(Qt.UserRole)
            new_name = item.text().strip()
            if not old_name or not new_name:
                item.setText(old_name)
                return
            if not self.rename_label(old_name, new_name):
                item.setText(old_name)
                return
            item.setData(Qt.UserRole, new_name)

        label_list.itemChanged.connect(handle_item_changed)

        delete_button = QPushButton("Delete Selected")
        delete_button.clicked.connect(
            lambda: self._handle_label_manager_delete_selected(dialog, label_list)
        )
        layout.addWidget(delete_button)

        cleanup_button = QPushButton("Delete Default Labels")
        cleanup_button.clicked.connect(
            lambda: self._handle_label_manager_cleanup_defaults(dialog, label_list)
        )
        layout.addWidget(cleanup_button)

        close_button = QPushButton("Close")
        close_button.clicked.connect(dialog.accept)
        layout.addWidget(close_button)

        dialog.setLayout(layout)
        dialog.adjustSize()
        dialog.exec()

    def _handle_label_manager_delete_selected(
        self, dialog: QDialog, label_list: QListWidget
    ):
        selected_names = [
            item.data(Qt.UserRole) for item in label_list.selectedItems()
        ]
        if not selected_names:
            QMessageBox.information(
                dialog, "No Selection", "Select one or more labels to delete."
            )
            return
        if self.delete_labels(selected_names):
            dialog.accept()

    def _handle_label_manager_cleanup_defaults(
        self, dialog: QDialog, label_list: QListWidget
    ):
        custom_labels = {
            label.name
            for label in self.config.predefined_labels
            if label.name not in {"Unlabeled", "Label 1", "Label 2", "Label 3", "Custom"}
        }
        if not custom_labels:
            QMessageBox.information(
                dialog,
                "No Custom Labels",
                "Create your own labels first, then the default placeholders can be removed.",
            )
            return

        default_cleanup_targets = [
            label.name
            for label in self.config.predefined_labels
            if label.name in {"Label 1", "Label 2", "Label 3", "Custom"}
        ]
        if not default_cleanup_targets:
            QMessageBox.information(
                dialog, "Nothing To Delete", "No default placeholder labels remain."
            )
            return

        if self.delete_labels(default_cleanup_targets):
            dialog.accept()

    def sync_label_combo_to_selection(self):
        if not hasattr(self, "label_combo"):
            return

        selected_annotation = self.layer._get_selected_annotation() if self.layer else None
        target_label = (
            selected_annotation.label if selected_annotation is not None else self.current_label
        )
        if not target_label:
            return

        with QSignalBlocker(self.label_combo):
            combo_index = self.label_combo.findText(target_label)
            if combo_index >= 0:
                self.label_combo.setCurrentIndex(combo_index)

    def set_annotation_mode(self, mode: MouseMode):
        if self.layer:
            self.layer.set_mode(mode)
        self.sync_mode_buttons()

    def sync_mode_buttons(self):
        if not self.mode_buttons or not self.layer:
            return

        for mode, button in self.mode_buttons.items():
            button.setChecked(mode == self.layer.mouse_mode)

    def on_label_update(self, old_new_label: tuple[str, str]):
        new_labels = []
        index = 0
        for i, label in enumerate(self.config.predefined_labels):
            if label.name == old_new_label[0]:
                label.name = old_new_label[1]
                index = i
            new_labels.append(label)

        self.config.predefined_labels = new_labels
        logger.info(f"Updated label from {old_new_label[0]} to {old_new_label[1]}")
        self.messageSignal.emit(
            f"Updated label from {old_new_label[0]} to {old_new_label[1]}."
        )

        self.update_label_combo()
        self.handle_label_change(index=index)
        self.label_combo.update()

    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 self.ensure_label_available(prediction.class_name):
                self.update_label_combo()

            prediction_color = self._label_color(prediction.class_name)
            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=prediction_color,
                        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=prediction_color,
                        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=prediction_color,
                        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.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
        self.save_layer_annotations(self.layer)
        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 and not any(
            layer.annotations for layer in self.annotable_layers
        ):
            QMessageBox.warning(self, "Warning", "No annotations to save!")
            return

        # an option to save all annotations (i.e. from all layers) or just the current layer
        options = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        # Change the "No" button text to "Just This Layer"
        msg_box = QMessageBox(self)
        msg_box.setWindowTitle("Save Annotations")
        msg_box.setText("Do you want to save annotations from all layers?")
        yes_button = msg_box.addButton("All Layers", QMessageBox.YesRole)
        msg_box.addButton("Just This Layer", QMessageBox.NoRole)
        msg_box.setDefaultButton(yes_button)
        msg_box.exec()
        save_all = msg_box.clickedButton() == yes_button

        if save_all:
            options = QFileDialog.Options()
            file_name, _ = QFileDialog.getSaveFileName(
                self,
                "Save Merged Annotations",
                str(self.config.cache_dir / "all_annotations.json"),
                "JSON Files (*.json)",
                options=options,
            )
            if not file_name:
                QMessageBox.warning(self, "Warning", "No file selected!")
                return
            merged_annotations = self.get_all_annotations()
            Annotation.save_as_json(merged_annotations, file_name)
            QMessageBox.information(
                self, "Success", "Merged annotations saved successfully!"
            )
            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.
        """
        # dialog box to load all annotations or just the current layer
        options = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
        # Change the "No" button text to "Just This Layer"
        msg_box = QMessageBox(self)
        msg_box.setWindowTitle("Load Annotations")
        msg_box.setText("Do you want to load annotations from all layers?")
        yes_button = msg_box.addButton("All Layers", QMessageBox.YesRole)
        msg_box.addButton("Just This Layer", QMessageBox.NoRole)
        msg_box.setDefaultButton(yes_button)
        msg_box.exec()
        load_all = msg_box.clickedButton() == yes_button
        if load_all:
            options = QFileDialog.Options()
            file_name, _ = QFileDialog.getOpenFileName(
                self,
                "Load Merged Annotations",
                str(self.config.cache_dir),
                "JSON Files (*.json)",
                options=options,
            )
            if not file_name:
                QMessageBox.warning(self, "Warning", "No file selected!")
                return
            annotations = Annotation.load_from_json(file_name)
            self.load_merged_annotations(annotations)
            self.update_annotation_list()
            QMessageBox.information(
                self, "Success", "Merged annotations loaded successfully!"
            )
            return
        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.sync_labels_from_annotations(self.layer.annotations)
                self.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
                self.save_layer_annotations(self.layer)
                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()
                self.update_annotations_for_label_color(current_label, color)
                self.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
                self.annotation_list.update_list()

    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.ensure_label_available(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."""
        if index < 0 or index >= len(self.config.predefined_labels):
            return

        label_info = self.config.predefined_labels[index]
        self.current_label = label_info.name

        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)
        selected_annotation = self.layer._get_selected_annotation()
        self.layer.selected_annotation = selected_annotation
        if selected_annotation:
            selected_annotation.label = label_info.name
            selected_annotation.color = label_info.color
            self.on_annotation_updated(selected_annotation)

        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 toggle_gridlines(self):
        """Toggle lightweight grid overlay in both tabs."""
        checked = self.grid_btn.isChecked() if hasattr(self, "grid_btn") else False
        self.config.show_gridlines = checked
        self.canvas_config.show_gridlines = checked
        if self.layer:
            self.layer.update()
        if hasattr(self.main_window, "baker_tab") and self.main_window.baker_tab.current_canvas:
            self.main_window.baker_tab.current_canvas.update()
        self.messageSignal.emit(f"Gridlines {'enabled' if checked else 'disabled'}.")

    def toggle_theme(self):
        """Toggle app theme."""
        if hasattr(self.main_window, "toggle_theme"):
            self.main_window.toggle_theme()

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

        modes = [
            ("📍", "Point", MouseMode.POINT),
            ("🔷", "Polygon", MouseMode.POLYGON),
            ("🔳", "Rectangle", MouseMode.RECTANGLE),
            ("⏳", "Idle", 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", self.clear_annotations),
        ]

        # 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)

        # Add mode buttons
        for icon, text, mode in modes:
            btn_txt = icon + text
            btn = QPushButton(btn_txt)
            btn.setToolTip(btn_txt)
            btn.setMinimumWidth(96)
            btn.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
            if isinstance(mode, MouseMode):
                btn.setCheckable(True)
                btn.setStyleSheet(
                    "QPushButton:checked {"
                    " background-color: #2f80ed;"
                    " color: white;"
                    " border: 2px solid #1456b8;"
                    " font-weight: bold;"
                    "}"
                )
                btn.clicked.connect(lambda _, m=mode: self.set_annotation_mode(m))
                self.mode_buttons[mode] = btn
            else:
                btn.clicked.connect(mode)
            toolbar_layout.addWidget(btn)

        # Add spacer
        self.grid_btn = QPushButton("Grid")
        self.grid_btn.setCheckable(True)
        self.grid_btn.setChecked(self.config.show_gridlines)
        self.grid_btn.clicked.connect(self.toggle_gridlines)
        toolbar_layout.addWidget(self.grid_btn)

        self.theme_btn = QPushButton("Theme")
        self.theme_btn.clicked.connect(self.toggle_theme)
        toolbar_layout.addWidget(self.theme_btn)

        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.manage_label_btn = QPushButton("Manage Label")
        self.manage_label_btn.clicked.connect(self.open_label_manager)
        toolbar_layout.addWidget(self.manage_label_btn)

        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)
        self.sync_mode_buttons()

    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)
                )

        # load from bake folder if it exists
        bake_folder = self.config.bake_dir
        if bake_folder.exists() and bake_folder.is_dir():
            for img_path in bake_folder.glob("*.*"):
                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:
            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()

            else:
                QMessageBox.warning(
                    self,
                    "No Images Found",
                    "No valid image files found in the selected folder.",
                )

    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."""
        try:
            self.layer.setVisible(False)  # Hide the current layer
            filename = Path(baking_result.filename)
            filepath = self.config.bake_dir / filename.name
            baking_result.image.save(str(filepath))
            Annotation.save_as_json(
                baking_result.annotations, self._cache_path_for_file(filepath)
            )
            self.sync_labels_from_annotations(
                [annotation.copy() for annotation in baking_result.annotations]
            )

            baked_result_entry = ImageEntry(is_baked_result=True, data=filepath)
            self.image_entries.append(baked_result_entry)

            logger.info("A baked result has arrived, adding it to the image list.")
            page_index = (len(self.image_entries) - 1) // self.config.deque_maxlen
            self.image_list_panel.image_entries = self.image_entries
            self.image_list_panel.current_page = page_index
            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)
        except Exception as e:
            logger.exception(f"Failed to add baked result: {e}")
            self.messageSignal.emit(f"Failed to add baked result: {e}")

    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}")
        if event.modifiers() == Qt.ControlModifier:
            if key == Qt.Key_Z:
                self.layer.undo()
                self.annotation_list.update_list()
                event.accept()
                return
            if key == Qt.Key_Y:
                self.layer.redo()
                self.annotation_list.update_list()
                event.accept()
                return
            if key == Qt.Key_A:
                self.layer.select_all_annotations()
                self.annotation_list.update_list()
                event.accept()
                return
            if key == Qt.Key_C:
                self.layer.copy_annotation()
                event.accept()
                return
            if key == Qt.Key_V:
                self.layer.paste_annotation()
                self.annotation_list.update_list()
                event.accept()
                return

        # only if no modifier keys are pressed
        if event.modifiers() == Qt.NoModifier:
            # 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:
                if self.layer:
                    self.layer.delete_selected_annotations()
                    self.annotation_list.update_list()
                    logger.info("Selected annotation deleted.")

            # if clicked q, set the mode to point
            elif key == Qt.Key_Q:
                self.set_annotation_mode(MouseMode.POINT)
                logger.info("Mouse mode set to POINT.")
            # if clicked w, set the mode to polygon
            elif key == Qt.Key_W:
                self.set_annotation_mode(MouseMode.POLYGON)
                logger.info("Mouse mode set to POLYGON.")
            # if clicked e, set the mode to rectangle
            elif key == Qt.Key_E:
                self.set_annotation_mode(MouseMode.RECTANGLE)
                logger.info("Mouse mode set to RECTANGLE.")
                # if pressed h key, then toggle visibility of annotations
            elif event.key() == Qt.Key_H:
                self.layer.toggle_annotation_visibility()

            # if selected l, then run layerify the selected annotations
            elif event.key() == Qt.Key_L:
                if self.layer and self.layer.annotations:
                    selected_annotations = [
                        ann for ann in self.layer.annotations if ann.selected
                    ]
                    if selected_annotations:
                        logger.info(
                            f"Layerifying {len(selected_annotations)} selected annotations."
                        )
                        self.layer.layerify_annotation(selected_annotations)
                    else:
                        # layerify all annotations in the current layer
                        self.layer.layerify_annotation(self.layer.annotations)
                    logger.info("Layerified all annotations in the current layer.")
                else:
                    logger.warning("No annotations to layerify.")
            elif event.key() == Qt.Key_C:
                # if an annotation is selected, open a dialog box
                # in that dialog box, ask for a caption
                self.layer.selected_annotation = self.layer._get_selected_annotation()
                if self.layer.selected_annotation:
                    current_caption = getattr(
                        self.layer.selected_annotation, "caption", ""
                    )
                    text, ok = QInputDialog.getMultiLineText(
                        self, "Edit Caption", "Enter caption:", current_caption
                    )
                    if ok:
                        self.layer.selected_annotation.caption = text
                        self.layer.update()

            # 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
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
def add_baked_result(self, baking_result: BakingResult):
    """Add a baked result to the baked results list and update the image list."""
    try:
        self.layer.setVisible(False)  # Hide the current layer
        filename = Path(baking_result.filename)
        filepath = self.config.bake_dir / filename.name
        baking_result.image.save(str(filepath))
        Annotation.save_as_json(
            baking_result.annotations, self._cache_path_for_file(filepath)
        )
        self.sync_labels_from_annotations(
            [annotation.copy() for annotation in baking_result.annotations]
        )

        baked_result_entry = ImageEntry(is_baked_result=True, data=filepath)
        self.image_entries.append(baked_result_entry)

        logger.info("A baked result has arrived, adding it to the image list.")
        page_index = (len(self.image_entries) - 1) // self.config.deque_maxlen
        self.image_list_panel.image_entries = self.image_entries
        self.image_list_panel.current_page = page_index
        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)
    except Exception as e:
        logger.exception(f"Failed to add baked result: {e}")
        self.messageSignal.emit(f"Failed to add baked result: {e}")

add_layer(layer)

Add a new layer to the tab.

Source code in imagebaker/tabs/layerify_tab.py
1413
1414
1415
1416
1417
1418
1419
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
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
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.ensure_label_available(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
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
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()
            self.update_annotations_for_label_color(current_label, color)
            self.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
            self.annotation_list.update_list()

cleanup_stale_annotation_cache()

Remove stale/orphan cache files and migrate legacy cache names.

Returns:

Type Description
tuple[int, int]

tuple[int, int]: (removed_count, migrated_count)

Source code in imagebaker/tabs/layerify_tab.py
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
def cleanup_stale_annotation_cache(self) -> tuple[int, int]:
    """
    Remove stale/orphan cache files and migrate legacy cache names.

    Returns:
        tuple[int, int]: (removed_count, migrated_count)
    """
    cache_dir = self.config.cache_dir
    if not cache_dir.exists():
        return 0, 0

    runtime_path = Path("Runtime")
    expected_cache_paths: set[Path] = set()

    for image_entry in self.image_entries:
        try:
            expected_cache_paths.add(
                self._cache_path_for_file(self._entry_file_path(image_entry)).resolve()
            )
        except Exception:
            continue

    for layer in self.annotable_layers:
        layer_path = Path(getattr(layer, "file_path", runtime_path))
        if layer_path == runtime_path:
            continue
        expected_cache_paths.add(self._cache_path_for_file(layer_path).resolve())

    removed = 0
    migrated = 0

    for cache_file in cache_dir.glob("*.json"):
        cache_file = cache_file.resolve()
        if cache_file.name == "all_annotations.json":
            continue
        if cache_file in expected_cache_paths:
            continue

        try:
            annotations = Annotation.load_from_json(cache_file)
        except Exception:
            # Corrupted or invalid JSON cache file.
            try:
                cache_file.unlink()
                removed += 1
            except Exception:
                pass
            continue

        if not annotations:
            try:
                cache_file.unlink()
                removed += 1
            except Exception:
                pass
            continue

        ann_paths = []
        for annotation in annotations:
            ann_path = Path(getattr(annotation, "file_path", runtime_path))
            if ann_path != runtime_path:
                ann_paths.append(ann_path)

        if not ann_paths:
            try:
                cache_file.unlink()
                removed += 1
            except Exception:
                pass
            continue

        existing_paths = [path for path in ann_paths if path.exists()]
        if not existing_paths:
            try:
                cache_file.unlink()
                removed += 1
            except Exception:
                pass
            continue

        # Migrate legacy per-image cache filenames to current hashed format.
        unique_paths = set(existing_paths)
        if len(unique_paths) == 1:
            source_path = next(iter(unique_paths))
            target_cache = self._cache_path_for_file(source_path).resolve()
            if target_cache != cache_file:
                for idx, annotation in enumerate(annotations):
                    annotation.annotation_id = idx
                    annotation.selected = False
                    annotation.file_path = source_path
                if not target_cache.exists():
                    Annotation.save_as_json(annotations, target_cache)
                try:
                    cache_file.unlink()
                    removed += 1
                    migrated += 1
                except Exception:
                    pass

    if removed > 0:
        logger.info(
            f"Cache cleanup complete. Removed {removed} stale cache file(s), migrated {migrated}."
        )
    return removed, migrated

clear_annotations()

Safely clear all annotations

Source code in imagebaker/tabs/layerify_tab.py
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
def clear_annotations(self):
    """Safely clear all annotations"""
    try:
        # Clear layer annotations
        self.clearAnnotations.emit()
        self.messageSignal.emit("Annotations cleared")
        # clear cache annotation of layer
        annotation_path = (
            self._cache_path_for_file(self.layer.file_path)
        )
        if annotation_path.exists():
            os.remove(annotation_path)
            logger.info(f"Cleared annotations from {annotation_path}")

    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
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
def create_toolbar(self):
    """Create Layerify-specific toolbar"""
    self.toolbar = QWidget()
    toolbar_layout = QHBoxLayout(self.toolbar)

    modes = [
        ("📍", "Point", MouseMode.POINT),
        ("🔷", "Polygon", MouseMode.POLYGON),
        ("🔳", "Rectangle", MouseMode.RECTANGLE),
        ("⏳", "Idle", 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", self.clear_annotations),
    ]

    # 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)

    # Add mode buttons
    for icon, text, mode in modes:
        btn_txt = icon + text
        btn = QPushButton(btn_txt)
        btn.setToolTip(btn_txt)
        btn.setMinimumWidth(96)
        btn.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
        if isinstance(mode, MouseMode):
            btn.setCheckable(True)
            btn.setStyleSheet(
                "QPushButton:checked {"
                " background-color: #2f80ed;"
                " color: white;"
                " border: 2px solid #1456b8;"
                " font-weight: bold;"
                "}"
            )
            btn.clicked.connect(lambda _, m=mode: self.set_annotation_mode(m))
            self.mode_buttons[mode] = btn
        else:
            btn.clicked.connect(mode)
        toolbar_layout.addWidget(btn)

    # Add spacer
    self.grid_btn = QPushButton("Grid")
    self.grid_btn.setCheckable(True)
    self.grid_btn.setChecked(self.config.show_gridlines)
    self.grid_btn.clicked.connect(self.toggle_gridlines)
    toolbar_layout.addWidget(self.grid_btn)

    self.theme_btn = QPushButton("Theme")
    self.theme_btn.clicked.connect(self.toggle_theme)
    toolbar_layout.addWidget(self.theme_btn)

    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.manage_label_btn = QPushButton("Manage Label")
    self.manage_label_btn.clicked.connect(self.open_label_manager)
    toolbar_layout.addWidget(self.manage_label_btn)

    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)
    self.sync_mode_buttons()

handle_label_change(index)

Handle the label change event.

Source code in imagebaker/tabs/layerify_tab.py
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
def handle_label_change(self, index):
    """Handle the label change event."""
    if index < 0 or index >= len(self.config.predefined_labels):
        return

    label_info = self.config.predefined_labels[index]
    self.current_label = label_info.name

    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)
    selected_annotation = self.layer._get_selected_annotation()
    self.layer.selected_annotation = selected_annotation
    if selected_annotation:
        selected_annotation.label = label_info.name
        selected_annotation.color = label_info.color
        self.on_annotation_updated(selected_annotation)

    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
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
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
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
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 self.ensure_label_available(prediction.class_name):
            self.update_label_combo()

        prediction_color = self._label_color(prediction.class_name)
        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=prediction_color,
                    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=prediction_color,
                    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=prediction_color,
                    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.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
    self.save_layer_annotations(self.layer)
    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
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
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
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
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,
        images_per_page=self.config.deque_maxlen,
    )

    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
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
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}")
    if event.modifiers() == Qt.ControlModifier:
        if key == Qt.Key_Z:
            self.layer.undo()
            self.annotation_list.update_list()
            event.accept()
            return
        if key == Qt.Key_Y:
            self.layer.redo()
            self.annotation_list.update_list()
            event.accept()
            return
        if key == Qt.Key_A:
            self.layer.select_all_annotations()
            self.annotation_list.update_list()
            event.accept()
            return
        if key == Qt.Key_C:
            self.layer.copy_annotation()
            event.accept()
            return
        if key == Qt.Key_V:
            self.layer.paste_annotation()
            self.annotation_list.update_list()
            event.accept()
            return

    # only if no modifier keys are pressed
    if event.modifiers() == Qt.NoModifier:
        # 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:
            if self.layer:
                self.layer.delete_selected_annotations()
                self.annotation_list.update_list()
                logger.info("Selected annotation deleted.")

        # if clicked q, set the mode to point
        elif key == Qt.Key_Q:
            self.set_annotation_mode(MouseMode.POINT)
            logger.info("Mouse mode set to POINT.")
        # if clicked w, set the mode to polygon
        elif key == Qt.Key_W:
            self.set_annotation_mode(MouseMode.POLYGON)
            logger.info("Mouse mode set to POLYGON.")
        # if clicked e, set the mode to rectangle
        elif key == Qt.Key_E:
            self.set_annotation_mode(MouseMode.RECTANGLE)
            logger.info("Mouse mode set to RECTANGLE.")
            # if pressed h key, then toggle visibility of annotations
        elif event.key() == Qt.Key_H:
            self.layer.toggle_annotation_visibility()

        # if selected l, then run layerify the selected annotations
        elif event.key() == Qt.Key_L:
            if self.layer and self.layer.annotations:
                selected_annotations = [
                    ann for ann in self.layer.annotations if ann.selected
                ]
                if selected_annotations:
                    logger.info(
                        f"Layerifying {len(selected_annotations)} selected annotations."
                    )
                    self.layer.layerify_annotation(selected_annotations)
                else:
                    # layerify all annotations in the current layer
                    self.layer.layerify_annotation(self.layer.annotations)
                logger.info("Layerified all annotations in the current layer.")
            else:
                logger.warning("No annotations to layerify.")
        elif event.key() == Qt.Key_C:
            # if an annotation is selected, open a dialog box
            # in that dialog box, ask for a caption
            self.layer.selected_annotation = self.layer._get_selected_annotation()
            if self.layer.selected_annotation:
                current_caption = getattr(
                    self.layer.selected_annotation, "caption", ""
                )
                text, ok = QInputDialog.getMultiLineText(
                    self, "Edit Caption", "Enter caption:", current_caption
                )
                if ok:
                    self.layer.selected_annotation.caption = text
                    self.layer.update()

        # 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
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
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
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
def load_annotations(self):
    """
    Load annotations from a JSON file.
    """
    # dialog box to load all annotations or just the current layer
    options = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
    # Change the "No" button text to "Just This Layer"
    msg_box = QMessageBox(self)
    msg_box.setWindowTitle("Load Annotations")
    msg_box.setText("Do you want to load annotations from all layers?")
    yes_button = msg_box.addButton("All Layers", QMessageBox.YesRole)
    msg_box.addButton("Just This Layer", QMessageBox.NoRole)
    msg_box.setDefaultButton(yes_button)
    msg_box.exec()
    load_all = msg_box.clickedButton() == yes_button
    if load_all:
        options = QFileDialog.Options()
        file_name, _ = QFileDialog.getOpenFileName(
            self,
            "Load Merged Annotations",
            str(self.config.cache_dir),
            "JSON Files (*.json)",
            options=options,
        )
        if not file_name:
            QMessageBox.warning(self, "Warning", "No file selected!")
            return
        annotations = Annotation.load_from_json(file_name)
        self.load_merged_annotations(annotations)
        self.update_annotation_list()
        QMessageBox.information(
            self, "Success", "Merged annotations loaded successfully!"
        )
        return
    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.sync_labels_from_annotations(self.layer.annotations)
            self.normalize_annotation_colors(self.layer.annotations, add_missing_labels=False)
            self.save_layer_annotations(self.layer)
            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
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
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
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
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._entry_file_path(self.image_entries[i]))
                self.load_layer_annotations(layer)
                self.sync_labels_from_annotations(layer.annotations)
                layer.layer_name = f"Layer_{i + 1}"
                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
                    # Select the first item in the image list panel's list widget
                    if self.image_list_panel.list_widget.count() > 0:
                        self.image_list_panel.list_widget.setCurrentRow(0)

            else:
                layer.setVisible(False)
                layer.file_path = Path("Runtime")

        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()

load_layer_annotations(layer, load_dir=None)

Load annotations for a specific layer

Source code in imagebaker/tabs/layerify_tab.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
def load_layer_annotations(
    self, layer: AnnotableLayer, load_dir: Path | None = None
):
    """Load annotations for a specific layer"""
    if layer.file_path:
        file_path = layer.file_path
        if load_dir is None:
            load_dir = self.config.cache_dir
        load_dir = self._cache_path_for_file(file_path, load_dir)
        if load_dir.exists():
            layer.annotations = Annotation.load_from_json(load_dir)
            self.sync_labels_from_annotations(layer.annotations)
            if self.normalize_annotation_colors(layer.annotations):
                Annotation.save_as_json(layer.annotations, load_dir)
            logger.info(
                f"Loaded annotations for {layer.layer_name} from {load_dir}"
            )
        else:
            layer.annotations = []
            layer.selected_annotation = None
            layer.current_annotation = None
            logger.warning(f"No annotations found for {layer.layer_name}")

normalize_annotation_colors(annotations, add_missing_labels=True)

Normalize annotation colors to always match their label color.

Source code in imagebaker/tabs/layerify_tab.py
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
def normalize_annotation_colors(
    self, annotations: list[Annotation], add_missing_labels: bool = True
) -> bool:
    """Normalize annotation colors to always match their label color."""
    changed = False
    added_any = False

    for annotation in annotations:
        if not annotation.label:
            annotation.label = self.get_default_label().name
            changed = True

        if add_missing_labels:
            added_any = (
                self.ensure_label_available(annotation.label, annotation.color)
                or added_any
            )

        target_color = self._label_color(annotation.label)
        if self._rgb(annotation.color) != self._rgb(target_color):
            annotation.color = target_color
            changed = True

    if added_any:
        self.update_label_combo()

    return changed

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
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def on_annotation_added(self, annotation: Annotation):
    """Handle annotation added event

    Args:
        annotation (Annotation): The annotation that was added.
    """

    # if annotation.label is not in the predefined labels, add it
    skip_label_registry = getattr(annotation, "_skip_label_registry", False)
    if hasattr(annotation, "_skip_label_registry"):
        delattr(annotation, "_skip_label_registry")

    if not skip_label_registry and self.ensure_label_available(
        annotation.label, annotation.color
    ):
        logger.info(f"Label {annotation.label} created.")
        self.update_label_combo()

    annotation.color = self._label_color(annotation.label)
    logger.info(f"Added annotation: {annotation.label}")
    self.messageSignal.emit(f"Added annotation: {annotation.label}")
    self.save_layer_annotations(self.layer)
    # Refresh the annotation list
    self.annotation_list.update_list()

on_annotation_removed()

Handle annotation removed event and persist the current layer state.

Source code in imagebaker/tabs/layerify_tab.py
665
666
667
668
669
def on_annotation_removed(self):
    """Handle annotation removed event and persist the current layer state."""
    self.messageSignal.emit("Annotation removed")
    self.save_layer_annotations(self.layer)
    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
650
651
652
653
654
655
656
657
658
659
660
661
662
663
def on_annotation_updated(self, annotation: Annotation):
    """
    A slot to handle the annotation updated signal.

    Args:
        annotation (Annotation): The updated annotation.
    """
    annotation.color = self._label_color(annotation.label)
    self.messageSignal.emit(f"Updated annotation: {annotation.label}")

    # Refresh the annotation list
    self.annotation_list.update_list()
    self.sync_label_combo_to_selection()
    self.save_layer_annotations(self.layer)

on_image_selected(image_entry)

Handle image selection from the image list panel.

Source code in imagebaker/tabs/layerify_tab.py
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 on_image_selected(self, image_entry: ImageEntry):
    """Handle image selection from the image list panel."""
    logger.info(f"Image selected: {image_entry}")

    # Persist current layer before remapping the visible slot.
    if (
        self.layer is not None
        and self.layer.file_path
        and Path(self.layer.file_path) != Path("Runtime")
    ):
        self.save_layer_annotations(self.layer, delete_if_empty=False)

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

    # Render selected entry into a deterministic visible slot to avoid
    # page-index/modulo remapping bugs.
    selected_layer = self.annotable_layers[0]
    self.curr_image_idx = self._find_image_entry_index(image_entry)
    if self.curr_image_idx < 0:
        # Still continue using the selected entry path so switching works.
        logger.warning("Selected image entry index not found, using direct entry path.")
        self.curr_image_idx = 0
    selected_layer.setVisible(True)

    selected_path = self._entry_file_path(image_entry)
    selected_layer.set_image(selected_path)
    selected_layer.file_path = selected_path

    self.load_layer_annotations(selected_layer)
    if self.layer:
        selected_layer.set_mode(self.layer.mouse_mode)
    self.layer = selected_layer  # Update the currently selected layer

    # Set the current label and color
    self.layer.current_label = current_label
    self.layer.current_color = current_color
    self.sync_labels_from_annotations(self.layer.annotations)
    self.annotation_list.layer = self.layer
    self.annotation_list.update_list()
    self.update_label_combo()
    self.sync_mode_buttons()
    self.sync_label_combo_to_selection()

    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
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
def save_annotations(self):
    """Save annotations to a JSON file."""
    if not self.layer.annotations and not any(
        layer.annotations for layer in self.annotable_layers
    ):
        QMessageBox.warning(self, "Warning", "No annotations to save!")
        return

    # an option to save all annotations (i.e. from all layers) or just the current layer
    options = QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
    # Change the "No" button text to "Just This Layer"
    msg_box = QMessageBox(self)
    msg_box.setWindowTitle("Save Annotations")
    msg_box.setText("Do you want to save annotations from all layers?")
    yes_button = msg_box.addButton("All Layers", QMessageBox.YesRole)
    msg_box.addButton("Just This Layer", QMessageBox.NoRole)
    msg_box.setDefaultButton(yes_button)
    msg_box.exec()
    save_all = msg_box.clickedButton() == yes_button

    if save_all:
        options = QFileDialog.Options()
        file_name, _ = QFileDialog.getSaveFileName(
            self,
            "Save Merged Annotations",
            str(self.config.cache_dir / "all_annotations.json"),
            "JSON Files (*.json)",
            options=options,
        )
        if not file_name:
            QMessageBox.warning(self, "Warning", "No file selected!")
            return
        merged_annotations = self.get_all_annotations()
        Annotation.save_as_json(merged_annotations, file_name)
        QMessageBox.information(
            self, "Success", "Merged annotations saved successfully!"
        )
        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)}"
            )

save_layer_annotations(layer, save_dir=None, delete_if_empty=True)

Save annotations for a specific layer

Source code in imagebaker/tabs/layerify_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
379
380
381
382
383
384
def save_layer_annotations(
    self,
    layer: AnnotableLayer,
    save_dir: Path | None = None,
    delete_if_empty: bool = True,
):
    """Save annotations for a specific layer"""
    file_path = layer.file_path
    if not file_path or Path(file_path) == Path("Runtime"):
        return
    if save_dir is None:
        # Save to the cache directory
        save_dir = self.config.cache_dir
    save_dir = self._cache_path_for_file(file_path, save_dir)

    # Keep annotation metadata aligned to the owning image.
    for index, annotation in enumerate(layer.annotations):
        annotation.annotation_id = index
        annotation.selected = False
        annotation.file_path = Path(file_path)

    # if there are annotations
    if len(layer.annotations) > 0:
        Annotation.save_as_json(layer.annotations, save_dir)
        logger.info(f"Saved annotations for {layer.layer_name} to {save_dir}")
    elif delete_if_empty and save_dir.exists():
        # Remove the existing annotation file if no annotations
        os.remove(save_dir)
        logger.info(f"Removed empty annotation file: {save_dir}")

select_folder()

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

Source code in imagebaker/tabs/layerify_tab.py
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
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:
        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()

        else:
            QMessageBox.warning(
                self,
                "No Images Found",
                "No valid image files found in the selected folder.",
            )

toggle_gridlines()

Toggle lightweight grid overlay in both tabs.

Source code in imagebaker/tabs/layerify_tab.py
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
def toggle_gridlines(self):
    """Toggle lightweight grid overlay in both tabs."""
    checked = self.grid_btn.isChecked() if hasattr(self, "grid_btn") else False
    self.config.show_gridlines = checked
    self.canvas_config.show_gridlines = checked
    if self.layer:
        self.layer.update()
    if hasattr(self.main_window, "baker_tab") and self.main_window.baker_tab.current_canvas:
        self.main_window.baker_tab.current_canvas.update()
    self.messageSignal.emit(f"Gridlines {'enabled' if checked else 'disabled'}.")

toggle_theme()

Toggle app theme.

Source code in imagebaker/tabs/layerify_tab.py
1444
1445
1446
1447
def toggle_theme(self):
    """Toggle app theme."""
    if hasattr(self.main_window, "toggle_theme"):
        self.main_window.toggle_theme()

update_active_entries(image_entries)

Update the active entries in the image list panel.

Source code in imagebaker/tabs/layerify_tab.py
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
def update_active_entries(self, image_entries: list[ImageEntry]):
    """Update the active entries in the image list panel."""
    # Persist all currently mapped layers before remapping page slots.
    for layer in self.annotable_layers:
        if layer.file_path:
            self.save_layer_annotations(layer, delete_if_empty=False)
    self.curr_image_idx = 0
    page_start = self.image_list_panel.current_page * self.image_list_panel.images_per_page
    for i, layer in enumerate(self.annotable_layers):
        layer.annotations = []

        if i < len(image_entries):
            # Use deterministic global index for the current page instead of
            # value-based lookup, which can point to a wrong duplicate entry.
            idx = page_start + i
            if idx >= len(self.image_entries):
                layer.setVisible(False)
                continue
            entry = self.image_entries[idx]
            entry_path = self._entry_file_path(entry)
            layer.set_image(entry_path)
            layer.file_path = entry_path
            self.load_layer_annotations(layer)

            layer.layer_name = f"Layer_{idx + 1}"
            layer.setVisible(i == 0)
            if i == 0:
                self.layer = layer
                # update annotation list to the first layer
                self.annotation_list.layer = self.layer
                self.annotation_list.update_list()
        else:
            layer.setVisible(False)
            layer.file_path = Path("Runtime")
    logger.info("Updated active entries in image list panel.")

update_annotation_list()

Update the annotation list with the current annotations.

Source code in imagebaker/tabs/layerify_tab.py
1330
1331
1332
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
671
672
673
674
675
676
677
678
679
680
681
682
683
684
def update_label_combo(self):
    """
    Add predefined labels to the label combo box.

    This method is called when a new label is added.
    """
    with QSignalBlocker(self.label_combo):
        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)
    logger.info("Updated label combo box with predefined labels.")
    self.sync_label_combo_to_selection()

Baker Tab

Bases: QWidget

Baker Tab implementation

Source code in imagebaker/tabs/baker_tab.py
 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
class BakerTab(QWidget):
    """Baker Tab implementation"""

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

    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.plugin_registry = {}
        self.plugin_actions: dict[str, QAction] = {}
        self._updating_plugin_menu = False
        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_list.layersSelected.connect(self.sync_plugin_menu_with_selection)
        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.layer_settings.beforeLayerEdit.connect(self.capture_undo_state)
        self.current_canvas.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 capture_undo_state(self):
        """Capture an undo snapshot for settings edits."""
        if self.current_canvas is not None:
            self.current_canvas.push_undo_state()

    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()
        selected_layers = (
            [layer for layer in self.current_canvas.layers if layer.selected]
            if self.current_canvas
            else []
        )
        self.sync_plugin_menu_with_selection(selected_layers)
        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.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.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)

        steps_label = QLabel("Steps:")
        steps_label.setStyleSheet("font-weight: bold;")
        baker_toolbar_layout.addWidget(steps_label)

        self.steps_spinbox = QSpinBox()
        self.steps_spinbox.setMinimum(1)
        self.steps_spinbox.setMaximum(1000)
        self.steps_spinbox.setValue(1)
        self.steps_spinbox.valueChanged.connect(self.update_slider_range)
        baker_toolbar_layout.addWidget(self.steps_spinbox)

        baker_modes = [
            ("Export Current State", self.export_current_state),
            ("Save State", self.save_current_state),
            ("Randomize States", self.randomize_states),
            ("Group Layers", self.group_layers),
            ("Convert Ann Type", self.convert_selected_annotation_type),
            ("Predict State", self.predict_state),
            ("Play States", self.play_saved_states),
            ("Clear States", self.clear_states),
            ("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 text == "Play States":
                self.timeline_slider = QSlider(Qt.Horizontal)
                self.timeline_slider.setMinimum(0)
                self.timeline_slider.setMaximum(0)
                self.timeline_slider.setValue(0)
                self.timeline_slider.setSingleStep(1)
                self.timeline_slider.setPageStep(1)
                self.timeline_slider.setEnabled(False)
                self.timeline_slider.valueChanged.connect(self.seek_state)
                baker_toolbar_layout.addWidget(self.timeline_slider)

        self.plugin_dropdown_btn = QToolButton()
        self.plugin_dropdown_btn.setText("Plugin Options")
        self.plugin_dropdown_btn.setPopupMode(QToolButton.InstantPopup)
        self.plugin_menu = QMenu(self.plugin_dropdown_btn)
        self.plugin_dropdown_btn.setMenu(self.plugin_menu)
        baker_toolbar_layout.addWidget(self.plugin_dropdown_btn)

        draw_button = QPushButton("Draw")
        draw_button.setCheckable(True)
        draw_button.clicked.connect(self.toggle_drawing_mode)
        baker_toolbar_layout.addWidget(draw_button)

        erase_button = QPushButton("Erase")
        erase_button.setCheckable(True)
        erase_button.clicked.connect(self.toggle_erase_mode)
        baker_toolbar_layout.addWidget(erase_button)

        color_picker_button = QPushButton("Color")
        color_picker_button.clicked.connect(self.open_color_picker)
        baker_toolbar_layout.addWidget(color_picker_button)

        self.grid_btn = QPushButton("Grid")
        self.grid_btn.setCheckable(True)
        self.grid_btn.setChecked(self.config.show_gridlines)
        self.grid_btn.clicked.connect(self.toggle_gridlines)
        baker_toolbar_layout.addWidget(self.grid_btn)

        self.theme_btn = QPushButton("Theme")
        self.theme_btn.clicked.connect(self.toggle_theme)
        baker_toolbar_layout.addWidget(self.theme_btn)

        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        baker_toolbar_layout.addWidget(spacer)

        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 toggle_gridlines(self):
        """Toggle lightweight grid overlay."""
        checked = self.grid_btn.isChecked() if hasattr(self, "grid_btn") else False
        self.config.show_gridlines = checked
        if hasattr(self.main_window, "layerify_config"):
            self.main_window.layerify_config.show_gridlines = checked
        if self.current_canvas:
            self.current_canvas.update()
        if hasattr(self.main_window, "layerify_tab") and self.main_window.layerify_tab.layer:
            self.main_window.layerify_tab.layer.update()
        self.messageSignal.emit(f"Gridlines {'enabled' if checked else 'disabled'}.")

    def toggle_theme(self):
        """Toggle app theme."""
        if hasattr(self.main_window, "toggle_theme"):
            self.main_window.toggle_theme()

    def export_for_annotation(self):
        """Export the baked states for annotation."""
        self.messageSignal.emit("Exporting states for prediction...")
        self.requestTabSwitch.emit(0)
        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._reset_steps_input()

        total_states = len(self.current_canvas.states)
        if total_states > 0:
            self.timeline_slider.setMaximum(total_states - 1)
            self.timeline_slider.setEnabled(True)
            self.timeline_slider.setValue(
                self.current_canvas._state_step_index(self.current_canvas.current_step)
            )
        else:
            self.timeline_slider.setMaximum(0)
            self.timeline_slider.setEnabled(False)
        self.timeline_slider.update()

    def _reset_steps_input(self):
        """Reset steps control back to 1 after saving a state."""
        self.steps_spinbox.setValue(1)
        self.steps_spinbox.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 randomize_states(self):
        """Randomize states for the current canvas."""
        if not self.current_canvas or not self.current_canvas.layers:
            self.messageSignal.emit("No layers available to randomize.")
            return

        num_states = max(1, self.steps_spinbox.value())
        self.current_canvas.randomize_states(num_states=num_states)
        self.timeline_slider.setMaximum(num_states - 1)
        self.timeline_slider.setEnabled(True)
        self.timeline_slider.setValue(0)
        self.update_list()
        self.messageSignal.emit(f"Created {num_states} randomized state(s).")

    def convert_selected_annotation_type(self):
        """
        Convert annotation type for selected layers.
        rectangle -> polygon, polygon -> rectangle
        """
        if not self.current_canvas or not self.current_canvas.layers:
            self.messageSignal.emit("No layers available.")
            return

        target_layers = [layer for layer in self.current_canvas.layers if layer.selected]
        if not target_layers:
            self.messageSignal.emit("Select one or more layers to convert annotation type.")
            return

        converted = 0
        skipped = 0

        for layer in target_layers:
            if not layer.annotations:
                skipped += 1
                continue

            ann = layer.annotations[0]
            if ann.polygon is not None and len(ann.polygon) >= 3:
                rect = ann.polygon.boundingRect()
                ann.rectangle = rect
                ann.polygon = None
                ann.points = []
                ann.mask = None
                converted += 1
            elif ann.rectangle is not None:
                rect = ann.rectangle
                ann.polygon = QPolygonF(
                    [
                        rect.topLeft(),
                        rect.topRight(),
                        rect.bottomRight(),
                        rect.bottomLeft(),
                    ]
                )
                ann.rectangle = None
                ann.points = []
                ann.mask = None
                converted += 1
            else:
                skipped += 1

            layer._apply_edge_opacity()
            layer.update()

        self.current_canvas.update()
        self.layer_list.update_list()
        self.layer_settings.update_sliders()
        self.messageSignal.emit(
            f"Converted {converted} annotation(s). Skipped {skipped} layer(s)."
        )

    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 group_layers(self):
        """Group exactly two selected layers on the current canvas."""
        if self.current_canvas is None:
            return
        grouped = self.current_canvas.group_selected_layers()
        if grouped:
            self.layer_list.update_list()
            self.layer_settings.selected_layer = self.current_canvas.selected_layer
            self.layer_settings.update_sliders()

    def reload_plugins(self):
        """Reload discoverable plugins from project root."""
        self.plugin_registry = discover_plugin_classes(Path.cwd())
        self.plugin_actions.clear()
        self.plugin_menu.clear()

        if not self.plugin_registry:
            placeholder = QAction("No plugins discovered", self.plugin_menu)
            placeholder.setEnabled(False)
            self.plugin_menu.addAction(placeholder)
            self.messageSignal.emit("No plugins discovered in project root.")
            return

        for plugin_name in sorted(self.plugin_registry):
            action = QAction(plugin_name, self.plugin_menu)
            action.setCheckable(True)
            action.toggled.connect(self.on_plugin_selection_changed)
            self.plugin_menu.addAction(action)
            self.plugin_actions[plugin_name] = action

        self.messageSignal.emit(
            f"Discovered {len(self.plugin_registry)} plugin option(s)."
        )
        self.sync_plugin_menu_with_selection()

    def _selected_plugin_classes(self):
        selected = []
        for plugin_name, action in self.plugin_actions.items():
            if action.isChecked() and plugin_name in self.plugin_registry:
                selected.append(self.plugin_registry[plugin_name])
        return selected

    def on_plugin_selection_changed(self, _checked: bool):
        if self._updating_plugin_menu:
            return
        self.apply_plugin_selection()

    def apply_plugin_selection(self):
        """Apply currently checked plugin options to selected layers."""
        if self.current_canvas is None:
            return

        plugin_classes = self._selected_plugin_classes()
        selected_layers = [layer for layer in self.current_canvas.layers if layer.selected]
        if selected_layers:
            self.current_canvas.set_plugins_for_selected_layers(plugin_classes)
        else:
            if not self.current_canvas.layers:
                return
            for layer in self.current_canvas.layers:
                layer.selected = True
            try:
                self.current_canvas.set_plugins_for_selected_layers(plugin_classes)
                self.messageSignal.emit(
                    "No layer selected. Applied plugin options to all layers."
                )
            finally:
                for layer in self.current_canvas.layers:
                    layer.selected = False
                self.current_canvas.selected_layer = None
        self.layer_list.update_list()
        self.layer_settings.update_sliders()

    def sync_plugin_menu_with_selection(self, selected_layers=None):
        """Sync dropdown checks from selected layers' current plugin set."""
        if self.current_canvas is None or not self.plugin_actions:
            return

        if selected_layers is None:
            selected_layers = [
                layer for layer in self.current_canvas.layers if layer.selected
            ]
        if not selected_layers:
            return

        intersection = None
        for layer in selected_layers:
            layer_types = {
                type(plugin)
                for plugin in getattr(layer, "plugins", [])
                if getattr(plugin, "enabled", True)
            }
            if intersection is None:
                intersection = set(layer_types)
            else:
                intersection &= layer_types

        intersection = intersection or set()
        self._updating_plugin_menu = True
        try:
            for plugin_name, action in self.plugin_actions.items():
                plugin_class = self.plugin_registry.get(plugin_name)
                action.setChecked(plugin_class in intersection)
        finally:
            self._updating_plugin_menu = False

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

    def keyPressEvent(self, event):
        """Handle key press events."""
        handled_by_canvas = (
            event.key() in {Qt.Key_Delete, Qt.Key_H, Qt.Key_W, Qt.Key_S}
            or (
                event.modifiers() == Qt.ControlModifier
                and event.key()
                in {
                    Qt.Key_C,
                    Qt.Key_V,
                    Qt.Key_D,
                    Qt.Key_E,
                    Qt.Key_S,
                    Qt.Key_G,
                    Qt.Key_Z,
                    Qt.Key_Y,
                }
            )
        )
        if handled_by_canvas and self.current_canvas is not None:
            self.current_canvas.handle_key_press(event)
            if event.isAccepted() and event.key() == Qt.Key_S:
                self._reset_steps_input()
            self.current_canvas.update()
            self.layer_list.update_list()
            self.layer_settings.update_sliders()
            if event.isAccepted():
                self.update()
                return

        # 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.add_layer(new_layer, center=True, on_top=True)
            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)

    def save_canvas_to_cache(self, canvas: CanvasLayer, path: Path | None = None):
        """Save the current canvas state to a file."""
        logger.warning("save_canvas_to_cache is not implemented yet.")

add_layer(layer)

Add a new layer to the canvas.

Source code in imagebaker/tabs/baker_tab.py
670
671
672
673
674
675
def add_layer(self, layer: CanvasLayer):
    """Add a new layer to the canvas."""
    self.current_canvas.add_layer(layer, center=True, on_top=True)
    self.layer_list.update_list()
    self.layer_settings.selected_layer = self.current_canvas.selected_layer
    self.layer_settings.update_sliders()

apply_plugin_selection()

Apply currently checked plugin options to selected layers.

Source code in imagebaker/tabs/baker_tab.py
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
def apply_plugin_selection(self):
    """Apply currently checked plugin options to selected layers."""
    if self.current_canvas is None:
        return

    plugin_classes = self._selected_plugin_classes()
    selected_layers = [layer for layer in self.current_canvas.layers if layer.selected]
    if selected_layers:
        self.current_canvas.set_plugins_for_selected_layers(plugin_classes)
    else:
        if not self.current_canvas.layers:
            return
        for layer in self.current_canvas.layers:
            layer.selected = True
        try:
            self.current_canvas.set_plugins_for_selected_layers(plugin_classes)
            self.messageSignal.emit(
                "No layer selected. Applied plugin options to all layers."
            )
        finally:
            for layer in self.current_canvas.layers:
                layer.selected = False
            self.current_canvas.selected_layer = None
    self.layer_list.update_list()
    self.layer_settings.update_sliders()

capture_undo_state()

Capture an undo snapshot for settings edits.

Source code in imagebaker/tabs/baker_tab.py
110
111
112
113
def capture_undo_state(self):
    """Capture an undo snapshot for settings edits."""
    if self.current_canvas is not None:
        self.current_canvas.push_undo_state()

clear_states()

Clear all saved states and disable the timeline slider.

Source code in imagebaker/tabs/baker_tab.py
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
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()

convert_selected_annotation_type()

Convert annotation type for selected layers. rectangle -> polygon, polygon -> rectangle

Source code in imagebaker/tabs/baker_tab.py
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
def convert_selected_annotation_type(self):
    """
    Convert annotation type for selected layers.
    rectangle -> polygon, polygon -> rectangle
    """
    if not self.current_canvas or not self.current_canvas.layers:
        self.messageSignal.emit("No layers available.")
        return

    target_layers = [layer for layer in self.current_canvas.layers if layer.selected]
    if not target_layers:
        self.messageSignal.emit("Select one or more layers to convert annotation type.")
        return

    converted = 0
    skipped = 0

    for layer in target_layers:
        if not layer.annotations:
            skipped += 1
            continue

        ann = layer.annotations[0]
        if ann.polygon is not None and len(ann.polygon) >= 3:
            rect = ann.polygon.boundingRect()
            ann.rectangle = rect
            ann.polygon = None
            ann.points = []
            ann.mask = None
            converted += 1
        elif ann.rectangle is not None:
            rect = ann.rectangle
            ann.polygon = QPolygonF(
                [
                    rect.topLeft(),
                    rect.topRight(),
                    rect.bottomRight(),
                    rect.bottomLeft(),
                ]
            )
            ann.rectangle = None
            ann.points = []
            ann.mask = None
            converted += 1
        else:
            skipped += 1

        layer._apply_edge_opacity()
        layer.update()

    self.current_canvas.update()
    self.layer_list.update_list()
    self.layer_settings.update_sliders()
    self.messageSignal.emit(
        f"Converted {converted} annotation(s). Skipped {skipped} layer(s)."
    )

create_toolbar()

Create Baker-specific toolbar.

Source code in imagebaker/tabs/baker_tab.py
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
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)

    steps_label = QLabel("Steps:")
    steps_label.setStyleSheet("font-weight: bold;")
    baker_toolbar_layout.addWidget(steps_label)

    self.steps_spinbox = QSpinBox()
    self.steps_spinbox.setMinimum(1)
    self.steps_spinbox.setMaximum(1000)
    self.steps_spinbox.setValue(1)
    self.steps_spinbox.valueChanged.connect(self.update_slider_range)
    baker_toolbar_layout.addWidget(self.steps_spinbox)

    baker_modes = [
        ("Export Current State", self.export_current_state),
        ("Save State", self.save_current_state),
        ("Randomize States", self.randomize_states),
        ("Group Layers", self.group_layers),
        ("Convert Ann Type", self.convert_selected_annotation_type),
        ("Predict State", self.predict_state),
        ("Play States", self.play_saved_states),
        ("Clear States", self.clear_states),
        ("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 text == "Play States":
            self.timeline_slider = QSlider(Qt.Horizontal)
            self.timeline_slider.setMinimum(0)
            self.timeline_slider.setMaximum(0)
            self.timeline_slider.setValue(0)
            self.timeline_slider.setSingleStep(1)
            self.timeline_slider.setPageStep(1)
            self.timeline_slider.setEnabled(False)
            self.timeline_slider.valueChanged.connect(self.seek_state)
            baker_toolbar_layout.addWidget(self.timeline_slider)

    self.plugin_dropdown_btn = QToolButton()
    self.plugin_dropdown_btn.setText("Plugin Options")
    self.plugin_dropdown_btn.setPopupMode(QToolButton.InstantPopup)
    self.plugin_menu = QMenu(self.plugin_dropdown_btn)
    self.plugin_dropdown_btn.setMenu(self.plugin_menu)
    baker_toolbar_layout.addWidget(self.plugin_dropdown_btn)

    draw_button = QPushButton("Draw")
    draw_button.setCheckable(True)
    draw_button.clicked.connect(self.toggle_drawing_mode)
    baker_toolbar_layout.addWidget(draw_button)

    erase_button = QPushButton("Erase")
    erase_button.setCheckable(True)
    erase_button.clicked.connect(self.toggle_erase_mode)
    baker_toolbar_layout.addWidget(erase_button)

    color_picker_button = QPushButton("Color")
    color_picker_button.clicked.connect(self.open_color_picker)
    baker_toolbar_layout.addWidget(color_picker_button)

    self.grid_btn = QPushButton("Grid")
    self.grid_btn.setCheckable(True)
    self.grid_btn.setChecked(self.config.show_gridlines)
    self.grid_btn.clicked.connect(self.toggle_gridlines)
    baker_toolbar_layout.addWidget(self.grid_btn)

    self.theme_btn = QPushButton("Theme")
    self.theme_btn.clicked.connect(self.toggle_theme)
    baker_toolbar_layout.addWidget(self.theme_btn)

    spacer = QWidget()
    spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
    baker_toolbar_layout.addWidget(spacer)

    self.main_layout.addWidget(self.toolbar)

export_current_state()

Export the current state as an image.

Source code in imagebaker/tabs/baker_tab.py
553
554
555
556
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
395
396
397
398
399
def export_for_annotation(self):
    """Export the baked states for annotation."""
    self.messageSignal.emit("Exporting states for prediction...")
    self.requestTabSwitch.emit(0)
    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
401
402
403
404
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
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
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()

group_layers()

Group exactly two selected layers on the current canvas.

Source code in imagebaker/tabs/baker_tab.py
564
565
566
567
568
569
570
571
572
def group_layers(self):
    """Group exactly two selected layers on the current canvas."""
    if self.current_canvas is None:
        return
    grouped = self.current_canvas.group_selected_layers()
    if grouped:
        self.layer_list.update_list()
        self.layer_settings.selected_layer = self.current_canvas.selected_layer
        self.layer_settings.update_sliders()

init_ui()

Initialize the UI components.

Source code in imagebaker/tabs/baker_tab.py
 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
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_list.layersSelected.connect(self.sync_plugin_menu_with_selection)
    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.layer_settings.beforeLayerEdit.connect(self.capture_undo_state)
    self.current_canvas.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
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
def keyPressEvent(self, event):
    """Handle key press events."""
    handled_by_canvas = (
        event.key() in {Qt.Key_Delete, Qt.Key_H, Qt.Key_W, Qt.Key_S}
        or (
            event.modifiers() == Qt.ControlModifier
            and event.key()
            in {
                Qt.Key_C,
                Qt.Key_V,
                Qt.Key_D,
                Qt.Key_E,
                Qt.Key_S,
                Qt.Key_G,
                Qt.Key_Z,
                Qt.Key_Y,
            }
        )
    )
    if handled_by_canvas and self.current_canvas is not None:
        self.current_canvas.handle_key_press(event)
        if event.isAccepted() and event.key() == Qt.Key_S:
            self._reset_steps_input()
        self.current_canvas.update()
        self.layer_list.update_list()
        self.layer_settings.update_sliders()
        if event.isAccepted():
            self.update()
            return

    # 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.add_layer(new_layer, center=True, on_top=True)
        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
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
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.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.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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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
371
372
373
374
375
376
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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
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
558
559
560
561
562
def predict_state(self):
    """Pass the current state to predict."""
    self.messageSignal.emit("Predicting state...")

    self.current_canvas.predict_state()

randomize_states()

Randomize states for the current canvas.

Source code in imagebaker/tabs/baker_tab.py
473
474
475
476
477
478
479
480
481
482
483
484
485
def randomize_states(self):
    """Randomize states for the current canvas."""
    if not self.current_canvas or not self.current_canvas.layers:
        self.messageSignal.emit("No layers available to randomize.")
        return

    num_states = max(1, self.steps_spinbox.value())
    self.current_canvas.randomize_states(num_states=num_states)
    self.timeline_slider.setMaximum(num_states - 1)
    self.timeline_slider.setEnabled(True)
    self.timeline_slider.setValue(0)
    self.update_list()
    self.messageSignal.emit(f"Created {num_states} randomized state(s).")

reload_plugins()

Reload discoverable plugins from project root.

Source code in imagebaker/tabs/baker_tab.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
def reload_plugins(self):
    """Reload discoverable plugins from project root."""
    self.plugin_registry = discover_plugin_classes(Path.cwd())
    self.plugin_actions.clear()
    self.plugin_menu.clear()

    if not self.plugin_registry:
        placeholder = QAction("No plugins discovered", self.plugin_menu)
        placeholder.setEnabled(False)
        self.plugin_menu.addAction(placeholder)
        self.messageSignal.emit("No plugins discovered in project root.")
        return

    for plugin_name in sorted(self.plugin_registry):
        action = QAction(plugin_name, self.plugin_menu)
        action.setCheckable(True)
        action.toggled.connect(self.on_plugin_selection_changed)
        self.plugin_menu.addAction(action)
        self.plugin_actions[plugin_name] = action

    self.messageSignal.emit(
        f"Discovered {len(self.plugin_registry)} plugin option(s)."
    )
    self.sync_plugin_menu_with_selection()

save_canvas_to_cache(canvas, path=None)

Save the current canvas state to a file.

Source code in imagebaker/tabs/baker_tab.py
724
725
726
def save_canvas_to_cache(self, canvas: CanvasLayer, path: Path | None = None):
    """Save the current canvas state to a file."""
    logger.warning("save_canvas_to_cache is not implemented yet.")

save_current_state()

Save the current state of the canvas.

Source code in imagebaker/tabs/baker_tab.py
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
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._reset_steps_input()

    total_states = len(self.current_canvas.states)
    if total_states > 0:
        self.timeline_slider.setMaximum(total_states - 1)
        self.timeline_slider.setEnabled(True)
        self.timeline_slider.setValue(
            self.current_canvas._state_step_index(self.current_canvas.current_step)
        )
    else:
        self.timeline_slider.setMaximum(0)
        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
544
545
546
547
548
549
550
551
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()

sync_plugin_menu_with_selection(selected_layers=None)

Sync dropdown checks from selected layers' current plugin set.

Source code in imagebaker/tabs/baker_tab.py
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
def sync_plugin_menu_with_selection(self, selected_layers=None):
    """Sync dropdown checks from selected layers' current plugin set."""
    if self.current_canvas is None or not self.plugin_actions:
        return

    if selected_layers is None:
        selected_layers = [
            layer for layer in self.current_canvas.layers if layer.selected
        ]
    if not selected_layers:
        return

    intersection = None
    for layer in selected_layers:
        layer_types = {
            type(plugin)
            for plugin in getattr(layer, "plugins", [])
            if getattr(plugin, "enabled", True)
        }
        if intersection is None:
            intersection = set(layer_types)
        else:
            intersection &= layer_types

    intersection = intersection or set()
    self._updating_plugin_menu = True
    try:
        for plugin_name, action in self.plugin_actions.items():
            plugin_class = self.plugin_registry.get(plugin_name)
            action.setChecked(plugin_class in intersection)
    finally:
        self._updating_plugin_menu = False

toggle_drawing_mode()

Toggle drawing mode on the current canvas.

Source code in imagebaker/tabs/baker_tab.py
349
350
351
352
353
354
355
356
357
358
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
360
361
362
363
364
365
366
367
368
369
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}.")

toggle_gridlines()

Toggle lightweight grid overlay.

Source code in imagebaker/tabs/baker_tab.py
378
379
380
381
382
383
384
385
386
387
388
def toggle_gridlines(self):
    """Toggle lightweight grid overlay."""
    checked = self.grid_btn.isChecked() if hasattr(self, "grid_btn") else False
    self.config.show_gridlines = checked
    if hasattr(self.main_window, "layerify_config"):
        self.main_window.layerify_config.show_gridlines = checked
    if self.current_canvas:
        self.current_canvas.update()
    if hasattr(self.main_window, "layerify_tab") and self.main_window.layerify_tab.layer:
        self.main_window.layerify_tab.layer.update()
    self.messageSignal.emit(f"Gridlines {'enabled' if checked else 'disabled'}.")

toggle_theme()

Toggle app theme.

Source code in imagebaker/tabs/baker_tab.py
390
391
392
393
def toggle_theme(self):
    """Toggle app theme."""
    if hasattr(self.main_window, "toggle_theme"):
        self.main_window.toggle_theme()

update_list(layer=None)

Update the layer list and layer settings.

Source code in imagebaker/tabs/baker_tab.py
186
187
188
189
190
191
192
193
194
195
196
197
198
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()
    selected_layers = (
        [layer for layer in self.current_canvas.layers if layer.selected]
        if self.current_canvas
        else []
    )
    self.sync_plugin_menu_with_selection(selected_layers)
    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
115
116
117
118
119
120
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
179
180
181
182
183
184
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
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
class ImageListPanel(QDockWidget):
    imageSelected = Signal(object)
    activeImageEntries = Signal(list)

    def __init__(
        self,
        image_entries: list["ImageEntry"],
        processed_images: set[Path],
        parent=None,
        max_name_length=15,
        images_per_page=10,
    ):
        """
        :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 = images_per_page
        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."""
        previous_item = self.list_widget.currentItem()
        previous_entry = (
            previous_item.data(Qt.UserRole) if previous_item is not None else None
        )

        self.list_widget.clear()

        # Calculate the range of images to display for the current page
        start_index = self.current_page * self.images_per_page
        end_index = min(start_index + self.images_per_page, len(image_entries))

        # Update the pagination label
        if len(image_entries) == 0:
            self.pagination_label.setText("Showing 0 to 0 of 0")
        else:
            self.pagination_label.setText(
                f"Showing {start_index + 1} to {end_index} of {len(image_entries)}"
            )

        # Display only the images for the current page
        active_image_entries = []
        for idx, image_entry in enumerate(
            image_entries[start_index:end_index], start=start_index + 1
        ):
            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:
                if hasattr(image_entry.data, "get_thumbnail"):
                    # Legacy baked entry as layer object
                    thumbnail_pixmap = image_entry.data.get_thumbnail()
                else:
                    # Baked entry stored as image path
                    thumbnail_pixmap = QPixmap(str(image_entry.data)).scaled(
                        50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
                    )
                name_label_text = f"Baked Result {idx}"
            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)
            active_image_entries.append(image_entry)

        self.activeImageEntries.emit(active_image_entries)
        restored = False
        if previous_entry is not None:
            for row in range(self.list_widget.count()):
                item = self.list_widget.item(row)
                if item.data(Qt.UserRole) == previous_entry:
                    self.list_widget.setCurrentItem(item)
                    restored = True
                    break

        if not restored and self.list_widget.count() > 0:
            self.list_widget.setCurrentRow(0)

        if self.list_widget.currentItem() is not None:
            self.list_widget.setFocus()

        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
177
178
179
180
181
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
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
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
79
80
81
82
83
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
85
86
87
88
89
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
 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
def update_image_list(self, image_entries):
    """Update the image list with image paths and baked results."""
    previous_item = self.list_widget.currentItem()
    previous_entry = (
        previous_item.data(Qt.UserRole) if previous_item is not None else None
    )

    self.list_widget.clear()

    # Calculate the range of images to display for the current page
    start_index = self.current_page * self.images_per_page
    end_index = min(start_index + self.images_per_page, len(image_entries))

    # Update the pagination label
    if len(image_entries) == 0:
        self.pagination_label.setText("Showing 0 to 0 of 0")
    else:
        self.pagination_label.setText(
            f"Showing {start_index + 1} to {end_index} of {len(image_entries)}"
        )

    # Display only the images for the current page
    active_image_entries = []
    for idx, image_entry in enumerate(
        image_entries[start_index:end_index], start=start_index + 1
    ):
        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:
            if hasattr(image_entry.data, "get_thumbnail"):
                # Legacy baked entry as layer object
                thumbnail_pixmap = image_entry.data.get_thumbnail()
            else:
                # Baked entry stored as image path
                thumbnail_pixmap = QPixmap(str(image_entry.data)).scaled(
                    50, 50, Qt.KeepAspectRatio, Qt.SmoothTransformation
                )
            name_label_text = f"Baked Result {idx}"
        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)
        active_image_entries.append(image_entry)

    self.activeImageEntries.emit(active_image_entries)
    restored = False
    if previous_entry is not None:
        for row in range(self.list_widget.count()):
            item = self.list_widget.item(row)
            if item.data(Qt.UserRole) == previous_entry:
                self.list_widget.setCurrentItem(item)
                restored = True
                break

    if not restored and self.list_widget.count() > 0:
        self.list_widget.setCurrentRow(0)

    if self.list_widget.currentItem() is not None:
        self.list_widget.setFocus()

    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
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
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):
        scroll_bar = self.list_widget.verticalScrollBar()
        scroll_value = scroll_bar.value()
        current_selected_ids = {
            ann.annotation_id for ann in self.layer.annotations if ann.selected
        } if self.layer is not None else set()

        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, event)

            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)
            if ann.annotation_id in current_selected_ids:
                item.setSelected(True)
        scroll_bar.setValue(
            min(scroll_value, scroll_bar.maximum())
        )
        self.update()

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

    def on_annotation_selected(self, index, event=None):
        if 0 <= index < len(self.layer.annotations):
            ann = self.layer.annotations[index]
            is_multi_select = (
                event is not None and bool(event.modifiers() & Qt.ControlModifier)
            )
            if is_multi_select:
                ann.selected = not ann.selected
                self.layer.selected_annotation = ann if ann.selected else self.layer._get_selected_annotation()
            else:
                ann.selected = not ann.selected
                self.layer.selected_annotation = ann if ann.selected else None
                # Set other annotations to not selected
                for i, a in enumerate(self.layer.annotations):
                    if i != index:
                        a.selected = False
            self.layer.annotationUpdated.emit(ann)
            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}")
            self.layer.annotations[index].selected = True
            self.layer.selected_annotation = self.layer.annotations[index]
            self.layer.delete_selected_annotations()
            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()
            event.accept()
            return
        elif event.key() == Qt.Key_A and event.modifiers() == Qt.ControlModifier:
            self.layer.select_all_annotations()
            self.update_list()
            event.accept()
            return
        elif event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier:
            self.layer.paste_annotation()
            event.accept()
            return
        elif event.key() == Qt.Key_Delete:
            self.delete_annotation(self.layer.selected_annotation_index)
            event.accept()
            return
        elif event.key() == Qt.Key_H:
            self.layer.toggle_annotation_visibility()
            event.accept()
            return

sizeHint()

Calculate the preferred size based on the content.

Source code in imagebaker/list_views/annotation_list.py
210
211
212
213
214
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
 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
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."""
        previous_item = self.list_widget.currentItem()
        previous_canvas = (
            previous_item.data(Qt.UserRole) if previous_item is not None else None
        )

        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)

        restored = False
        if previous_canvas is not None:
            for row in range(self.list_widget.count()):
                item = self.list_widget.item(row)
                if item.data(Qt.UserRole) is previous_canvas:
                    self.list_widget.setCurrentItem(item)
                    restored = True
                    break

        if not restored and self.list_widget.count() > 0:
            self.list_widget.setCurrentRow(0)
            restored = True

        current_item = self.list_widget.currentItem()
        if current_item is not None:
            self.handle_item_clicked(current_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
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
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
194
195
196
197
198
199
200
201
202
203
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
188
189
190
191
192
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
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
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
102
103
104
105
106
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
108
109
110
111
112
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
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
def update_canvas_list(self):
    """Update the canvas list with pagination."""
    previous_item = self.list_widget.currentItem()
    previous_canvas = (
        previous_item.data(Qt.UserRole) if previous_item is not None else None
    )

    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)

    restored = False
    if previous_canvas is not None:
        for row in range(self.list_widget.count()):
            item = self.list_widget.item(row)
            if item.data(Qt.UserRole) is previous_canvas:
                self.list_widget.setCurrentItem(item)
                restored = True
                break

    if not restored and self.list_widget.count() > 0:
        self.list_widget.setCurrentRow(0)
        restored = True

    current_item = self.list_widget.currentItem()
    if current_item is not None:
        self.handle_item_clicked(current_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
 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
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)
        self.group_layers_button = QPushButton("Group Layers")
        self.group_layers_button.setToolTip(
            "Group 2 selected layers or ungroup 1 grouped layer (Ctrl+G)"
        )
        self.group_layers_button.clicked.connect(self.group_selected_layers)
        main_layout.addWidget(self.group_layers_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"""
        if self.canvas is not None:
            self.layers = self.canvas.layers
        # 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"""
        if self.canvas is not None:
            self.layers = self.canvas.layers
        selected_layers = {layer for layer in self.layers if layer.selected}
        current_item = self.list_widget.currentItem()
        current_layer = None
        if current_item is not None:
            current_row = self.list_widget.row(current_item)
            if 0 <= current_row < len(self.layers):
                current_layer = self.layers[current_row]

        # 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>")
            plugin_count = len(getattr(layer, "plugins", []))
            if plugin_count:
                plugin_names = ", ".join(
                    getattr(plugin, "name", type(plugin).__name__)
                    for plugin in layer.plugins[:3]
                )
                if plugin_count > 3:
                    plugin_names += ", ..."
                secondary_text.append(f"Plugins ({plugin_count}): {plugin_names}")

            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)

            if layer in selected_layers:
                item.setSelected(True)
            if layer is current_layer:
                self.list_widget.setCurrentItem(item)

        if self.list_widget.currentItem() is None and self.list_widget.count() > 0:
            self.list_widget.setCurrentRow(0)

        # 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 self.canvas is not None:
            self.layers = self.canvas.layers
        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: {[layer.layer_name for layer 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)
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_G:
            self.group_selected_layers()
            event.accept()
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
            if self.canvas is not None and self.canvas.undo():
                self.layers = self.canvas.layers
                self.update_list()
            event.accept()
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
            if self.canvas is not None and self.canvas.redo():
                self.layers = self.canvas.layers
                self.update_list()
            event.accept()
        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 self.canvas is not None:
            self.layers = self.canvas.layers
        if 0 <= index < len(self.layers):
            logger.info(f"Deleting layer: {self.layers[index].layer_name}")
            del self.canvas.layers[index]
            self.layers = self.canvas.layers
            self.update_list()
            self.canvas.update()
            logger.info(f"BaseLayer deleted: {index}")

    def toggle_visibility(self, index):
        """Toggle visibility of a layer by index"""
        if self.canvas is not None:
            self.layers = self.canvas.layers
        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
        if self.canvas is not None:
            self.canvas.layers.append(layer)
            self.layers = self.canvas.layers
        else:
            self.layers.append(layer)
        self.update_list()
        # 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 group_selected_layers(self):
        """Group two selected layers, or ungroup one selected grouped layer."""
        if self.canvas is None:
            return
        grouped = self.canvas.group_selected_layers()
        if grouped:
            self.layers = self.canvas.layers
            self.update_list()

    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:
            if self.canvas.undo():
                self.layers = self.canvas.layers
                self.update_list()
        elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
            if self.canvas.redo():
                self.layers = self.canvas.layers
                self.update_list()

add_layer(layer=None)

Add a new layer to the list

Source code in imagebaker/list_views/layer_list.py
402
403
404
405
406
407
408
409
410
411
def add_layer(self, layer: BaseLayer = None):
    """Add a new layer to the list"""
    if layer is None:
        return
    if self.canvas is not None:
        self.canvas.layers.append(layer)
        self.layers = self.canvas.layers
    else:
        self.layers.append(layer)
    self.update_list()

clear_layers()

Clear all layers from the list

Source code in imagebaker/list_views/layer_list.py
81
82
83
84
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
334
335
336
337
338
339
340
341
342
343
344
345
346
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
377
378
379
380
381
382
383
384
385
386
387
def delete_layer(self, index):
    """Delete a layer by index"""
    if self.canvas is not None:
        self.layers = self.canvas.layers
    if 0 <= index < len(self.layers):
        logger.info(f"Deleting layer: {self.layers[index].layer_name}")
        del self.canvas.layers[index]
        self.layers = self.canvas.layers
        self.update_list()
        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
371
372
373
374
375
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
419
420
421
422
423
424
425
426
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)
    ]

group_selected_layers()

Group two selected layers, or ungroup one selected grouped layer.

Source code in imagebaker/list_views/layer_list.py
428
429
430
431
432
433
434
435
def group_selected_layers(self):
    """Group two selected layers, or ungroup one selected grouped layer."""
    if self.canvas is None:
        return
    grouped = self.canvas.group_selected_layers()
    if grouped:
        self.layers = self.canvas.layers
        self.update_list()

keyPressEvent(event)

Handle key presses.

Source code in imagebaker/list_views/layer_list.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
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:
        if self.canvas.undo():
            self.layers = self.canvas.layers
            self.update_list()
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
        if self.canvas.redo():
            self.layers = self.canvas.layers
            self.update_list()

list_key_press_event(event)

Handle key press events in the list widget

Source code in imagebaker/list_views/layer_list.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
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)
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_G:
        self.group_selected_layers()
        event.accept()
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
        if self.canvas is not None and self.canvas.undo():
            self.layers = self.canvas.layers
            self.update_list()
        event.accept()
    elif event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
        if self.canvas is not None and self.canvas.redo():
            self.layers = self.canvas.layers
            self.update_list()
        event.accept()
    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
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
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: {[layer.layer_name for layer in selected_layers]}"
        )

on_layer_selected(indices)

Select multiple layers by indices

Source code in imagebaker/list_views/layer_list.py
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
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
 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
def on_rows_moved(self, parent, start, end, destination, row):
    """Handle rows being moved in the list widget via drag and drop"""
    if self.canvas is not None:
        self.layers = self.canvas.layers
    # 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
414
415
416
417
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
266
267
268
269
270
271
272
273
274
275
276
277
278
def toggle_annotation_export(self, index, state):
    """Toggle annotation export for a layer by index"""
    if self.canvas is not None:
        self.layers = self.canvas.layers
    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
389
390
391
392
393
394
395
396
397
398
399
400
def toggle_visibility(self, index):
    """Toggle visibility of a layer by index"""
    if self.canvas is not None:
        self.layers = self.canvas.layers
    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
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
def update_list(self):
    """Update the list widget with current layers"""
    if self.canvas is not None:
        self.layers = self.canvas.layers
    selected_layers = {layer for layer in self.layers if layer.selected}
    current_item = self.list_widget.currentItem()
    current_layer = None
    if current_item is not None:
        current_row = self.list_widget.row(current_item)
        if 0 <= current_row < len(self.layers):
            current_layer = self.layers[current_row]

    # 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>")
        plugin_count = len(getattr(layer, "plugins", []))
        if plugin_count:
            plugin_names = ", ".join(
                getattr(plugin, "name", type(plugin).__name__)
                for plugin in layer.plugins[:3]
            )
            if plugin_count > 3:
                plugin_names += ", ..."
            secondary_text.append(f"Plugins ({plugin_count}): {plugin_names}")

        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)

        if layer in selected_layers:
            item.setSelected(True)
        if layer is current_layer:
            self.list_widget.setCurrentItem(item)

    if self.list_widget.currentItem() is None and self.list_widget.count() > 0:
        self.list_widget.setCurrentRow(0)

    # 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
 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
 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
class LayerSettings(QDockWidget):
    layerState = Signal(LayerState)
    messageSignal = Signal(str)
    beforeLayerEdit = Signal()

    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"])

        # Caption input
        caption_layout = QHBoxLayout()
        caption_label = QLabel("Caption:")
        caption_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self.caption_input = QLineEdit()
        self.caption_input.setPlaceholderText("Enter caption...")
        self.caption_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        caption_layout.addWidget(caption_label)
        caption_layout.addWidget(self.caption_input)
        self.main_layout.addLayout(caption_layout)

        # Set initial value if layer is selected
        if self.selected_layer:
            self.caption_input.setText(self.selected_layer.caption)

        # Update layer caption on editing finished
        def update_caption():
            if self.selected_layer:
                self.beforeLayerEdit.emit()
                logger.info(
                    f"Updating caption for layer {self.selected_layer.layer_name} to {self.caption_input.text()}"
                )
                self.selected_layer.caption = self.caption_input.text()
                self.selected_layer.update()

        self.caption_input.editingFinished.connect(update_caption)

        # 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
            self.beforeLayerEdit.emit()
            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,
            caption=self.selected_layer.caption,
        )
        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)
                )
                self.caption_input.setText(self.selected_layer.caption)
            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
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
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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
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,
        caption=self.selected_layer.caption,
    )
    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
 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
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"])

    # Caption input
    caption_layout = QHBoxLayout()
    caption_label = QLabel("Caption:")
    caption_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
    self.caption_input = QLineEdit()
    self.caption_input.setPlaceholderText("Enter caption...")
    self.caption_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
    caption_layout.addWidget(caption_label)
    caption_layout.addWidget(self.caption_input)
    self.main_layout.addLayout(caption_layout)

    # Set initial value if layer is selected
    if self.selected_layer:
        self.caption_input.setText(self.selected_layer.caption)

    # Update layer caption on editing finished
    def update_caption():
        if self.selected_layer:
            self.beforeLayerEdit.emit()
            logger.info(
                f"Updating caption for layer {self.selected_layer.layer_name} to {self.caption_input.text()}"
            )
            self.selected_layer.caption = self.caption_input.text()
            self.selected_layer.update()

    self.caption_input.editingFinished.connect(update_caption)

    # 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
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
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
        self.beforeLayerEdit.emit()
        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
214
215
216
217
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
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
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)
            )
            self.caption_input.setText(self.selected_layer.caption)
        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
 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
class BaseLayer(QWidget):
    annotation_clipboard: list[Annotation] = []
    messageSignal = Signal(str)
    modeChanged = Signal(object)
    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: Annotation | None = None
        self.copied_annotation: Annotation | None = None
        self.selected_annotation: Annotation | None = None

        self.layers: list[BaseLayer] = []
        self.plugins = []
        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._mouse_widget_pos: QPointF | None = None
        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]] = {}

        self.states: dict[int, list[LayerState]] = {}
        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}")
                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.ZOOM_IN == self.mouse_mode:
            self.setCursor(self._create_zoom_cursor(zoom_in=True))
        elif MouseMode.ZOOM_OUT == self.mouse_mode:
            self.setCursor(self._create_zoom_cursor(zoom_in=False))
        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 _create_zoom_cursor(self, zoom_in: bool) -> QCursor:
        """Create a magnifier-like cursor with plus/minus marker."""
        size = 24
        pixmap = QPixmap(size, size)
        pixmap.fill(Qt.transparent)

        painter = QPainter(pixmap)
        painter.setRenderHints(QPainter.Antialiasing)
        pen = QPen(Qt.black, 2)
        painter.setPen(pen)

        # Lens
        painter.drawEllipse(2, 2, 14, 14)
        # Handle
        painter.drawLine(13, 13, 21, 21)
        # Center marker
        painter.drawLine(6, 9, 12, 9)
        if zoom_in:
            painter.drawLine(9, 6, 9, 12)

        painter.end()
        return QCursor(pixmap, 2, 2)

    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]
        # Copy child layers from the source layer (not from the new layer itself).
        layer.layers = [child.copy() for child in self.layers]
        layer.plugins = [
            plugin.copy() if hasattr(plugin, "copy") else plugin
            for plugin in self.plugins
        ]
        layer.layer_name = self.layer_name
        layer.file_path = Path(self.file_path)
        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
        self.modeChanged.emit(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.setFocus()
        self._mouse_widget_pos = (
            event.position() if hasattr(event, "position") else QPointF(event.pos())
        )
        self.handle_mouse_press(event)

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

    def mouseMoveEvent(self, event: QMouseEvent):
        self._mouse_widget_pos = (
            event.position() if hasattr(event, "position") else QPointF(event.pos())
        )
        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 leaveEvent(self, event):
        self._mouse_widget_pos = None
        self.update()
        super().leaveEvent(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()
        if event.isAccepted():
            return
        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 _grid_view_transform(self) -> tuple[QPointF, float]:
        """Return widget-space anchor offset and zoom used by the grid."""
        anchor = self.offset if isinstance(self.offset, QPointF) else QPointF(0, 0)
        zoom = float(self.scale) if self.scale else 1.0
        return anchor, max(0.01, zoom)

    def _draw_grid_overlay(self, painter: QPainter):
        """Draw a lightweight screen-space grid overlay with hover highlight."""
        if not getattr(self.config, "show_gridlines", False):
            return

        logical_spacing = max(16, int(getattr(self.config, "grid_spacing", 48)))
        width = self.width()
        height = self.height()
        if width <= 0 or height <= 0:
            return

        grid_color = getattr(self.config, "grid_color", QColor(120, 120, 120, 80))
        anchor, zoom = self._grid_view_transform()
        base_step = logical_spacing * zoom
        if base_step <= 0:
            return

        # Keep draw cost bounded when zoomed far out by skipping minor lines.
        min_screen_step = 8.0
        stride = max(1, int(math.ceil(min_screen_step / base_step)))
        step = base_step * stride
        anchor_x = float(anchor.x())
        anchor_y = float(anchor.y())

        painter.save()
        painter.resetTransform()
        painter.setRenderHint(QPainter.Antialiasing, False)

        pen = QPen(grid_color, 1)
        pen.setCosmetic(True)
        painter.setPen(pen)
        start_x = anchor_x % step
        start_y = anchor_y % step

        max_vlines = int(width / step) + 3
        max_hlines = int(height / step) + 3

        for i in range(max_vlines):
            x = start_x + (i * step)
            if x > width:
                break
            x_px = int(round(x))
            painter.drawLine(x_px, 0, x_px, height)

        for i in range(max_hlines):
            y = start_y + (i * step)
            if y > height:
                break
            y_px = int(round(y))
            painter.drawLine(0, y_px, width, y_px)

        hover_pos = self._mouse_widget_pos
        if hover_pos is not None:
            hover_x = int(hover_pos.x())
            hover_y = int(hover_pos.y())
            if 0 <= hover_x < width and 0 <= hover_y < height:
                highlight_color = QColor(grid_color).lighter(190)
                highlight_color.setAlpha(210)

                snap_x = int(
                    round((hover_x - anchor_x) / step) * step + anchor_x
                )
                snap_y = int(
                    round((hover_y - anchor_y) / step) * step + anchor_y
                )
                snap_x = max(0, min(width, snap_x))
                snap_y = max(0, min(height, snap_y))

                snap_pen = QPen(highlight_color, 2)
                snap_pen.setCosmetic(True)
                painter.setPen(snap_pen)
                painter.drawLine(snap_x, 0, snap_x, height)
                painter.drawLine(0, snap_y, width, snap_y)

                dot_size = 4
                painter.setBrush(highlight_color)
                painter.setPen(Qt.NoPen)
                painter.drawEllipse(QPointF(snap_x, snap_y), dot_size, dot_size)

        painter.restore()

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

    def paint_event(self):
        painter = QPainter(self)
        try:
            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)
            # Keep the grid in front of layer content.
            self._draw_grid_overlay(painter)
        finally:
            painter.end()

    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

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

    @caption.setter
    def caption(self, value: str):
        self.layer_state.caption = 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
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
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]
    # Copy child layers from the source layer (not from the new layer itself).
    layer.layers = [child.copy() for child in self.layers]
    layer.plugins = [
        plugin.copy() if hasattr(plugin, "copy") else plugin
        for plugin in self.plugins
    ]
    layer.layer_name = self.layer_name
    layer.file_path = Path(self.file_path)
    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
208
209
210
211
212
213
214
215
216
217
218
219
220
221
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
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
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
665
666
667
668
669
670
671
672
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
655
656
657
658
659
660
661
662
663
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
674
675
676
677
678
679
680
681
682
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
684
685
686
687
688
689
690
691
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
289
290
291
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
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
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}")
            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
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
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
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
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
    self.modeChanged.emit(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
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
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.ZOOM_IN == self.mouse_mode:
        self.setCursor(self._create_zoom_cursor(zoom_in=True))
    elif MouseMode.ZOOM_OUT == self.mouse_mode:
        self.setCursor(self._create_zoom_cursor(zoom_in=False))
    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
293
294
295
296
297
298
299
300
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
  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
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
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 = {}

        self._last_draw_point = None  # Track the last point for smooth drawing
        self._undo_stack = []
        self._redo_stack = []
        self._max_history = 100
        self._interaction_undo_pushed = False
        self._plugin_render_cache = {}
        self._current_step_index = 0

    def _snapshot_layers(self):
        snapshot_layers = []
        selected_indices = []
        for idx, layer in enumerate(self.layers):
            layer_copy = layer.copy()
            layer_copy.selected = layer.selected
            if layer.selected:
                selected_indices.append(idx)
            snapshot_layers.append(layer_copy)
        return {
            "layers": snapshot_layers,
            "selected_indices": selected_indices,
        }

    def _restore_layers(self, snapshot):
        restored_layers = [layer.copy() for layer in snapshot.get("layers", [])]
        selected_indices = set(snapshot.get("selected_indices", []))
        for idx, layer in enumerate(restored_layers):
            layer.selected = idx in selected_indices
        self.layers = restored_layers
        self._plugin_render_cache.clear()
        self.selected_layer = self._get_selected_layer()
        self._update_back_buffer()
        self.layersChanged.emit()
        if self.selected_layer is not None:
            self.layerSelected.emit(self.selected_layer)
        self.update()

    def push_undo_state(self):
        """Capture current canvas layers for undo."""
        self._undo_stack.append(self._snapshot_layers())
        if len(self._undo_stack) > self._max_history:
            self._undo_stack.pop(0)
        self._redo_stack.clear()

    def undo(self):
        """Undo the last canvas edit operation."""
        if not self._undo_stack:
            self.messageSignal.emit("Nothing to undo.")
            return False
        self._redo_stack.append(self._snapshot_layers())
        snapshot = self._undo_stack.pop()
        self._restore_layers(snapshot)
        self.messageSignal.emit("Undo applied.")
        return True

    def redo(self):
        """Redo the last undone canvas edit operation."""
        if not self._redo_stack:
            self.messageSignal.emit("Nothing to redo.")
            return False
        self._undo_stack.append(self._snapshot_layers())
        snapshot = self._redo_stack.pop()
        self._restore_layers(snapshot)
        self.messageSignal.emit("Redo applied.")
        return True

    def _apply_plugins_to_state(
        self,
        layer: BaseLayer,
        state,
        step: int,
        total_steps: int,
    ):
        plugins = getattr(layer, "plugins", [])
        if not plugins:
            return state

        for plugin in plugins:
            if not getattr(plugin, "enabled", True):
                continue
            try:
                updated_state = plugin.update(
                    state=state,
                    step=step,
                    total_steps=total_steps,
                    layer=layer,
                    canvas=self,
                )
                if updated_state is not None:
                    state = updated_state
            except Exception as error:
                logger.error(
                    f"Plugin '{getattr(plugin, 'name', type(plugin).__name__)}' failed: {error}"
                )
        return state

    def _build_states_for_step(self, step: int, total_steps: int):
        built_states = []
        for order, layer in enumerate(self.layers):
            state = layer.layer_state.copy()
            state.order = order
            state.selected = False
            state.caption = layer.caption
            state.drawing_states = [
                DrawingState(position=d.position, color=d.color, size=d.size)
                for d in layer.layer_state.drawing_states
            ]
            state = self._apply_plugins_to_state(layer, state, step, total_steps)
            built_states.append(state)
        return built_states

    def _plugin_cache_key(self, layer: BaseLayer, step: int, total_steps: int):
        def _plugin_desc(plugin):
            describe_fn = getattr(plugin, "describe", None)
            if callable(describe_fn):
                return describe_fn()
            return str(plugin)

        plugin_signature = tuple(
            (
                type(plugin).__name__,
                getattr(plugin, "enabled", True),
                _plugin_desc(plugin),
            )
            for plugin in getattr(layer, "plugins", [])
        )
        return (
            layer.layer_id,
            int(step),
            int(total_steps),
            int(layer.image.cacheKey()) if not layer.image.isNull() else 0,
            plugin_signature,
        )

    def _state_step_index(self, step_key: int):
        if not self.states:
            return 0
        ordered_steps = self._ordered_state_steps()
        try:
            return ordered_steps.index(step_key)
        except ValueError:
            return 0

    def _ordered_state_steps(self) -> list[int]:
        if not self.states:
            return []
        return sorted(self.states.keys())

    def _step_key_for_index(self, step_index: int) -> int | None:
        ordered_steps = self._ordered_state_steps()
        if not ordered_steps:
            return None
        idx = max(0, min(len(ordered_steps) - 1, int(step_index)))
        return ordered_steps[idx]

    def _get_layer_render_pixmap(self, layer: BaseLayer, step: int, total_steps: int):
        if not getattr(layer, "plugins", []):
            return layer.image

        key = self._plugin_cache_key(layer, step, total_steps)
        cached = self._plugin_render_cache.get(key)
        if cached is not None:
            return cached

        rendered = apply_pixel_plugins(
            layer=layer,
            step=step,
            total_steps=total_steps,
            canvas=self,
        )
        self._plugin_render_cache[key] = rendered
        if len(self._plugin_render_cache) > 256:
            self._plugin_render_cache.pop(next(iter(self._plugin_render_cache)))
        return rendered

    def _build_export_render_cache(
        self,
        states: dict[int, list],
        timeline_total_steps: int | None = None,
    ) -> dict[tuple[int, int], QImage]:
        """
        Pre-render plugin-adjusted layer images for export/predict in the UI thread.

        Returns:
            Mapping of (timeline_step, layer_id) -> QImage (RGBA8888).
        """
        render_cache: dict[tuple[int, int], QImage] = {}
        if not states or not self.layers:
            return render_cache

        state_snapshots = {
            layer.layer_id: layer.layer_state.copy() for layer in self.layers
        }
        image_snapshots = {
            layer.layer_id: layer.image.copy() for layer in self.layers
        }
        sorted_items = sorted(states.items())
        if timeline_total_steps is None:
            total_steps = max(1, len(sorted_items))
        else:
            total_steps = max(1, int(timeline_total_steps))

        try:
            for step_key, step_states in sorted_items:
                timeline_step = int(step_key)
                for state in step_states:
                    layer = self.get_layer(state.layer_id)
                    if layer is None or layer.image.isNull():
                        continue

                    # Keep export plugin renders aligned with per-state edge settings.
                    update_opacities = (
                        layer.edge_width != state.edge_width
                        or layer.edge_opacity != state.edge_opacity
                    )
                    layer.layer_state = state
                    if update_opacities:
                        layer._apply_edge_opacity()

                    render_pixmap = self._get_layer_render_pixmap(
                        layer=layer,
                        step=timeline_step,
                        total_steps=total_steps,
                    )
                    if render_pixmap.isNull():
                        continue

                    image = render_pixmap.toImage()
                    if image.format() != QImage.Format_RGBA8888:
                        image = image.convertToFormat(QImage.Format_RGBA8888)
                    render_cache[(timeline_step, state.layer_id)] = image.copy()
        finally:
            for layer in self.layers:
                snapshot_state = state_snapshots.get(layer.layer_id)
                snapshot_image = image_snapshots.get(layer.layer_id)
                if snapshot_state is not None:
                    layer.layer_state = snapshot_state
                if snapshot_image is not None:
                    layer.image = snapshot_image

        return render_cache

    def save_current_state(self, steps: int = 1):
        """Save current state and apply plugins for each generated step."""
        curr_states = {}
        mode = self.mouse_mode
        total_steps = max(1, int(steps))
        start_step = (max(self.states.keys()) + 1) if self.states else 0

        for layer in self.layers:
            intermediate_states = calculate_intermediate_states(
                layer.previous_state, layer.layer_state.copy(), total_steps
            )
            is_selected = layer.selected

            for local_step, state in enumerate(intermediate_states):
                state_step = start_step + local_step
                state.selected = False
                state.drawing_states = [
                    DrawingState(position=d.position, color=d.color, size=d.size)
                    for d in layer.layer_state.drawing_states
                ]
                state = self._apply_plugins_to_state(
                    layer, state, local_step, total_steps
                )
                if state_step not in curr_states:
                    curr_states[state_step] = []
                curr_states[state_step].append(state)

            layer.previous_state = layer.layer_state.copy()
            layer.selected = is_selected

        for state_step, states in sorted(curr_states.items()):
            self.states[state_step] = states
            self.current_step = state_step

        self._current_step_index = self._state_step_index(self.current_step)
        self._plugin_render_cache.clear()
        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
        ]
        self.messageSignal.emit(
            "State saved."
            + f" Total states: {len(self.states)}"
            + f" | Steps: {total_steps}"
            + f" | Current step: {self.current_step}"
        )
        self.mouse_mode = mode
        self.update()

    def add_plugins_to_selected_layers(self, plugins: list[BasePlugin]) -> tuple[int, int]:
        """Attach plugin instances to each selected layer."""
        selected_layers = self._get_selected_layers()
        if not selected_layers:
            self.messageSignal.emit("Select at least one layer to add a plugin.")
            return 0, 0
        if not plugins:
            self.messageSignal.emit("No plugins selected.")
            return 0, 0

        self.push_undo_state()
        added = 0
        skipped = 0
        for layer in selected_layers:
            layer_plugins = getattr(layer, "plugins", [])
            for plugin in plugins:
                if any(type(existing) is type(plugin) for existing in layer_plugins):
                    skipped += 1
                    continue
                layer_plugins.append(plugin.copy())
                added += 1
            layer.plugins = layer_plugins

        self._plugin_render_cache.clear()
        self.layersChanged.emit()
        self.update()
        plugin_names = ", ".join(plugin.name for plugin in plugins)
        self.messageSignal.emit(
            f"Added plugin(s) [{plugin_names}] {added} time(s)."
            + (f" Skipped {skipped} duplicate attachment(s)." if skipped else "")
        )
        return added, skipped

    def add_plugin_to_selected_layers(self, plugin: BasePlugin) -> tuple[int, int]:
        """Backward-compatible wrapper for single-plugin add."""
        return self.add_plugins_to_selected_layers([plugin])

    def set_plugins_for_selected_layers(
        self, plugin_classes: list[type[BasePlugin]]
    ) -> tuple[int, int]:
        """Set selected layers' plugins to exactly the provided plugin classes."""
        selected_layers = self._get_selected_layers()
        if not selected_layers:
            self.messageSignal.emit("Select at least one layer to configure plugins.")
            return 0, 0

        desired = tuple(plugin_classes)
        updates = []
        failed = 0

        for layer in selected_layers:
            current_plugins = getattr(layer, "plugins", [])
            current_map = {type(plugin): plugin for plugin in current_plugins}
            current_types = tuple(type(plugin) for plugin in current_plugins)
            if current_types == desired:
                continue

            new_plugins = []
            for plugin_class in desired:
                existing = current_map.get(plugin_class)
                if existing is not None:
                    existing.enabled = True
                    new_plugins.append(existing)
                    continue
                try:
                    new_plugins.append(plugin_class())
                except Exception as error:
                    logger.error(
                        f"Failed to instantiate plugin '{plugin_class.__name__}': {error}"
                    )
                    failed += 1
            updates.append((layer, new_plugins))

        if not updates and failed == 0:
            return 0, 0

        self.push_undo_state()
        for layer, new_plugins in updates:
            layer.plugins = new_plugins

        changed_layers = len(updates)
        self._plugin_render_cache.clear()
        self.layersChanged.emit()
        self.update()
        self.messageSignal.emit(
            f"Updated plugins on {changed_layers} selected layer(s)."
            + (f" Failed {failed} plugin instantiation(s)." if failed else "")
        )
        return changed_layers, failed

    def clear_plugins_from_selected_layers(self) -> int:
        """Remove all plugins from selected layers."""
        selected_layers = self._get_selected_layers()
        if not selected_layers:
            self.messageSignal.emit("Select at least one layer to remove plugins.")
            return 0

        removed = 0
        for layer in selected_layers:
            removed += len(getattr(layer, "plugins", []))

        if removed == 0:
            self.messageSignal.emit("Selected layers do not have plugins.")
            return 0

        self.push_undo_state()
        for layer in selected_layers:
            layer.plugins = []

        self._plugin_render_cache.clear()
        self.layersChanged.emit()
        self.update()
        self.messageSignal.emit(f"Removed {removed} plugin instance(s).")
        return removed

    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()
            event.accept()
            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

            event.accept()
            return  # Important: return after handling

        # Handle Ctrl+C
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
            self._copy_layer()
            event.accept()
            return  # Important: return after handling

        # Handle Ctrl+V
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
            self._paste_layer()
            event.accept()
            return  # Important: return after handling

        # Handle Ctrl+Z / Ctrl+Y
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
            self.undo()
            event.accept()
            return
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
            self.redo()
            event.accept()
            return

        # Handle Ctrl+G
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_G:
            self.group_selected_layers()
            event.accept()
            return

        if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_S:
            self.save_current_state(steps=1)
            event.accept()
            return

        if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_D:
            self.mouse_mode = (
                MouseMode.DRAW if self.mouse_mode != MouseMode.DRAW else MouseMode.IDLE
            )
            self.messageSignal.emit(f"Drawing mode {self.mouse_mode.name.lower()}.")
            event.accept()
            return

        if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_E:
            self.mouse_mode = (
                MouseMode.ERASE if self.mouse_mode != MouseMode.ERASE else MouseMode.IDLE
            )
            self.messageSignal.emit(f"Erasing mode {self.mouse_mode.name.lower()}.")
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_H:
            selected_layer = self._get_selected_layer()
            if selected_layer:
                self.push_undo_state()
                selected_layer.visible = not selected_layer.visible
                selected_layer.update()
                self.layersChanged.emit()
                self.messageSignal.emit(
                    f"Toggled visibility of layer: {selected_layer.layer_name}"
                )
                self.update()
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_W:
            self._move_selected_layer_up()
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_S:
            self.save_current_state(steps=1)
            event.accept()
            return

    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)
        total_steps = max(1, len(self.states)) if self.states else 1
        render_step = self._current_step_index if self.states else 0
        for layer in self.layers:
            if layer.visible and not layer.image.isNull():
                render_pixmap = self._get_layer_render_pixmap(
                    layer=layer, step=render_step, total_steps=total_steps
                )
                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(render_pixmap.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, render_pixmap)

                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()

    def _grid_view_transform(self) -> tuple[QPointF, float]:
        """Use canvas pan + zoom so grid scales and moves with the page view."""
        anchor = (
            self.pan_offset if isinstance(self.pan_offset, QPointF) else QPointF(0, 0)
        )
        zoom = float(self.scale) if self.scale else 1.0
        return anchor, max(0.01, zoom)

    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 not layer:
            logger.debug("No layer selected for drawing.")
            # show popup message window that closes in 2 seconds
            QMessageBox.information(
                self.parent(),
                "No Layer Selected",
                "Please select a layer to draw on.",
            )
            return

        # 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} to layer {layer.layer_name}"
            )
        else:
            logger.debug(
                f"Mouse mode {self.mouse_mode} does not support drawing states."
            )
            return
        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
            self._interaction_undo_pushed = False

            # 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 not self._interaction_undo_pushed:
                self.push_undo_state()
                self._interaction_undo_pushed = True
            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:
            if not self._interaction_undo_pushed:
                self.push_undo_state()
                self._interaction_undo_pushed = True
            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
            self._interaction_undo_pushed = False
            if self.mouse_mode in [MouseMode.DRAW, MouseMode.ERASE]:
                if self._get_selected_layer() is not None:
                    self.push_undo_state()
                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:
                clicked_layer = None
                for layer in reversed(self.layers):
                    if not layer.visible:
                        continue
                    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):
                        clicked_layer = layer
                        break

                if clicked_layer is not None:
                    clicked_layer.selected = not clicked_layer.selected
                    self.selected_layer = (
                        clicked_layer if clicked_layer.selected else self._get_selected_layer()
                    )
                    self.layersChanged.emit()
                    self.update()
                else:
                    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 _get_selected_layers(self) -> list[BaseLayer]:
        return [layer for layer in self.layers if layer.selected]

    @staticmethod
    def _compose_layer_transform(layer: BaseLayer) -> QTransform:
        transform = QTransform()
        transform.translate(layer.position.x(), layer.position.y())
        transform.rotate(layer.rotation)
        transform.scale(layer.scale_x, layer.scale_y)
        return transform

    @staticmethod
    def _decompose_transform(transform: QTransform) -> tuple[QPointF, float, float, float]:
        m11 = transform.m11()
        m12 = transform.m12()
        m21 = transform.m21()
        m22 = transform.m22()

        scale_x = math.hypot(m11, m12)
        scale_y = math.hypot(m21, m22)

        # Avoid zero scales while keeping transform stable.
        scale_x = max(0.001, scale_x)
        scale_y = max(0.001, scale_y)
        rotation = math.degrees(math.atan2(m12, m11))
        position = QPointF(transform.dx(), transform.dy())
        return position, rotation, scale_x, scale_y

    @staticmethod
    def _is_group_layer(layer: BaseLayer) -> bool:
        return len(layer.layers) >= 2

    @staticmethod
    def _map_annotation_to_group(
        annotation: Annotation, layer_transform: QTransform
    ) -> Annotation:
        mapped = annotation.copy()
        if mapped.rectangle:
            mapped.rectangle = layer_transform.mapRect(mapped.rectangle)
        if mapped.polygon:
            mapped.polygon = layer_transform.map(mapped.polygon)
        if mapped.points:
            mapped.points = [layer_transform.map(point) for point in mapped.points]
        return mapped

    def group_selected_layers(self) -> bool:
        """
        Group exactly two selected layers, or ungroup one selected grouped layer.
        Returns:
            bool: True when grouping was applied, otherwise False.
        """
        selected_layers = self._get_selected_layers()
        if len(selected_layers) == 1 and self._is_group_layer(selected_layers[0]):
            return self._ungroup_layer(selected_layers[0])

        if len(selected_layers) != 2:
            self.messageSignal.emit("Select exactly 2 layers to group.")
            return False

        indices = sorted(self.layers.index(layer) for layer in selected_layers)
        ordered_layers = [self.layers[idx] for idx in indices]

        union_rect = None
        transformed_rects: list[QRectF] = []
        for layer in ordered_layers:
            if layer.image.isNull():
                self.messageSignal.emit("Cannot group layers with empty images.")
                return False

            layer_transform = self._compose_layer_transform(layer)
            transformed_rect = layer_transform.mapRect(
                QRectF(QPointF(0, 0), layer.original_size)
            )
            transformed_rects.append(transformed_rect)
            union_rect = (
                transformed_rect
                if union_rect is None
                else union_rect.united(transformed_rect)
            )

        if union_rect is None or union_rect.width() <= 0 or union_rect.height() <= 0:
            self.messageSignal.emit("Unable to group selected layers.")
            return False

        self.push_undo_state()
        group_width = max(1, int(math.ceil(union_rect.width())))
        group_height = max(1, int(math.ceil(union_rect.height())))

        composed = QPixmap(group_width, group_height)
        composed.fill(Qt.transparent)

        composed_painter = QPainter(composed)
        composed_painter.setRenderHints(
            QPainter.Antialiasing | QPainter.SmoothPixmapTransform
        )
        try:
            for layer in ordered_layers:
                layer_local_transform = QTransform()
                layer_local_transform.translate(
                    layer.position.x() - union_rect.left(),
                    layer.position.y() - union_rect.top(),
                )
                layer_local_transform.rotate(layer.rotation)
                layer_local_transform.scale(layer.scale_x, layer.scale_y)

                composed_painter.save()
                composed_painter.setTransform(layer_local_transform, combine=False)
                composed_painter.setOpacity(max(0.0, min(1.0, layer.opacity / 255.0)))
                composed_painter.drawPixmap(0, 0, layer.image)

                for state in layer.layer_state.drawing_states:
                    composed_painter.setPen(
                        QPen(
                            state.color,
                            state.size,
                            Qt.SolidLine,
                            Qt.RoundCap,
                            Qt.RoundJoin,
                        )
                    )
                    composed_painter.drawPoint(state.position)
                composed_painter.restore()
        finally:
            composed_painter.end()

        grouped_layer = ordered_layers[-1].copy()
        grouped_layer.set_image(composed)
        grouped_layer.position = QPointF(union_rect.left(), union_rect.top())
        grouped_layer.rotation = 0.0
        grouped_layer.scale_x = 1.0
        grouped_layer.scale_y = 1.0
        grouped_layer.opacity = 255
        grouped_layer.visible = True
        grouped_layer.selected = True
        grouped_layer.allow_annotation_export = any(
            layer.allow_annotation_export for layer in ordered_layers
        )
        grouped_layer.layer_name = (
            f"Group({ordered_layers[0].layer_name}, {ordered_layers[1].layer_name})"
        )
        grouped_layer.caption = (
            f"Grouped: {ordered_layers[0].layer_name}, {ordered_layers[1].layer_name}"
        )
        grouped_layer.layers = []
        grouped_layer.plugins = []

        merged_annotations: list[Annotation] = []
        for layer in ordered_layers:
            child_copy = layer.copy()
            child_copy.selected = False
            child_copy.position = child_copy.position - QPointF(
                union_rect.left(), union_rect.top()
            )
            grouped_layer.layers.append(child_copy)
            grouped_layer.plugins.extend(
                [
                    plugin.copy() if hasattr(plugin, "copy") else plugin
                    for plugin in getattr(layer, "plugins", [])
                ]
            )

            annotation_transform = QTransform()
            annotation_transform.translate(
                layer.position.x() - union_rect.left(),
                layer.position.y() - union_rect.top(),
            )
            annotation_transform.rotate(layer.rotation)
            annotation_transform.scale(layer.scale_x, layer.scale_y)
            for annotation in layer.annotations:
                mapped = self._map_annotation_to_group(annotation, annotation_transform)
                mapped.selected = False
                mapped.file_path = grouped_layer.file_path
                merged_annotations.append(mapped)

        if not merged_annotations:
            merged_annotations = [
                Annotation(annotation_id=0, label="Grouped", color=QColor(255, 255, 255))
            ]

        for idx, annotation in enumerate(merged_annotations):
            annotation.annotation_id = idx
        grouped_layer.annotations = merged_annotations

        for layer in self.layers:
            layer.selected = False
        insert_index = indices[-1]
        for idx in reversed(indices):
            del self.layers[idx]
            if idx < insert_index:
                insert_index -= 1
        self.layers.insert(insert_index, grouped_layer)

        for order, layer in enumerate(self.layers):
            layer.order = order

        self.selected_layer = grouped_layer
        self._update_back_buffer()
        self.layersChanged.emit()
        self.layerSelected.emit(grouped_layer)
        self.update()
        self.messageSignal.emit(
            f"Grouped layers: {ordered_layers[0].layer_name} + {ordered_layers[1].layer_name}"
        )
        return True

    def _ungroup_layer(self, grouped_layer: BaseLayer) -> bool:
        """Ungroup a previously grouped layer back into child layers."""
        if not self._is_group_layer(grouped_layer):
            self.messageSignal.emit("Select a grouped layer to ungroup.")
            return False

        if grouped_layer not in self.layers:
            self.messageSignal.emit("Grouped layer not found in canvas.")
            return False
        self.push_undo_state()

        group_index = self.layers.index(grouped_layer)
        group_transform = self._compose_layer_transform(grouped_layer)
        restored_layers: list[BaseLayer] = []
        for child_relative in grouped_layer.layers:
            child = child_relative.copy()
            child_local_transform = self._compose_layer_transform(child)
            child_world_transform = group_transform * child_local_transform

            position, rotation, scale_x, scale_y = self._decompose_transform(
                child_world_transform
            )
            child.position = position
            child.rotation = rotation
            child.scale_x = scale_x
            child.scale_y = scale_y
            child.opacity = int(round((child.opacity * grouped_layer.opacity) / 255.0))
            child.visible = grouped_layer.visible and child.visible
            child.selected = False
            restored_layers.append(child)

        del self.layers[group_index]
        for offset, child in enumerate(restored_layers):
            self.layers.insert(group_index + offset, child)

        for layer in self.layers:
            layer.selected = False
        if restored_layers:
            restored_layers[0].selected = True
            self.selected_layer = restored_layers[0]
        else:
            self.selected_layer = None

        for order, layer in enumerate(self.layers):
            layer.order = order

        self._update_back_buffer()
        self.layersChanged.emit()
        if self.selected_layer is not None:
            self.layerSelected.emit(self.selected_layer)
        self.update()
        self.messageSignal.emit(f"Ungrouped layer: {grouped_layer.layer_name}")
        return True

    def _center_layer_in_view(self, layer: BaseLayer):
        """Center a layer in the current canvas viewport."""
        if layer.image.isNull():
            return

        view_center = QPointF(self.width() / 2.0, self.height() / 2.0)
        world_center = (
            (view_center - self.pan_offset) / self.scale
            if self.scale != 0
            else view_center
        )
        half_width = (layer.image.width() * layer.scale_x) / 2.0
        half_height = (layer.image.height() * layer.scale_y) / 2.0
        layer.position = QPointF(world_center.x() - half_width, world_center.y() - half_height)

    def add_layer(
        self, layer: BaseLayer, index=-1, center=False, on_top=False, track_undo=True
    ):
        """
        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.
            center (bool, optional): Whether to center the layer in current view.
            on_top (bool, optional): Whether to place the layer on top of the stack.

        Raises:
            ValueError: If the layer is not a BaseLayer instance
        """
        if track_undo:
            self.push_undo_state()

        if center:
            self._center_layer_in_view(layer)

        layer.layer_name = f"{len(self.layers) + 1}_" + layer.layer_name
        if on_top or 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.
        """
        if self.layers:
            self.push_undo_state()
        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, on_top=True)
            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()
        if self.selected_layer:
            self.push_undo_state()
            self.layers = [
                layer for layer in self.layers if layer is not self.selected_layer
            ]
            self.messageSignal.emit(f"Deleted {self.selected_layer.layer_name} layer.")
            self.selected_layer = None
            self._update_back_buffer()
            self.layersChanged.emit()
            self.update()

    def _move_selected_layer_up(self):
        selected_layer = self._get_selected_layer()
        if not selected_layer:
            return
        self.push_undo_state()

        index = self.layers.index(selected_layer)
        if index > 0:
            self.layers.insert(index - 1, selected_layer)
            del self.layers[index + 1]
        else:
            self.layers.append(selected_layer)
            del self.layers[0]

        self.layersChanged.emit()
        self.update()
        self.messageSignal.emit(f"Moved layer {selected_layer.layer_name} up in order.")

    def _move_selected_layer_down(self):
        selected_layer = self._get_selected_layer()
        if not selected_layer:
            return
        self.push_undo_state()

        index = self.layers.index(selected_layer)
        if index < len(self.layers) - 1:
            self.layers.insert(index + 2, selected_layer)
            del self.layers[index]
        else:
            self.layers.insert(0, selected_layer)
            del self.layers[-1]

        self.layersChanged.emit()
        self.update()
        self.messageSignal.emit(
            f"Moved layer {selected_layer.layer_name} down in order."
        )

    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 current baked image to {filename}")

        if self.states:
            step_key = (
                int(self.current_step)
                if int(self.current_step) in self.states
                else self._step_key_for_index(self._current_step_index)
            )
            if step_key is None:
                step_key = min(self.states.keys())
            export_states = {int(step_key): self.states[int(step_key)]}
            timeline_total_steps = max(1, len(self.states))
            cache_source_states = {
                int(k): self.states[int(k)]
                for k in sorted(self.states.keys())
                if int(k) <= int(step_key)
            }
            if not cache_source_states:
                cache_source_states = export_states
            self.messageSignal.emit(f"Exporting current timeline step {step_key}.")
        else:
            export_states = {0: self._build_states_for_step(step=0, total_steps=1)}
            timeline_total_steps = 1
            cache_source_states = export_states
            self.messageSignal.emit("No saved states found. Exporting current live state.")

        logger.debug(f"Exporting current state payload: {list(export_states.keys())}")

        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()
        export_render_cache = self._build_export_render_cache(
            cache_source_states,
            timeline_total_steps=timeline_total_steps,
        )
        self.worker = BakerWorker(
            layers=self.layers,
            states=export_states,
            filename=filename,
            render_cache=export_render_cache,
            timeline_total_steps=timeline_total_steps,
        )
        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 key or timeline index."""
        if not self.states:
            return

        requested = int(step)
        step_key = requested if requested in self.states else self._step_key_for_index(requested)
        if step_key is None:
            return

        self.messageSignal.emit(f"Seeking to step {step_key}")
        logger.info(f"Seeking to step {step_key}")
        self.current_step = step_key
        self._current_step_index = self._state_step_index(step_key)

        # Get the states for the selected step
        if step_key in self.states:
            states = self.states[step_key]
            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}")
            self.current_step = step
            self._current_step_index = self._state_step_index(step)

            # Update the slider position
            self.parentWidget().timeline_slider.setValue(self._current_step_index)
            # 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 randomize_states(self, num_states: int):
        """Create randomized states for all layers."""
        if not self.layers:
            self.messageSignal.emit("No layers available to randomize.")
            return

        num_states = max(1, int(num_states))
        self.states.clear()

        canvas_width = max(1, self.width())
        canvas_height = max(1, self.height())

        for step in range(num_states):
            randomized_states = []
            for order, layer in enumerate(self.layers):
                state = layer.layer_state.copy()

                state.scale_x = random.uniform(0.5, 1.5)
                state.scale_y = random.uniform(0.5, 1.5)
                max_x = max(0.0, canvas_width - (layer.image.width() * state.scale_x))
                max_y = max(0.0, canvas_height - (layer.image.height() * state.scale_y))
                state.position = QPointF(
                    random.uniform(0.0, max_x),
                    random.uniform(0.0, max_y),
                )
                state.rotation = random.uniform(0.0, 360.0)
                state.opacity = random.randint(128, 255)
                state.order = order
                state.selected = False
                state.caption = layer.caption
                state.drawing_states = [
                    DrawingState(position=d.position, color=d.color, size=d.size)
                    for d in layer.layer_state.drawing_states
                ]
                state = self._apply_plugins_to_state(layer, state, step, num_states)

                randomized_states.append(state)

            self.states[step] = randomized_states

        self.current_step = num_states - 1
        self.seek_state(0)
        self.update()
        self.messageSignal.emit(f"Randomized {num_states} state(s).")

    def export_baked_states(self, export_to_annotation_tab=False):
        """Export all the states stored in self.states."""
        export_states = self.states
        if len(self.states) == 0:
            msg = "No states to export. Creating a single image."
            logger.warning(msg)
            self.messageSignal.emit(msg)
            export_states = {0: self._build_states_for_step(step=0, total_steps=1)}

        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()
        timeline_total_steps = max(1, len(self.states)) if self.states else 1
        export_render_cache = self._build_export_render_cache(
            export_states,
            timeline_total_steps=timeline_total_steps,
        )
        self.worker = BakerWorker(
            states=export_states,
            layers=self.layers,
            filename=filename,
            render_cache=export_render_cache,
            timeline_total_steps=timeline_total_steps,
        )
        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_RGBA2BGRA)
                        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, center=False, on_top=False, track_undo=True)

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
center bool

Whether to center the layer in current view.

False
on_top bool

Whether to place the layer on top of the stack.

False

Raises:

Type Description
ValueError

If the layer is not a BaseLayer instance

Source code in imagebaker/layers/canvas_layer.py
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
def add_layer(
    self, layer: BaseLayer, index=-1, center=False, on_top=False, track_undo=True
):
    """
    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.
        center (bool, optional): Whether to center the layer in current view.
        on_top (bool, optional): Whether to place the layer on top of the stack.

    Raises:
        ValueError: If the layer is not a BaseLayer instance
    """
    if track_undo:
        self.push_undo_state()

    if center:
        self._center_layer_in_view(layer)

    layer.layer_name = f"{len(self.layers) + 1}_" + layer.layer_name
    if on_top or 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}")

add_plugin_to_selected_layers(plugin)

Backward-compatible wrapper for single-plugin add.

Source code in imagebaker/layers/canvas_layer.py
391
392
393
def add_plugin_to_selected_layers(self, plugin: BasePlugin) -> tuple[int, int]:
    """Backward-compatible wrapper for single-plugin add."""
    return self.add_plugins_to_selected_layers([plugin])

add_plugins_to_selected_layers(plugins)

Attach plugin instances to each selected layer.

Source code in imagebaker/layers/canvas_layer.py
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
def add_plugins_to_selected_layers(self, plugins: list[BasePlugin]) -> tuple[int, int]:
    """Attach plugin instances to each selected layer."""
    selected_layers = self._get_selected_layers()
    if not selected_layers:
        self.messageSignal.emit("Select at least one layer to add a plugin.")
        return 0, 0
    if not plugins:
        self.messageSignal.emit("No plugins selected.")
        return 0, 0

    self.push_undo_state()
    added = 0
    skipped = 0
    for layer in selected_layers:
        layer_plugins = getattr(layer, "plugins", [])
        for plugin in plugins:
            if any(type(existing) is type(plugin) for existing in layer_plugins):
                skipped += 1
                continue
            layer_plugins.append(plugin.copy())
            added += 1
        layer.plugins = layer_plugins

    self._plugin_render_cache.clear()
    self.layersChanged.emit()
    self.update()
    plugin_names = ", ".join(plugin.name for plugin in plugins)
    self.messageSignal.emit(
        f"Added plugin(s) [{plugin_names}] {added} time(s)."
        + (f" Skipped {skipped} duplicate attachment(s)." if skipped else "")
    )
    return added, skipped

clear_layers()

Clear all layers from the canvas layer.

Source code in imagebaker/layers/canvas_layer.py
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
def clear_layers(self):
    """
    Clear all layers from the canvas layer.
    """
    if self.layers:
        self.push_undo_state()
    self.layers.clear()
    self._update_back_buffer()
    self.update()
    self.messageSignal.emit("Cleared all layers")

clear_plugins_from_selected_layers()

Remove all plugins from selected layers.

Source code in imagebaker/layers/canvas_layer.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
def clear_plugins_from_selected_layers(self) -> int:
    """Remove all plugins from selected layers."""
    selected_layers = self._get_selected_layers()
    if not selected_layers:
        self.messageSignal.emit("Select at least one layer to remove plugins.")
        return 0

    removed = 0
    for layer in selected_layers:
        removed += len(getattr(layer, "plugins", []))

    if removed == 0:
        self.messageSignal.emit("Selected layers do not have plugins.")
        return 0

    self.push_undo_state()
    for layer in selected_layers:
        layer.plugins = []

    self._plugin_render_cache.clear()
    self.layersChanged.emit()
    self.update()
    self.messageSignal.emit(f"Removed {removed} plugin instance(s).")
    return removed

export_baked_states(export_to_annotation_tab=False)

Export all the states stored in self.states.

Source code in imagebaker/layers/canvas_layer.py
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
def export_baked_states(self, export_to_annotation_tab=False):
    """Export all the states stored in self.states."""
    export_states = self.states
    if len(self.states) == 0:
        msg = "No states to export. Creating a single image."
        logger.warning(msg)
        self.messageSignal.emit(msg)
        export_states = {0: self._build_states_for_step(step=0, total_steps=1)}

    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()
    timeline_total_steps = max(1, len(self.states)) if self.states else 1
    export_render_cache = self._build_export_render_cache(
        export_states,
        timeline_total_steps=timeline_total_steps,
    )
    self.worker = BakerWorker(
        states=export_states,
        layers=self.layers,
        filename=filename,
        render_cache=export_render_cache,
        timeline_total_steps=timeline_total_steps,
    )
    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
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
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 current baked image to {filename}")

    if self.states:
        step_key = (
            int(self.current_step)
            if int(self.current_step) in self.states
            else self._step_key_for_index(self._current_step_index)
        )
        if step_key is None:
            step_key = min(self.states.keys())
        export_states = {int(step_key): self.states[int(step_key)]}
        timeline_total_steps = max(1, len(self.states))
        cache_source_states = {
            int(k): self.states[int(k)]
            for k in sorted(self.states.keys())
            if int(k) <= int(step_key)
        }
        if not cache_source_states:
            cache_source_states = export_states
        self.messageSignal.emit(f"Exporting current timeline step {step_key}.")
    else:
        export_states = {0: self._build_states_for_step(step=0, total_steps=1)}
        timeline_total_steps = 1
        cache_source_states = export_states
        self.messageSignal.emit("No saved states found. Exporting current live state.")

    logger.debug(f"Exporting current state payload: {list(export_states.keys())}")

    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()
    export_render_cache = self._build_export_render_cache(
        cache_source_states,
        timeline_total_steps=timeline_total_steps,
    )
    self.worker = BakerWorker(
        layers=self.layers,
        states=export_states,
        filename=filename,
        render_cache=export_render_cache,
        timeline_total_steps=timeline_total_steps,
    )
    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()

group_selected_layers()

Group exactly two selected layers, or ungroup one selected grouped layer. Returns: bool: True when grouping was applied, otherwise False.

Source code in imagebaker/layers/canvas_layer.py
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
def group_selected_layers(self) -> bool:
    """
    Group exactly two selected layers, or ungroup one selected grouped layer.
    Returns:
        bool: True when grouping was applied, otherwise False.
    """
    selected_layers = self._get_selected_layers()
    if len(selected_layers) == 1 and self._is_group_layer(selected_layers[0]):
        return self._ungroup_layer(selected_layers[0])

    if len(selected_layers) != 2:
        self.messageSignal.emit("Select exactly 2 layers to group.")
        return False

    indices = sorted(self.layers.index(layer) for layer in selected_layers)
    ordered_layers = [self.layers[idx] for idx in indices]

    union_rect = None
    transformed_rects: list[QRectF] = []
    for layer in ordered_layers:
        if layer.image.isNull():
            self.messageSignal.emit("Cannot group layers with empty images.")
            return False

        layer_transform = self._compose_layer_transform(layer)
        transformed_rect = layer_transform.mapRect(
            QRectF(QPointF(0, 0), layer.original_size)
        )
        transformed_rects.append(transformed_rect)
        union_rect = (
            transformed_rect
            if union_rect is None
            else union_rect.united(transformed_rect)
        )

    if union_rect is None or union_rect.width() <= 0 or union_rect.height() <= 0:
        self.messageSignal.emit("Unable to group selected layers.")
        return False

    self.push_undo_state()
    group_width = max(1, int(math.ceil(union_rect.width())))
    group_height = max(1, int(math.ceil(union_rect.height())))

    composed = QPixmap(group_width, group_height)
    composed.fill(Qt.transparent)

    composed_painter = QPainter(composed)
    composed_painter.setRenderHints(
        QPainter.Antialiasing | QPainter.SmoothPixmapTransform
    )
    try:
        for layer in ordered_layers:
            layer_local_transform = QTransform()
            layer_local_transform.translate(
                layer.position.x() - union_rect.left(),
                layer.position.y() - union_rect.top(),
            )
            layer_local_transform.rotate(layer.rotation)
            layer_local_transform.scale(layer.scale_x, layer.scale_y)

            composed_painter.save()
            composed_painter.setTransform(layer_local_transform, combine=False)
            composed_painter.setOpacity(max(0.0, min(1.0, layer.opacity / 255.0)))
            composed_painter.drawPixmap(0, 0, layer.image)

            for state in layer.layer_state.drawing_states:
                composed_painter.setPen(
                    QPen(
                        state.color,
                        state.size,
                        Qt.SolidLine,
                        Qt.RoundCap,
                        Qt.RoundJoin,
                    )
                )
                composed_painter.drawPoint(state.position)
            composed_painter.restore()
    finally:
        composed_painter.end()

    grouped_layer = ordered_layers[-1].copy()
    grouped_layer.set_image(composed)
    grouped_layer.position = QPointF(union_rect.left(), union_rect.top())
    grouped_layer.rotation = 0.0
    grouped_layer.scale_x = 1.0
    grouped_layer.scale_y = 1.0
    grouped_layer.opacity = 255
    grouped_layer.visible = True
    grouped_layer.selected = True
    grouped_layer.allow_annotation_export = any(
        layer.allow_annotation_export for layer in ordered_layers
    )
    grouped_layer.layer_name = (
        f"Group({ordered_layers[0].layer_name}, {ordered_layers[1].layer_name})"
    )
    grouped_layer.caption = (
        f"Grouped: {ordered_layers[0].layer_name}, {ordered_layers[1].layer_name}"
    )
    grouped_layer.layers = []
    grouped_layer.plugins = []

    merged_annotations: list[Annotation] = []
    for layer in ordered_layers:
        child_copy = layer.copy()
        child_copy.selected = False
        child_copy.position = child_copy.position - QPointF(
            union_rect.left(), union_rect.top()
        )
        grouped_layer.layers.append(child_copy)
        grouped_layer.plugins.extend(
            [
                plugin.copy() if hasattr(plugin, "copy") else plugin
                for plugin in getattr(layer, "plugins", [])
            ]
        )

        annotation_transform = QTransform()
        annotation_transform.translate(
            layer.position.x() - union_rect.left(),
            layer.position.y() - union_rect.top(),
        )
        annotation_transform.rotate(layer.rotation)
        annotation_transform.scale(layer.scale_x, layer.scale_y)
        for annotation in layer.annotations:
            mapped = self._map_annotation_to_group(annotation, annotation_transform)
            mapped.selected = False
            mapped.file_path = grouped_layer.file_path
            merged_annotations.append(mapped)

    if not merged_annotations:
        merged_annotations = [
            Annotation(annotation_id=0, label="Grouped", color=QColor(255, 255, 255))
        ]

    for idx, annotation in enumerate(merged_annotations):
        annotation.annotation_id = idx
    grouped_layer.annotations = merged_annotations

    for layer in self.layers:
        layer.selected = False
    insert_index = indices[-1]
    for idx in reversed(indices):
        del self.layers[idx]
        if idx < insert_index:
            insert_index -= 1
    self.layers.insert(insert_index, grouped_layer)

    for order, layer in enumerate(self.layers):
        layer.order = order

    self.selected_layer = grouped_layer
    self._update_back_buffer()
    self.layersChanged.emit()
    self.layerSelected.emit(grouped_layer)
    self.update()
    self.messageSignal.emit(
        f"Grouped layers: {ordered_layers[0].layer_name} + {ordered_layers[1].layer_name}"
    )
    return True

handle_baker_error(error_msg)

To handle any errors that occur during the baking process.

Source code in imagebaker/layers/canvas_layer.py
1742
1743
1744
1745
1746
1747
1748
1749
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
482
483
484
485
486
487
488
489
490
491
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
473
474
475
476
477
478
479
480
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
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
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)
    total_steps = max(1, len(self.states)) if self.states else 1
    render_step = self._current_step_index if self.states else 0
    for layer in self.layers:
        if layer.visible and not layer.image.isNull():
            render_pixmap = self._get_layer_render_pixmap(
                layer=layer, step=render_step, total_steps=total_steps
            )
            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(render_pixmap.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, render_pixmap)

            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()

play_states()

Play all the states stored in self.states.

Source code in imagebaker/layers/canvas_layer.py
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
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}")
        self.current_step = step
        self._current_step_index = self._state_step_index(step)

        # Update the slider position
        self.parentWidget().timeline_slider.setValue(self._current_step_index)
        # 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
1751
1752
1753
1754
1755
def predict_state(self):
    """
    To send the current state to the prediction tab.
    """
    self.export_current_state(export_to_annotation_tab=True)

push_undo_state()

Capture current canvas layers for undo.

Source code in imagebaker/layers/canvas_layer.py
103
104
105
106
107
108
def push_undo_state(self):
    """Capture current canvas layers for undo."""
    self._undo_stack.append(self._snapshot_layers())
    if len(self._undo_stack) > self._max_history:
        self._undo_stack.pop(0)
    self._redo_stack.clear()

randomize_states(num_states)

Create randomized states for all layers.

Source code in imagebaker/layers/canvas_layer.py
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
def randomize_states(self, num_states: int):
    """Create randomized states for all layers."""
    if not self.layers:
        self.messageSignal.emit("No layers available to randomize.")
        return

    num_states = max(1, int(num_states))
    self.states.clear()

    canvas_width = max(1, self.width())
    canvas_height = max(1, self.height())

    for step in range(num_states):
        randomized_states = []
        for order, layer in enumerate(self.layers):
            state = layer.layer_state.copy()

            state.scale_x = random.uniform(0.5, 1.5)
            state.scale_y = random.uniform(0.5, 1.5)
            max_x = max(0.0, canvas_width - (layer.image.width() * state.scale_x))
            max_y = max(0.0, canvas_height - (layer.image.height() * state.scale_y))
            state.position = QPointF(
                random.uniform(0.0, max_x),
                random.uniform(0.0, max_y),
            )
            state.rotation = random.uniform(0.0, 360.0)
            state.opacity = random.randint(128, 255)
            state.order = order
            state.selected = False
            state.caption = layer.caption
            state.drawing_states = [
                DrawingState(position=d.position, color=d.color, size=d.size)
                for d in layer.layer_state.drawing_states
            ]
            state = self._apply_plugins_to_state(layer, state, step, num_states)

            randomized_states.append(state)

        self.states[step] = randomized_states

    self.current_step = num_states - 1
    self.seek_state(0)
    self.update()
    self.messageSignal.emit(f"Randomized {num_states} state(s).")

redo()

Redo the last undone canvas edit operation.

Source code in imagebaker/layers/canvas_layer.py
121
122
123
124
125
126
127
128
129
130
def redo(self):
    """Redo the last undone canvas edit operation."""
    if not self._redo_stack:
        self.messageSignal.emit("Nothing to redo.")
        return False
    self._undo_stack.append(self._snapshot_layers())
    snapshot = self._redo_stack.pop()
    self._restore_layers(snapshot)
    self.messageSignal.emit("Redo applied.")
    return True

save_current_state(steps=1)

Save current state and apply plugins for each generated step.

Source code in imagebaker/layers/canvas_layer.py
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
def save_current_state(self, steps: int = 1):
    """Save current state and apply plugins for each generated step."""
    curr_states = {}
    mode = self.mouse_mode
    total_steps = max(1, int(steps))
    start_step = (max(self.states.keys()) + 1) if self.states else 0

    for layer in self.layers:
        intermediate_states = calculate_intermediate_states(
            layer.previous_state, layer.layer_state.copy(), total_steps
        )
        is_selected = layer.selected

        for local_step, state in enumerate(intermediate_states):
            state_step = start_step + local_step
            state.selected = False
            state.drawing_states = [
                DrawingState(position=d.position, color=d.color, size=d.size)
                for d in layer.layer_state.drawing_states
            ]
            state = self._apply_plugins_to_state(
                layer, state, local_step, total_steps
            )
            if state_step not in curr_states:
                curr_states[state_step] = []
            curr_states[state_step].append(state)

        layer.previous_state = layer.layer_state.copy()
        layer.selected = is_selected

    for state_step, states in sorted(curr_states.items()):
        self.states[state_step] = states
        self.current_step = state_step

    self._current_step_index = self._state_step_index(self.current_step)
    self._plugin_render_cache.clear()
    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
    ]
    self.messageSignal.emit(
        "State saved."
        + f" Total states: {len(self.states)}"
        + f" | Steps: {total_steps}"
        + f" | Current step: {self.current_step}"
    )
    self.mouse_mode = mode
    self.update()

seek_state(step)

Seek to a specific state key or timeline index.

Source code in imagebaker/layers/canvas_layer.py
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
def seek_state(self, step):
    """Seek to a specific state key or timeline index."""
    if not self.states:
        return

    requested = int(step)
    step_key = requested if requested in self.states else self._step_key_for_index(requested)
    if step_key is None:
        return

    self.messageSignal.emit(f"Seeking to step {step_key}")
    logger.info(f"Seeking to step {step_key}")
    self.current_step = step_key
    self._current_step_index = self._state_step_index(step_key)

    # Get the states for the selected step
    if step_key in self.states:
        states = self.states[step_key]
        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()

set_plugins_for_selected_layers(plugin_classes)

Set selected layers' plugins to exactly the provided plugin classes.

Source code in imagebaker/layers/canvas_layer.py
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
def set_plugins_for_selected_layers(
    self, plugin_classes: list[type[BasePlugin]]
) -> tuple[int, int]:
    """Set selected layers' plugins to exactly the provided plugin classes."""
    selected_layers = self._get_selected_layers()
    if not selected_layers:
        self.messageSignal.emit("Select at least one layer to configure plugins.")
        return 0, 0

    desired = tuple(plugin_classes)
    updates = []
    failed = 0

    for layer in selected_layers:
        current_plugins = getattr(layer, "plugins", [])
        current_map = {type(plugin): plugin for plugin in current_plugins}
        current_types = tuple(type(plugin) for plugin in current_plugins)
        if current_types == desired:
            continue

        new_plugins = []
        for plugin_class in desired:
            existing = current_map.get(plugin_class)
            if existing is not None:
                existing.enabled = True
                new_plugins.append(existing)
                continue
            try:
                new_plugins.append(plugin_class())
            except Exception as error:
                logger.error(
                    f"Failed to instantiate plugin '{plugin_class.__name__}': {error}"
                )
                failed += 1
        updates.append((layer, new_plugins))

    if not updates and failed == 0:
        return 0, 0

    self.push_undo_state()
    for layer, new_plugins in updates:
        layer.plugins = new_plugins

    changed_layers = len(updates)
    self._plugin_render_cache.clear()
    self.layersChanged.emit()
    self.update()
    self.messageSignal.emit(
        f"Updated plugins on {changed_layers} selected layer(s)."
        + (f" Failed {failed} plugin instantiation(s)." if failed else "")
    )
    return changed_layers, failed

undo()

Undo the last canvas edit operation.

Source code in imagebaker/layers/canvas_layer.py
110
111
112
113
114
115
116
117
118
119
def undo(self):
    """Undo the last canvas edit operation."""
    if not self._undo_stack:
        self.messageSignal.emit("Nothing to undo.")
        return False
    self._redo_stack.append(self._snapshot_layers())
    snapshot = self._undo_stack.pop()
    self._restore_layers(snapshot)
    self.messageSignal.emit("Undo applied.")
    return True

Annotable Layer

Bases: BaseLayer

Source code in imagebaker/layers/annotable_layer.py
  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
 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
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
class AnnotableLayer(BaseLayer):
    annotationAdded = Signal(Annotation)
    annotationRemoved = Signal()
    annotationUpdated = Signal(Annotation)
    annotationCleared = Signal()
    annotationMoved = Signal()
    layersChanged = Signal()
    labelUpdated = Signal(tuple)

    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

        # Brush annotation state
        self._brush_mask = None  # Numpy array for brush mask
        self._brush_last_pos = None
        self._undo_stack = []
        self._redo_stack = []
        self._max_history = 100
        self._drag_start_snapshot = None

    def _snapshot_annotations(self):
        return [ann.copy() for ann in self.annotations]

    def _restore_annotations(self, snapshot):
        self.annotations = [ann.copy() for ann in snapshot]
        for idx, ann in enumerate(self.annotations):
            ann.annotation_id = idx
            ann.selected = False
        self.selected_annotation = None
        self.current_annotation = None
        self.update()

    def _push_undo_state(self):
        self._undo_stack.append(self._snapshot_annotations())
        if len(self._undo_stack) > self._max_history:
            self._undo_stack.pop(0)
        self._redo_stack.clear()

    def undo(self):
        if not self._undo_stack:
            self.messageSignal.emit("Nothing to undo.")
            return
        self._redo_stack.append(self._snapshot_annotations())
        self._restore_annotations(self._undo_stack.pop())
        self.annotationRemoved.emit()
        self.messageSignal.emit("Undo applied.")

    def redo(self):
        if not self._redo_stack:
            self.messageSignal.emit("Nothing to redo.")
            return
        self._undo_stack.append(self._snapshot_annotations())
        self._restore_annotations(self._redo_stack.pop())
        self.annotationRemoved.emit()
        self.messageSignal.emit("Redo applied.")

    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 toggle_annotation_visibility(self):
        """Toggle visibility of all annotations."""
        selected_annotation = self._get_selected_annotation()
        if selected_annotation is not None:
            self._push_undo_state()
            selected_annotation.visible = not selected_annotation.visible
            self.annotationUpdated.emit(selected_annotation)
            self.update()

    def handle_key_press(self, event: QKeyEvent):
        # Handle Ctrl key for panning
        if event.key() == Qt.Key_Control:
            # disable this for now to allow pannings
            # if (
            #     self.mouse_mode != MouseMode.POLYGON
            # ):  # Only activate pan mode when not drawing polygons

            self.mouse_mode = MouseMode.PAN
            event.accept()
            return

        # Handle Ctrl+C for copy
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_C:
            self._copy_annotation()
            event.accept()
            return

        # Handle Ctrl+V for paste
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_V:
            self._paste_annotation()
            event.accept()
            return

        # Handle Ctrl+A for select all
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_A:
            self.select_all_annotations()
            event.accept()
            return
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
            self.undo()
            event.accept()
            return
        if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Y:
            self.redo()
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_Delete:
            self.delete_selected_annotations()
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and Qt.Key_0 <= event.key() <= Qt.Key_9:
            label_index = event.key() - Qt.Key_0
            if label_index < len(self.config.predefined_labels):
                label_info = self.config.predefined_labels[label_index]
                self.current_label = label_info.name
                self.current_color = label_info.color

                selected_annotations = [ann for ann in self.annotations if ann.selected]
                if not selected_annotations and self.selected_annotation is not None:
                    selected_annotations = [self.selected_annotation]

                for annotation in selected_annotations:
                    annotation.label = label_info.name
                    annotation.color = label_info.color
                    self.annotationUpdated.emit(annotation)

                self.messageSignal.emit(f"Label changed to: {label_info.name}")
                self.update()
                event.accept()
                return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_Q:
            self.set_mode(MouseMode.POINT)
            self.messageSignal.emit("Mouse mode set to POINT.")
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_W:
            self.set_mode(MouseMode.POLYGON)
            self.messageSignal.emit("Mouse mode set to POLYGON.")
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_E:
            self.set_mode(MouseMode.RECTANGLE)
            self.messageSignal.emit("Mouse mode set to RECTANGLE.")
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_H:
            self.toggle_annotation_visibility()
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_L:
            selected_annotations = [ann for ann in self.annotations if ann.selected]
            if selected_annotations:
                self.layerify_annotation(selected_annotations)
            else:
                self.layerify_annotation(self.annotations)
            event.accept()
            return

        if event.modifiers() == Qt.NoModifier and event.key() == Qt.Key_C:
            self.edit_selected_annotation_caption()
            event.accept()
            return

    def handle_key_release(self, event):
        if event.key() == Qt.Key_Control:
            if self.mouse_mode == MouseMode.PAN:
                self.mouse_mode = MouseMode.IDLE

    def copy_annotation(self):
        self._copy_annotation()

    def paste_annotation(self):
        self._paste_annotation()

    def delete_selected_annotations(self):
        self._push_undo_state()
        selected_annotations = [ann for ann in self.annotations if ann.selected]
        if not selected_annotations and self.selected_annotation is not None:
            selected_annotations = [self.selected_annotation]

        if not selected_annotations:
            self.messageSignal.emit("No annotation selected to delete.")
            return

        selected_ids = {id(ann) for ann in selected_annotations}
        removed_count = len(selected_annotations)
        self.annotations = [
            ann for ann in self.annotations if id(ann) not in selected_ids
        ]

        for index, annotation in enumerate(self.annotations):
            annotation.annotation_id = index

        self.selected_annotation = None
        self.current_annotation = None
        self.annotationRemoved.emit()
        self.messageSignal.emit(f"Deleted {removed_count} annotation(s)")
        self.update()

    def edit_selected_annotation_caption(self):
        selected_annotation = self._get_selected_annotation()
        self.selected_annotation = selected_annotation
        if selected_annotation is None:
            self.messageSignal.emit("No annotation selected for caption editing.")
            return

        current_caption = getattr(selected_annotation, "caption", "")
        text, ok = QInputDialog.getMultiLineText(
            self, "Edit Caption", "Enter caption:", current_caption
        )
        if ok:
            self._push_undo_state()
            selected_annotation.caption = text
            self.annotationUpdated.emit(selected_annotation)
            self.update()

    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):
        painter.setRenderHints(
            QPainter.Antialiasing | QPainter.SmoothPixmapTransform
        )
        self.label_rects.clear()

        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)

        # Draw mask if present
        if annotation.mask is not None:
            import numpy as np
            from PySide6.QtGui import QImage

            mask = annotation.mask
            if mask.dtype != np.uint8:
                mask = (mask * 255).astype(np.uint8)
            h, w = mask.shape
            # Create an RGBA image with the annotation color and alpha from mask
            color = annotation.color
            rgba = np.zeros((h, w, 4), dtype=np.uint8)
            rgba[..., 0] = color.red()
            rgba[..., 1] = color.green()
            rgba[..., 2] = color.blue()
            rgba[..., 3] = (mask * (self.config.normal_draw_config.brush_alpha)).astype(
                np.uint8
            )
            qimg = QImage(rgba.data, w, h, QImage.Format_RGBA8888)
            painter.drawImage(0, 0, qimg)

        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 no mask
        if annotation.mask is None:
            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()

        is_active_annotation = (
            annotation is self.current_annotation
            or (
                annotation is self.selected_annotation
                and hasattr(self, "active_handle")
                and self.active_handle is not None
            )
        )

        # Draw labels only for completed, non-active annotations.
        if (
            annotation.is_complete
            and annotation.label
            and not is_temp
            and not is_active_annotation
        ):
            label_pos = self.get_label_position(annotation)
            text = annotation.label

            # Convert to widget coordinates because we draw labels in screen space.
            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)

            zoom = max(0.1, float(self.scale))
            label_px = int(
                round(self.config.normal_draw_config.label_font_size * zoom)
            )
            label_px = max(9, min(48, label_px))

            painter.resetTransform()

            label_font = painter.font()
            label_font.setPixelSize(label_px)
            label_font.setItalic(False)
            label_font.setWeight(QFont.DemiBold)
            painter.setFont(label_font)

            label_metrics = painter.fontMetrics()
            label_width = label_metrics.horizontalAdvance(text)
            label_height = label_metrics.height()

            bg_rect = QRectF(
                widget_pos.x() - label_width / 2 - 4,
                widget_pos.y() - label_height / 2 - 3,
                label_width + 8,
                label_height + 6,
            )
            self.label_rects.append((bg_rect, annotation))

            painter.setPen(Qt.NoPen)
            painter.setBrush(self.config.normal_draw_config.label_font_background_color)
            painter.drawRoundedRect(bg_rect, 3, 3)

            painter.setPen(Qt.white)
            painter.drawText(bg_rect, Qt.AlignCenter, text)

            if annotation.caption:
                caption_text = annotation.caption
                caption_px = int(
                    round(self.config.selected_draw_config.label_font_size * zoom)
                )
                caption_px = max(8, min(40, caption_px))

                caption_font = painter.font()
                caption_font.setItalic(True)
                caption_font.setPixelSize(caption_px)
                caption_font.setWeight(QFont.Light)
                painter.setFont(caption_font)

                caption_metrics = painter.fontMetrics()
                caption_width = caption_metrics.horizontalAdvance(caption_text)
                caption_height = caption_metrics.height()
                caption_rect = QRectF(
                    widget_pos.x() - caption_width / 2 - 4,
                    bg_rect.bottom() + 2,
                    caption_width + 8,
                    caption_height + 4,
                )

                painter.setPen(Qt.NoPen)
                painter.setBrush(
                    self.config.normal_draw_config.label_font_background_color
                )
                painter.drawRoundedRect(caption_rect, 3, 3)

                painter.setPen(Qt.white)
                painter.drawText(caption_rect, Qt.AlignCenter, caption_text)

        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:
                rect = self.current_annotation.rectangle
                # Ignore click-only rectangle annotations (no meaningful drag).
                if rect and (
                    rect.width() >= self.config.drag_threshold
                    and rect.height() >= self.config.drag_threshold
                ):
                    self.finalize_annotation()
                else:
                    self.current_annotation = None
            elif self.mouse_mode == MouseMode.POLYGON and self.current_annotation:
                pass
            elif self.mouse_mode in [
                MouseMode.PAN,
                MouseMode.ZOOM_IN,
                MouseMode.ZOOM_OUT,
            ]:
                if self.current_annotation:
                    if (
                        self.current_annotation.polygon
                        and not self.current_annotation.is_complete
                    ):
                        self.mouse_mode = MouseMode.POLYGON
                else:
                    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
        if self._drag_start_snapshot is not None:
            before = self._drag_start_snapshot
            after = self._snapshot_annotations()
            if str(before) != str(after):
                self._undo_stack.append(before)
                if len(self._undo_stack) > self._max_history:
                    self._undo_stack.pop(0)
                self._redo_stack.clear()
            self._drag_start_snapshot = 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(
                        list(self.current_annotation.polygon)[:-1]
                    )

                # 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

            # 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._drag_start_snapshot = self._snapshot_annotations()
            if self.mouse_mode == MouseMode.DRAW:
                import numpy as np

                self._brush_mask = np.zeros(
                    (self.image.height(), self.image.width()), dtype=np.uint8
                )
                self._brush_last_pos = (int(clamped_pos.x()), int(clamped_pos.y()))
                self.current_label = self.current_label or "Brush"
                self.current_color = self.current_color or QColor(255, 0, 0)
                return
        # Brush drawing
        if event.buttons() & Qt.LeftButton and self.mouse_mode == MouseMode.DRAW:
            if self._brush_mask is not None and self._brush_last_pos is not None:
                import cv2

                x0, y0 = self._brush_last_pos
                x1, y1 = int(clamped_pos.x()), int(clamped_pos.y())
                brush_size = getattr(self, "brush_size", 5)
                cv2.line(
                    self._brush_mask, (x0, y0), (x1, y1), color=1, thickness=brush_size
                )
                self._brush_last_pos = (x1, y1)
                self.update()
            return
        if event.button() == Qt.LeftButton:
            if self.mouse_mode == MouseMode.DRAW and self._brush_mask is not None:
                mask = self._brush_mask.copy()
                if np.any(mask):
                    ann = Annotation(
                        annotation_id=len(self.annotations),
                        label=self.current_label or "Brush",
                        color=self.current_color or QColor(255, 0, 0),
                        mask=mask,
                        is_complete=True,
                    )
                    self.annotations.append(ann)
                    self.annotationAdded.emit(ann)
                self._brush_mask = None
                self._brush_last_pos = None
                self.update()
                return
            self.selected_annotation, self.active_handle = (
                self.find_annotation_and_handle_at(img_pos)
            )
            # Handle dragging later on
            if self.selected_annotation:
                is_multi_select = bool(event.modifiers() & Qt.ControlModifier)
                self.drag_offset = img_pos - self.get_annotation_position(
                    self.selected_annotation
                )
                if is_multi_select:
                    # Toggle selection state without clearing existing selections.
                    self.selected_annotation.selected = not self.selected_annotation.selected
                else:
                    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

                self.update()
                return

            # 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:
                    # if this point is equal to the last point of the previous polygon, then ignore it
                    if len(self.annotations) > 0:
                        last_polygon = self.annotations[-1].polygon
                        if last_polygon:
                            last_point = last_polygon[-1]
                            if last_point == clamped_pos:
                                logger.info(
                                    "Ignoring point, same as last polygon point"
                                )
                                return

                    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.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:
            self.labelUpdated.emit((annotation.label, new_label))
            annotation.label = new_label
            self.annotationUpdated.emit(annotation)  # Emit signal
            self.update()

    def finalize_annotation(self):
        self._push_undo_state()
        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)
        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 select_all_annotations(self):
        if not self.annotations:
            self.messageSignal.emit("No annotations to select.")
            return

        for ann in self.annotations:
            ann.selected = True
            self.annotationUpdated.emit(ann)
        self.selected_annotation = self.annotations[-1]
        self.messageSignal.emit(f"Selected {len(self.annotations)} annotations")
        self.update()

    def _copy_annotation(self):
        selected_annotations = [ann for ann in self.annotations if ann.selected]
        if not selected_annotations:
            self.selected_annotation = self._get_selected_annotation()
            if self.selected_annotation:
                selected_annotations = [self.selected_annotation]

        if selected_annotations:
            copied_annotations = [ann.copy() for ann in selected_annotations]
            for ann in copied_annotations:
                ann.selected = False
            BaseLayer.annotation_clipboard = copied_annotations
            self.copied_annotation = copied_annotations[0]
            self.messageSignal.emit(
                f"Copied {len(copied_annotations)} annotation(s)"
            )
            self.mouse_mode = MouseMode.IDLE
        else:
            self.messageSignal.emit("No annotation selected to copy.")

    def _paste_annotation(self):
        clipboard = BaseLayer.annotation_clipboard
        if clipboard:
            self._push_undo_state()
            pasted_annotations = []
            for copied_annotation in clipboard:
                new_annotation = copied_annotation.copy()
                new_annotation.annotation_id = len(self.annotations)
                new_annotation.selected = False
                new_annotation.file_path = self.file_path
                new_annotation._skip_label_registry = True
                self.annotations.append(new_annotation)
                self.annotationAdded.emit(new_annotation)
                self.thumbnails[new_annotation.annotation_id] = self.get_thumbnail(
                    new_annotation
                )
                pasted_annotations.append(new_annotation)

            self.messageSignal.emit(
                f"Pasted {len(pasted_annotations)} annotation(s)"
            )
            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.caption = annotation.caption

        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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
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
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
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)

    # Draw mask if present
    if annotation.mask is not None:
        import numpy as np
        from PySide6.QtGui import QImage

        mask = annotation.mask
        if mask.dtype != np.uint8:
            mask = (mask * 255).astype(np.uint8)
        h, w = mask.shape
        # Create an RGBA image with the annotation color and alpha from mask
        color = annotation.color
        rgba = np.zeros((h, w, 4), dtype=np.uint8)
        rgba[..., 0] = color.red()
        rgba[..., 1] = color.green()
        rgba[..., 2] = color.blue()
        rgba[..., 3] = (mask * (self.config.normal_draw_config.brush_alpha)).astype(
            np.uint8
        )
        qimg = QImage(rgba.data, w, h, QImage.Format_RGBA8888)
        painter.drawImage(0, 0, qimg)

    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 no mask
    if annotation.mask is None:
        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()

    is_active_annotation = (
        annotation is self.current_annotation
        or (
            annotation is self.selected_annotation
            and hasattr(self, "active_handle")
            and self.active_handle is not None
        )
    )

    # Draw labels only for completed, non-active annotations.
    if (
        annotation.is_complete
        and annotation.label
        and not is_temp
        and not is_active_annotation
    ):
        label_pos = self.get_label_position(annotation)
        text = annotation.label

        # Convert to widget coordinates because we draw labels in screen space.
        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)

        zoom = max(0.1, float(self.scale))
        label_px = int(
            round(self.config.normal_draw_config.label_font_size * zoom)
        )
        label_px = max(9, min(48, label_px))

        painter.resetTransform()

        label_font = painter.font()
        label_font.setPixelSize(label_px)
        label_font.setItalic(False)
        label_font.setWeight(QFont.DemiBold)
        painter.setFont(label_font)

        label_metrics = painter.fontMetrics()
        label_width = label_metrics.horizontalAdvance(text)
        label_height = label_metrics.height()

        bg_rect = QRectF(
            widget_pos.x() - label_width / 2 - 4,
            widget_pos.y() - label_height / 2 - 3,
            label_width + 8,
            label_height + 6,
        )
        self.label_rects.append((bg_rect, annotation))

        painter.setPen(Qt.NoPen)
        painter.setBrush(self.config.normal_draw_config.label_font_background_color)
        painter.drawRoundedRect(bg_rect, 3, 3)

        painter.setPen(Qt.white)
        painter.drawText(bg_rect, Qt.AlignCenter, text)

        if annotation.caption:
            caption_text = annotation.caption
            caption_px = int(
                round(self.config.selected_draw_config.label_font_size * zoom)
            )
            caption_px = max(8, min(40, caption_px))

            caption_font = painter.font()
            caption_font.setItalic(True)
            caption_font.setPixelSize(caption_px)
            caption_font.setWeight(QFont.Light)
            painter.setFont(caption_font)

            caption_metrics = painter.fontMetrics()
            caption_width = caption_metrics.horizontalAdvance(caption_text)
            caption_height = caption_metrics.height()
            caption_rect = QRectF(
                widget_pos.x() - caption_width / 2 - 4,
                bg_rect.bottom() + 2,
                caption_width + 8,
                caption_height + 4,
            )

            painter.setPen(Qt.NoPen)
            painter.setBrush(
                self.config.normal_draw_config.label_font_background_color
            )
            painter.drawRoundedRect(caption_rect, 3, 3)

            painter.setPen(Qt.white)
            painter.drawText(caption_rect, Qt.AlignCenter, caption_text)

    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
 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
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

toggle_annotation_visibility()

Toggle visibility of all annotations.

Source code in imagebaker/layers/annotable_layer.py
117
118
119
120
121
122
123
124
def toggle_annotation_visibility(self):
    """Toggle visibility of all annotations."""
    selected_annotation = self._get_selected_annotation()
    if selected_annotation is not None:
        self._push_undo_state()
        selected_annotation.visible = not selected_annotation.visible
        self.annotationUpdated.emit(selected_annotation)
        self.update()

Workers

Baker Worker

Bases: QObject

Source code in imagebaker/workers/baker_worker.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
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["BaseLayer"],
        filename: Path,
        render_cache: dict[tuple[int, int], QImage] | None = None,
        timeline_total_steps: int | None = None,
    ):
        """
        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
        self.render_cache = render_cache or {}
        self.timeline_total_steps = (
            None if timeline_total_steps is None else int(timeline_total_steps)
        )
        self._fallback_render_cache: dict[tuple[int, int], QImage] = {}

        # logger.info(f"Received States: {self.states}")

    @staticmethod
    def _normalize_rgba_image(image: QImage) -> QImage:
        if image.format() != QImage.Format_RGBA8888:
            return image.convertToFormat(QImage.Format_RGBA8888)
        return image

    def _resolve_render_image(
        self,
        layer: "BaseLayer",
        state: "LayerState",
        timeline_step: int,
        total_steps: int,
    ) -> QImage:
        key = (int(timeline_step), int(state.layer_id))
        cached = self.render_cache.get(key)
        if cached is not None:
            return self._normalize_rgba_image(cached).copy()

        fallback = self._fallback_render_cache.get(key)
        if fallback is not None:
            return fallback

        try:
            render_pixmap = apply_pixel_plugins(
                layer=layer,
                step=timeline_step,
                total_steps=total_steps,
                canvas=None,
            )
            image = self._normalize_rgba_image(render_pixmap.toImage()).copy()
        except Exception as error:
            logger.error(
                f"Failed to render pixel plugins for layer {layer.layer_name}: {error}"
            )
            image = self._normalize_rgba_image(layer.image.toImage()).copy()

        self._fallback_render_cache[key] = image
        return image

    def process(self):
        results = []
        try:
            if self.timeline_total_steps is None:
                total_steps = max(1, len(self.states))
            else:
                total_steps = max(1, int(self.timeline_total_steps))

            for step, states in sorted(self.states.items()):
                timeline_step = int(step)
                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()

                        render_image = self._resolve_render_image(
                            layer=layer,
                            state=state,
                            timeline_step=timeline_step,
                            total_steps=total_steps,
                        )

                        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), render_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():
                            render_image = self._resolve_render_image(
                                layer=layer,
                                state=state,
                                timeline_step=timeline_step,
                                total_steps=total_steps,
                            )
                            # 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)
                                painter.setOpacity(layer.opacity / 255.0)
                                painter.drawImage(QPoint(0, 0), render_image)
                            finally:
                                painter.restore()

                            # Draw the drawing states
                            logger.debug(
                                f"Drawing states for layer {layer.layer_name}: {state.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
                                        )
                                except Exception as e:
                                    logger.error(
                                        f"Error drawing state for layer {layer.layer_name}: {e}"
                                    )
                                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.drawImage(QPoint(0, 0), render_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:
                                base_ann: Annotation | None = (
                                    layer.annotations[0] if layer.annotations else None
                                )
                                if base_ann is not None:
                                    new_annotation = self._generate_annotation(
                                        base_ann, alpha_channel
                                    )
                                    new_annotation.caption = layer.caption
                                    new_annotations.append(new_annotation)

                                brush_mask = self._render_brush_mask(
                                    width=width,
                                    height=height,
                                    layer=layer,
                                    state=state,
                                    top_left=top_left,
                                )
                                brush_annotation = self._generate_brush_annotation(
                                    base_ann=base_ann,
                                    brush_mask=brush_mask,
                                    fallback_name=layer.layer_name,
                                    caption=layer.caption,
                                )
                                if brush_annotation is not None:
                                    new_annotations.append(brush_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:
            import traceback

            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,
            caption=ann.caption,
            is_model_generated=ann.is_model_generated,
        )

        if ann.points:
            # Avoid exporting point clouds for baked results; use shape geometry.
            polygons = mask_to_polygons(alpha_channel, merge_polygons=True)
            if polygons:
                new_annotation.polygon = QPolygonF(
                    [QPointF(p[0], p[1]) for p in polygons[0]]
                )
            else:
                xywhs = mask_to_rectangles(alpha_channel, merge_rectangles=True)
                if xywhs:
                    new_annotation.rectangle = QRectF(
                        xywhs[0][0], xywhs[0][1], xywhs[0][2], xywhs[0][3]
                    )
        elif ann.rectangle:
            xywhs = mask_to_rectangles(alpha_channel, merge_rectangles=True)
            if len(xywhs) == 0:
                logger.info("No rectangles found")
                # return None
            else:
                logger.info(f"Found {len(xywhs)} rectangles")
                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)
            if polygon:
                poly = QPolygonF([QPointF(p[0], p[1]) for p in polygon[0]])
                new_annotation.polygon = poly
            else:
                xywhs = mask_to_rectangles(alpha_channel, merge_rectangles=True)
                if xywhs:
                    new_annotation.rectangle = QRectF(
                        xywhs[0][0], xywhs[0][1], xywhs[0][2], xywhs[0][3]
                    )
        else:
            logger.info("No annotation found")
        return new_annotation

    def _render_brush_mask(self, width, height, layer, state, top_left):
        if not state.drawing_states:
            return None

        brush_mask = QImage(width, height, QImage.Format_ARGB32)
        brush_mask.fill(Qt.transparent)
        brush_painter = QPainter(brush_mask)

        try:
            brush_painter.setRenderHints(
                QPainter.Antialiasing | QPainter.SmoothPixmapTransform
            )
            brush_painter.translate(layer.position - top_left)
            brush_painter.rotate(layer.rotation)
            brush_painter.scale(layer.scale_x, layer.scale_y)
            for drawing_state in state.drawing_states:
                brush_painter.setPen(
                    QPen(
                        Qt.white,
                        drawing_state.size,
                        Qt.SolidLine,
                        Qt.RoundCap,
                        Qt.RoundJoin,
                    )
                )
                brush_painter.drawPoint(drawing_state.position)
        finally:
            brush_painter.end()

        mask_arr = qpixmap_to_numpy(brush_mask)
        alpha_channel = mask_arr[:, :, 3].copy()
        alpha_channel[alpha_channel > 0] = 255
        if not np.any(alpha_channel):
            return None
        return alpha_channel

    def _generate_brush_annotation(
        self,
        base_ann: Annotation | None,
        brush_mask: np.ndarray | None,
        fallback_name: str,
        caption: str = "",
    ) -> Annotation | None:
        if brush_mask is None:
            return None

        label = f"{base_ann.label}_brush" if base_ann is not None else "Brush"
        color = base_ann.color if base_ann is not None else QColor(255, 255, 255)
        annotation_id = (
            base_ann.annotation_id * 1000 + 1
            if base_ann is not None
            else abs(hash((fallback_name, "brush"))) % 1_000_000
        )

        brush_annotation = Annotation(
            annotation_id=annotation_id,
            label=label,
            color=color,
            mask=brush_mask,
            is_complete=True,
            visible=True,
            caption=caption or (base_ann.caption if base_ann is not None else ""),
        )

        polygons = mask_to_polygons(brush_mask, merge_polygons=True)
        if polygons:
            brush_annotation.polygon = QPolygonF(
                [QPointF(p[0], p[1]) for p in polygons[0]]
            )
        else:
            xywhs = mask_to_rectangles(brush_mask, merge_rectangles=True)
            if xywhs:
                x, y, w, h = xywhs[0]
                brush_annotation.rectangle = QRectF(x, y, w, h)

        return brush_annotation

Layerify Worker

Bases: QObject

Source code in imagebaker/workers/layerify_worker.py
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
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
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
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
106
107
108
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.
    """
    color = (0, 255, 0, 255) if image.shape[2] == 4 else (0, 255, 0)

    for i, ann in enumerate(annotations):
        if ann.rectangle:
            # if image has alpha channel, make color full alpha
            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()),
                ),
                color,
                2,
            )
            rect_center = ann.rectangle.center()

            cv2.putText(
                image,
                ann.label,
                (int(rect_center.x()), int(rect_center.y())),
                cv2.FONT_HERSHEY_SIMPLEX,
                1,
                color,
                2,
            )
        elif ann.polygon:
            cv2.polylines(
                image,
                [np.array([[int(p.x()), int(p.y())] for p in ann.polygon])],
                True,
                color,
                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,
                color,
                2,
            )
        elif ann.points:
            for p in ann.points:
                cv2.circle(image, (int(p.x()), int(p.y())), 5, color, -1)
            cv2.putText(
                image,
                ann.label,
                (int(ann.points[0].x()), int(ann.points[0].y())),
                cv2.FONT_HERSHEY_SIMPLEX,
                1,
                color,
                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
 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
97
98
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),
            caption=previous_state.caption,  # Assuming caption is the same in both states
        )

        # 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