Skip to content

cylindra.widgets.main.CylindraMainWidget

Source code in cylindra/widgets/main.py
 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
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
@magicclass(
    widget_type="scrollable",
    stylesheet=_STYLE,
    name="cylindra",
    use_native_menubar=False,
)
@_shared_doc.update_cls
class CylindraMainWidget(MagicTemplate):
    # Main GUI class.

    # Widget for manual spline fitting
    spline_fitter = field(_sw.SplineFitter, name="_Spline fitter")
    # Widget for manual spline clipping
    spline_clipper = field(_sw.SplineClipper, name="_Spline clipper")
    # Widget for sweeping along splines
    spline_slicer = field(_sw.SplineSlicer, name="_Spline slicer")
    # Widget for pre-filtering/pre-processing
    image_processor = field(_sw.ImageProcessor, name="_Image Processor")
    # Widget for tomogram simulator
    simulator = field(_sw.Simulator, name="_Simulator")
    # Widget for measuring FFT parameters from a 2D power spectra
    spectra_inspector = field(_sw.SpectraInspector, name="_SpectraInspector")
    # Widget for subtomogram analysis
    sta = field(SubtomogramAveraging, name="_STA widget")

    mole_layers = MoleculesLayerAccessor()

    @property
    def batch(self) -> "CylindraBatchWidget":
        """Return the batch analyzer."""
        return self.AnalysisMenu.open_project_batch_analyzer()

    # Menu bar
    FileMenu = field(_sw.FileMenu, name="File")
    ImageMenu = field(_sw.ImageMenu, name="Image")
    SplinesMenu = field(_sw.SplinesMenu, name="Splines")
    MoleculesMenu = field(_sw.MoleculesMenu, name="Molecules")
    AnalysisMenu = field(_sw.AnalysisMenu, name="Analysis")
    PluginsMenu = field(_sw.PluginsMenu, name="Plugins")
    OthersMenu = field(_sw.OthersMenu, name="Others")

    # Toolbar
    Toolbar = field(_sw.CylindraToolbar)

    # Child widgets
    GeneralInfo = field(_sw.GeneralInfo)
    # Widget for controling splines
    SplineControl = field(_sw.SplineControl)
    # Widget for summary of local properties
    LocalProperties = box.collapsible(field(_sw.LocalPropertiesWidget), text="Local Properties")  # fmt: skip
    # Widget for summary of glocal properties
    GlobalProperties = field(_sw.GlobalPropertiesWidget, name="Global Properties")  # fmt: skip
    # Widget for 2D overview of splines
    Overview = field(QtImageCanvas).with_options(tooltip="Overview of splines")  # fmt: skip

    ### methods ###

    def __init__(self):
        self._tomogram = CylTomogram.dummy(binsize=[1])
        self._reserved_layers = ReservedLayers()
        self._macro_offset: int = 1
        self._macro_image_load_offset: int = 1
        self._plugins_called: list[CylindraPluginFunction] = []
        self._need_save: bool = False
        self._batch: "CylindraBatchWidget | None" = None
        self._project_dir: "Path | None" = None
        self._current_binsize: int = 1
        self._project_metadata = dict[str, Any]()
        self.objectName()  # load napari types

    def __post_init__(self):
        self.min_width = 400
        self.LocalProperties.collapsed = False
        self.GlobalProperties.collapsed = False
        self.Overview.min_height = 300

        self.LocalProperties._props_changed.connect(
            lambda: self._update_local_properties_in_widget(replot=True)
        )

        # load all the workflows
        cfg = _config.get_config()
        for file in cfg.list_workflow_paths():
            try:
                self.OthersMenu.Workflows.append_workflow(file)
            except Exception as e:
                _Logger.exception(f"Failed to load workflow {file.stem}: {e}")

        # setup auto saver
        self._auto_saver = AutoSaver(self, sec=cfg.autosave_interval)

        # dask worker number
        if cfg.default_dask_n_workers is not None:
            if cfg.default_dask_n_workers <= 0:
                _Logger.warning("Invalid dask worker number. Set to default.")
            else:
                self.OthersMenu.configure_dask(cfg.default_dask_n_workers)

        @self.macro.on_appended.append
        def _on_appended(expr: mk.Expr):
            self._need_save = not str(expr).startswith("ui.open_image(")
            self._auto_saver.save()

        @self.macro.on_popped.append
        def _on_popped(*_):
            self._need_save = len(self.macro) >= self._macro_offset and not str(
                self.macro[-1]
            ).startswith("ui.open_image(")
            self._auto_saver.save()

        self.default_config = SplineConfig.from_file(cfg.default_spline_config_path)

        # load plugins
        load_plugin(self)
        return None

    @property
    def tomogram(self) -> CylTomogram:
        """The current tomogram instance."""
        return self._tomogram

    @property
    def splines(self):
        """The spline list."""
        return self.tomogram.splines

    @property
    def logger(self):
        """The logger instance."""
        return _Logger

    @property
    def default_config(self) -> SplineConfig:
        """Default spline configuration."""
        return self._default_cfg

    @default_config.setter
    def default_config(self, cfg: SplineConfig | dict[str, Any]):
        if not isinstance(cfg, SplineConfig):
            cfg = SplineConfig.from_dict(cfg, unknown="error")
        self._default_cfg = cfg
        self._refer_spline_config(cfg)

    @property
    def sub_viewer(self) -> "napari.Viewer":
        """The sub-viewer for subtomogram averages."""
        return self.sta.sub_viewer

    @property
    def project_dir(self) -> Path | None:
        """The project directory."""
        return self._project_dir

    @property
    def project_metadata(self) -> dict[str, Any]:
        """The project metadata."""
        return self._project_metadata

    def _init_macro_state(self):
        self._macro_offset = len(self.macro)
        self._plugins_called.clear()

    def _get_splines(self, widget=None) -> list[tuple[str, int]]:
        """Get list of spline objects for categorical widgets."""
        tomo = self.tomogram
        return [(f"({i}) {spl}", i) for i, spl in enumerate(tomo.splines)]

    def _get_spline_coordinates(self, coords=None) -> np.ndarray:
        """Get coordinates of the manually picked spline."""
        if coords is None:
            coords = self._reserved_layers.work.data
        out = np.round(coords, 3)
        if out.ndim != 2 or out.shape[1] != 3 or out.dtype.kind not in "iuf":
            raise ValueError("Input coordinates must be a (N, 3) numeric array.")
        return out

    def _get_available_binsize(self, _=None) -> list[int]:
        out = [x[0] for x in self.tomogram.multiscaled]
        if 1 not in out:
            out = [1, *out]
        return out

    def _get_default_config(self, config):
        if config is None:
            config = self.default_config.asdict()
        elif isinstance(config, dict):
            config = self.default_config.updated(**config).asdict()
        elif isinstance(config, SplineConfig):
            config = config.asdict()
        else:
            raise TypeError(f"Invalid config type: {type(config)}")
        return config

    def _norm_splines(self, splines: list[int] | Literal["all"]) -> list[int]:
        if isinstance(splines, str) and splines == "all":
            return list(range(self.splines.count()))
        return splines

    @set_design(icon="mdi:pen-add", location=Toolbar)
    @bind_key("F1")
    def register_path(
        self,
        coords: Annotated[np.ndarray, {"validator": _get_spline_coordinates}] = None,
        config: Annotated[dict[str, Any] | SplineConfig, {"validator": _get_default_config}] = None,
        err_max: Annotated[nm, {"bind": 0.5}] = 0.5,
    ):  # fmt: skip
        """Register points as a spline path."""
        if coords is None or coords.size == 0:
            raise ValueError("No points are given.")

        tomo = self.tomogram
        tomo.add_spline(coords, config=config, err_max=err_max)
        self._add_spline_instance(tomo.splines[-1])
        return undo_callback(self.delete_spline).with_args(-1)

    def _add_spline_instance(self, spl: "CylSpline"):
        # draw path
        tomo = self.tomogram
        self._add_spline_to_images(spl, len(tomo.splines) - 1)
        self._reserved_layers.work.data = []
        self._reserved_layers.prof.selected_data = set()
        self.reset_choices()
        self.SplineControl.num = len(tomo.splines) - 1
        return None

    _runner = field(_sw.Runner)
    _image_loader = _sw.ImageLoader
    _file_iterator = field(_sw.FileIterator)

    def _confirm_delete(self):
        i = self.SplineControl.num
        if i is None:
            # If user is writing the first spline, there's no spline registered.
            return False
        return self.tomogram.splines[i].has_props()

    @set_design(icon="solar:eraser-bold", location=Toolbar)
    @confirm(text="Spline has properties. Are you sure to delete it?", condition=_confirm_delete)  # fmt: skip
    @do_not_record(recursive=False)
    def clear_current(self):
        """Clear current selection."""
        if self._reserved_layers.work.data.size > 0:
            self._reserved_layers.work.data = []
        else:
            self.delete_spline(self.SplineControl.num)
        return None

    @set_design(icon="material-symbols:bomb", location=Toolbar)
    @confirm(text="Are you sure to clear all?\nYou cannot undo this.")
    @do_not_record
    def clear_all(self):
        """Clear all the splines and results."""
        self.macro.clear_undo_stack()
        self.Overview.layers.clear()
        self.tomogram.splines.clear()
        self._init_widget_state()
        self._init_layers()
        del self.macro[self._macro_image_load_offset + 1 :]
        self._need_save = False
        self.reset_choices()
        return None

    def _format_macro(self, macro: "mk.Macro | None" = None):
        if macro is None:
            macro = self.macro
        v = mk.Expr("getattr", [mk.symbol(self), "parent_viewer"])
        return macro.format([(mk.symbol(self.parent_viewer), v)])

    @do_not_record(recursive=False)
    @nogui
    def run_workflow(self, filename: str, *args, **kwargs):
        """
        Run a user-defined workflow.

        This method will run a .py file that was defined by the user from
        `Workflow > Define workflow`. *args and **kwargs follow the signature of the
        main function of the workflow.
        """
        main = _config.get_main_function(filename)
        out = main(self, *args, **kwargs)
        return out

    @set_design(text="Open", location=_image_loader)
    @dask_thread_worker.with_progress(desc="Reading image")
    @confirm(text="You may have unsaved data. Open a new tomogram?", condition="self._need_save")  # fmt: skip
    def open_image(
        self,
        path: Annotated[str | Path, {"bind": _image_loader.path}],
        scale: Annotated[nm, {"bind": _image_loader.scale.scale_value}] = None,
        tilt_range: Annotated[Any, {"bind": _image_loader.tilt_model}] = None,
        bin_size: Annotated[int | Sequence[int], {"bind": _image_loader.bin_size}] = [1],
        filter: Annotated[ImageFilter | None, {"bind": _image_loader.filter}] = ImageFilter.Lowpass,
        invert: Annotated[bool, {"bind": _image_loader.invert}] = False,
        eager: Annotated[bool, {"bind": _image_loader.eager}] = False
    ):  # fmt: skip
        """
        Load an image file and process it before sending it to the viewer.

        Parameters
        ----------
        path : Path
            Path to the tomogram. Must be 3-D image.
        scale : float, default 1.0
            Pixel size in nm/pixel unit.
        tilt_range : tuple of float, default None
            Range of tilt angles in degrees.
        bin_size : int or list of int, default [1]
            Initial bin size of image. Binned image will be used for visualization in the viewer.
            You can use both binned and non-binned image for analysis.
        {filter}
        invert : bool, default False
            If true, invert the intensity of the image.
        eager : bool, default False
            If true, the image will be loaded immediately. Otherwise, it will be loaded
            lazily.
        """
        img = ip.lazy.imread(path, chunks=_config.get_config().dask_chunk)
        if scale is not None:
            scale = float(scale)
            img.scale.x = img.scale.y = img.scale.z = scale
        else:
            scale = img.scale.x
        if isinstance(bin_size, int):
            bin_size = [bin_size]
        elif len(bin_size) == 0:
            raise ValueError("You must specify at least one bin size.")
        else:
            bin_size = list(set(bin_size))  # delete duplication
        tomo = CylTomogram.imread(
            path=path,
            scale=scale,
            tilt=tilt_range,
            binsize=bin_size,
            eager=eager,
        )
        self._init_macro_state()
        self._project_dir = None
        return self._send_tomogram_to_viewer.with_args(tomo, filter, invert=invert)

    @open_image.started.connect
    def _open_image_on_start(self):
        return self._image_loader.close()

    @set_design(text=capitalize, location=_sw.FileMenu)
    @thread_worker.with_progress(desc="Reading project", total=0)
    @confirm(text="You may have unsaved data. Open a new project?", condition="self._need_save")  # fmt: skip
    @do_not_record
    @bind_key("Ctrl+K, Ctrl+P")
    def load_project(
        self,
        path: Path.Read[FileFilter.PROJECT],
        filter: ImageFilter | None = ImageFilter.Lowpass,
        read_image: Annotated[bool, {"label": "read image data"}] = True,
        update_config: bool = False,
    ):
        """
        Load a project file (project.json, tar file or zip file).

        Parameters
        ----------
        path : path-like or CylindraProject
            Path to the project file, or the project directory that contains a project
            file, or a CylindraProject object.
        {filter}
        read_image : bool, default True
            Whether to read image data from the project directory. If false, image data
            will be memory-mapped and will not be shown in the viewer. Unchecking this
            is useful to decrease loading time.
        update_config : bool, default False
            Whether to update the default spline configuration with the one described
            in the project.
        """
        if isinstance(path, CylindraProject):
            project = path
            project_path = project.project_path
        else:
            project = CylindraProject.from_file(path)
            project_path = project.project_path
        _Logger.print_html(
            f"<code>ui.load_project('{Path(project_path).as_posix()}', "
            f"filter={str(filter)!r}, {read_image=}, {update_config=})</code>"
        )
        if project_path is not None:
            _Logger.print(f"Project loaded: {project_path.as_posix()}")
            self._project_dir = project_path
        yield from project._to_gui(
            self,
            filter=filter,
            read_image=read_image,
            update_config=update_config,
        )

    @set_design(text=capitalize, location=_sw.FileMenu)
    @do_not_record
    @bind_key("Ctrl+K, Ctrl+S")
    def save_project(
        self,
        path: Path.Save,
        molecules_ext: Literal[".csv", ".parquet"] = ".csv",
        save_landscape: Annotated[bool, {"label": "Save landscape layers"}] = False,
    ):
        """
        Save current project state and the results in a directory.

        The json file contains paths of images and results, parameters of splines,
        scales and version. Local and global properties will be exported as csv files.
        Molecule coordinates and features will be exported as the `molecules_ext`
        format. If results are saved at the default directory, they will be
        written as relative paths in the project json file so that moving root
        directory does not affect the loading behavior.

        Parameters
        ----------
        path : Path
            Path of json file.
        molecules_ext : str, default ".csv"
            Extension of the molecule file. Can be ".csv" or ".parquet".
        save_landscape : bool, default False
            Save landscape layers if any. False by default because landscape layers are
            usually large.
        """
        path = Path(path)
        CylindraProject.save_gui(self, path, molecules_ext, save_landscape)
        _Logger.print(f"Project saved: {path.as_posix()}")
        self._need_save = False
        self._project_dir = path
        autosave_path = _config.autosave_path()
        if autosave_path.exists():
            with suppress(Exception):
                autosave_path.unlink()
        return None

    @set_design(text=capitalize, location=_sw.FileMenu)
    @do_not_record
    @bind_key("Ctrl+K, Ctrl+Shift+S")
    def overwrite_project(self):
        """Overwrite currently opened project."""
        if self._project_dir is None:
            raise ValueError(
                "No project is loaded. You can use `Save project` "
                "(ui.save_project(...)) to save the current state."
            )
        project = CylindraProject.from_file(self._project_dir)
        if project.molecules_info:
            ext = Path(project.molecules_info[0].name).suffix
        else:
            ext = ".csv"
        return self.save_project(self._project_dir, ext)

    @set_design(text=capitalize, location=_sw.FileMenu)
    def load_splines(self, paths: Path.Multiple[FileFilter.JSON]):
        """
        Load splines from a list of json paths.

        Parameters
        ----------
        paths : list of path-like objects
            Paths to json files that describe spline parameters in the correct format.
        """
        if isinstance(paths, (str, Path, bytes)):
            paths = [paths]
        splines = [CylSpline.from_json(path) for path in paths]
        self.tomogram.splines.extend(splines)
        self._update_splines_in_images()
        self.reset_choices()
        return None

    @set_design(text=capitalize, location=_sw.FileMenu)
    def load_molecules(self, paths: Path.Multiple[FileFilter.MOLE]):
        """Load molecules from a csv file."""
        if isinstance(paths, (str, Path, bytes)):
            paths = [paths]
        moles = [Molecules.from_file(path) for path in paths]
        for mole, path in zip(moles, paths, strict=False):
            name = Path(path).stem
            add_molecules(self.parent_viewer, mole, name)
        return None

    @set_design(text=capitalize, location=_sw.FileMenu)
    @do_not_record
    def save_spline(
        self,
        spline: Annotated[int, {"choices": _get_splines}],
        save_path: Path.Save[FileFilter.JSON],
    ):
        """Save splines as a json file."""
        spl = self.tomogram.splines[spline]
        spl.to_json(save_path)
        return None

    @do_not_record
    @set_design(text=capitalize, location=_sw.FileMenu)
    def save_molecules(
        self, layer: MoleculesLayerType, save_path: Path.Save[FileFilter.MOLE]
    ):
        """
        Save monomer coordinates, orientation and features as a csv file.

        Parameters
        ----------
        {layer}
        save_path : Path
            Where to save the molecules.
        """
        return assert_layer(layer, self.parent_viewer).molecules.to_csv(save_path)

    @set_design(text=capitalize, location=_sw.FileMenu)
    @do_not_record
    def open_reference_image(self, path: Path.Read[FileFilter.IMAGE]):
        """
        Open an image as a reference image of the current tomogram.

        The input image is usually a denoised image created by other softwares, or
        simply a filtered image. Please note that this method does not check that the
        input image is appropriate as a reference of the current tomogram, as
        potentially any 3D image can be used.

        Parameters
        ----------
        path : path-like
            Path to the image file. The image must be 3-D.
        """
        img = ip.imread(path)
        return self._update_reference_image(img)

    @set_design(text=capitalize, location=_sw.FileMenu)
    @do_not_record
    def open_label_image(self, path: Path.Read[FileFilter.IMAGE]):
        """Open an image file as a label image of the current tomogram."""
        label = ip.imread(path)
        if label.ndim != 3:
            raise ValueError("Label image must be 3-D.")
        tr = self.tomogram.multiscale_translation(label.scale.x / self.tomogram.scale)
        label = self.parent_viewer.add_labels(
            label,
            name=label.name,
            translate=[tr, tr, tr],
            scale=list(label.scale.values()),
            opacity=0.4,
        )
        self._reserved_layers.to_be_removed.add(label)
        return label

    @set_design(text=capitalize, location=_sw.ImageMenu)
    @dask_thread_worker.with_progress(desc=_pdesc.filter_image_fmt)
    @do_not_record
    def filter_reference_image(
        self,
        method: ImageFilter = ImageFilter.Lowpass,
    ):  # fmt: skip
        """Apply filter to enhance contrast of the reference image."""
        method = ImageFilter(method)
        if self.tomogram.is_dummy:
            return
        t0 = timer()
        with utils.set_gpu():
            img = self._reserved_layers.image_data
            overlap = [min(s, 32) for s in img.shape]
            _tiled = img.tiled(chunks=(224, 224, 224), overlap=overlap)
            sigma = 1.6 / self._reserved_layers.scale
            match method:
                case ImageFilter.Lowpass:
                    img_filt = _tiled.lowpass_filter(cutoff=0.2)
                case ImageFilter.Gaussian:
                    img_filt = _tiled.gaussian_filter(sigma=sigma, fourier=True)
                case ImageFilter.DoG:
                    img_filt = _tiled.dog_filter(low_sigma=sigma, fourier=True)
                case ImageFilter.LoG:
                    img_filt = _tiled.log_filter(sigma=sigma)
                case _:  # pragma: no cover
                    raise ValueError(f"No method matches {method!r}")

        contrast_limits = fast_percentile(img_filt, [1, 99.9])

        @thread_worker.callback
        def _filter_reference_image_on_return():
            self._reserved_layers.image.data = img_filt
            self._reserved_layers.image.contrast_limits = contrast_limits
            proj = self._reserved_layers.image.data.mean(axis="z")
            self.Overview.image = proj
            self.Overview.contrast_limits = contrast_limits

        t0.toc()
        return _filter_reference_image_on_return

    @thread_worker.with_progress(desc="Inverting image")
    @set_design(text=capitalize, location=_sw.ImageMenu)
    def invert_image(self):
        """Invert the intensity of the images."""
        t0 = timer()
        self.tomogram.invert()
        if self._reserved_layers.is_lazy:

            @thread_worker.callback
            def _invert_image_on_return():
                return undo_callback(self.invert_image)

        else:
            img_inv = -self._reserved_layers.image.data
            cmin, cmax = fast_percentile(img_inv, [1, 99.9])
            if cmin >= cmax:
                cmax = cmin + 1

            @thread_worker.callback
            def _invert_image_on_return():
                self._reserved_layers.image.data = img_inv
                self._reserved_layers.image.contrast_limits = (cmin, cmax)
                clow, chigh = self.Overview.contrast_limits
                self.Overview.image = -self.Overview.image
                self.Overview.contrast_limits = -chigh, -clow
                return undo_callback(self.invert_image)

        t0.toc()
        return _invert_image_on_return

    @set_design(text="Add multi-scale", location=_sw.ImageMenu)
    @dask_thread_worker.with_progress(desc=lambda bin_size: f"Adding multiscale (bin = {bin_size})")  # fmt: skip
    def add_multiscale(
        self,
        bin_size: Annotated[int, {"choices": list(range(2, 17))}] = 4,
    ):
        """
        Add a new multi-scale image of current tomogram.

        Parameters
        ----------
        bin_size : int, default 4
            Bin size of the new image
        """
        tomo = self.tomogram
        tomo.get_multiscale(binsize=bin_size, add=True)
        return thread_worker.callback(self.set_multiscale).with_args(bin_size)

    @set_design(text="Set multi-scale", location=_sw.ImageMenu)
    def set_multiscale(self, bin_size: Annotated[int, {"choices": _get_available_binsize}]):  # fmt: skip
        """
        Set multiscale used for image display.

        Parameters
        ----------
        bin_size: int
            Bin size of multiscaled image.
        """
        tomo = self.tomogram
        _old_bin_size = self._current_binsize
        imgb = tomo.get_multiscale(bin_size)
        factor = self._reserved_layers.scale / imgb.scale.x
        self._reserved_layers.update_image(imgb, tomo.multiscale_translation(bin_size))
        current_z = self.parent_viewer.dims.current_step[0]
        self.parent_viewer.dims.set_current_step(axis=0, value=current_z * factor)

        # update overview
        self.Overview.image = imgb.mean(axis="z")
        self.Overview.xlim = [x * factor for x in self.Overview.xlim]
        self.Overview.ylim = [y * factor for y in self.Overview.ylim]
        self._current_binsize = bin_size
        self.reset_choices()
        return undo_callback(self.set_multiscale).with_args(_old_bin_size)

    @set_design(text=capitalize, location=_sw.ImageMenu)
    def sample_subtomograms(self):
        """Sample subtomograms at the anchor points on splines"""
        self.spline_fitter.close()

        # initialize GUI
        if len(self.tomogram.splines) == 0:
            raise ValueError("No spline found.")
        spl = self.tomogram.splines[0]
        if spl.has_anchors:
            self.SplineControl["pos"].max = spl.anchors.size - 1
        self.SplineControl._num_changed()
        self._reserved_layers.work.mode = "pan_zoom"

        self._update_local_properties_in_widget()
        self._update_global_properties_in_widget()
        self._highlight_spline()

        # reset contrast limits
        self.SplineControl._reset_contrast_limits()
        return None

    def _get_spline_idx(self, *_) -> int:
        return self.SplineControl.num

    @set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
    def invert_spline(self, spline: Annotated[int, {"bind": _get_spline_idx}] = None):
        """
        Invert current displayed spline **in place**.

        Parameters
        ----------
        spline : int, optional
            ID of splines to be inverted.
        """
        if spline is None:
            return
        spl = self.tomogram.splines[spline]
        self.tomogram.splines[spline] = spl.invert()
        self._update_splines_in_images()
        self.reset_choices()

        need_resample = self.SplineControl.need_resample
        self._init_widget_state()
        if need_resample:
            self.sample_subtomograms()
        self._set_orientation_marker(spline)
        return undo_callback(self.invert_spline).with_args(spline)

    @set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
    def align_to_polarity(
        self, orientation: Literal["MinusToPlus", "PlusToMinus"] = "MinusToPlus"
    ):
        """
        Align all the splines in the direction parallel to the cylinder polarity.

        Parameters
        ----------
        orientation : Ori, default Ori.MinusToPlus
            To which direction splines will be aligned.
        """
        need_resample = self.SplineControl.need_resample
        _old_orientations = [spl.orientation for spl in self.tomogram.splines]
        self.tomogram.align_to_polarity(orientation=orientation)
        self._update_splines_in_images()
        self._init_widget_state()
        self.reset_choices()
        if need_resample:
            self.sample_subtomograms()
        for i in range(len(self.tomogram.splines)):
            self._set_orientation_marker(i)
        _new_orientations = [spl.orientation for spl in self.tomogram.splines]
        return (
            undo_callback(self._set_orientations)
            .with_args(_old_orientations, need_resample)
            .with_redo(lambda: self._set_orientations(_new_orientations))
        )

    @set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
    @thread_worker.with_progress(desc="Auto-detecting polarities...", total=_NSPLINES)
    def infer_polarity(
        self,
        splines: SplinesType = None,
        depth: Annotated[nm, {"min": 5.0, "max": 500.0, "step": 5.0}] = 40,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    ):  # fmt: skip
        """
        Automatically detect the cylinder polarities.

        This function uses Fourier vorticity to detect the polarities of the splines.
        The subtomogram at the center of the spline will be sampled in the cylindric
        coordinate and the power spectra in (radius, angle) space will be calculated.
        The peak position of the `angle = nPF` line scan will be used to detect the
        polarity of the spline.

        Parameters
        ----------
        {splines}{depth}{bin_size}
        """
        tomo = self.tomogram
        _old_orientations = [spl.orientation for spl in self.tomogram.splines]
        for i in self._norm_splines(splines):
            tomo.infer_polarity(i=i, binsize=bin_size, depth=depth, update=True)
            yield
        _new_orientations = [spl.orientation for spl in self.tomogram.splines]

        @thread_worker.callback
        def _on_return():
            self._update_splines_in_images()
            for i in range(len(tomo.splines)):
                self._set_orientation_marker(i)

            self.SplineControl._update_canvas()
            return (
                undo_callback(self._set_orientations)
                .with_args(_old_orientations)
                .with_redo(lambda: self._set_orientations(_new_orientations))
            )

        return _on_return

    def _set_orientations(self, orientations: list[Ori], resample: bool = True):
        for spl, ori in zip(self.tomogram.splines, orientations, strict=True):
            spl.orientation = ori
        self._update_splines_in_images()
        self._init_widget_state()
        self.reset_choices()
        for i in range(len(self.tomogram.splines)):
            self._set_orientation_marker(i)
        if resample:
            self.sample_subtomograms()
        return None

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    @bind_key("Ctrl+K, Ctrl+X")
    def clip_spline(
        self,
        spline: Annotated[int, {"choices": _get_splines}],
        lengths: Annotated[tuple[nm, nm], {"options": {"min": -1000.0, "max": 1000.0, "step": 0.1, "label": "clip length (nm)"}}] = (0.0, 0.0),
    ):  # fmt: skip
        """
        Clip selected spline at its edges by given lengths.

        Parameters
        ----------
        spline : int
            The ID of spline to be clipped.
        lengths : tuple of float, default (0., 0.)
            The length in nm to be clipped at the start and end of the spline.
        """
        if spline is None:
            return
        spl = self.tomogram.splines[spline]
        _old_spl = spl.copy()
        length = spl.length()
        start, stop = np.array(lengths) / length
        self.tomogram.splines[spline] = spl.clip(start, 1 - stop)
        self._update_splines_in_images()
        # current layer will be removed. Select another layer.
        self.parent_viewer.layers.selection = {self._reserved_layers.work}

        @undo_callback
        def out():
            self.tomogram.splines[spline] = _old_spl
            self._update_splines_in_images()

        return out

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    def split_spline(
        self,
        spline: Annotated[int, {"choices": _get_splines}],
        at: Annotated[nm, {"min": 0.0, "max": 10000.0, "step": 0.1, "label": "split at (nm)"}] = 100.0,
        from_start: bool = True,
        trim: Annotated[nm, {"min": 0.0, "max": 100.0, "step": 0.1, "label": "trim (nm)"}] = 0.0,
    ):  # fmt: skip
        """
        Split the spline into two at the given position.

        Parameters
        ----------
        {spline}
        at : float, default 100.0
            Position to split the spline in nm.
        from_start : bool, default True
            If True, the split position will be measured from the start of the spline.
        trim : float, default 0.0
            Trim the split parts by this length (nm).
        """
        spl = self.splines[spline]
        spls = spl.split(at, from_start=from_start, trim=trim)
        self.splines.pop(spline)
        for new_spl in reversed(spls):
            self.splines.insert(spline, new_spl)
        self._update_splines_in_images()
        self.reset_choices()

        @undo_callback
        def _out():
            del self.splines[-2:]
            self.splines.insert(spline, spl)
            self._update_splines_in_images()
            self.reset_choices()

        return _out

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    def split_splines_at_changing_point(
        self,
        splines: SplinesType = None,
        estimate_by: str = "radius",
        diff_cutoff: Annotated[float, {"min": 0.0, "max": 1000.0, "step": 0.01}] = 0.4,
        trim: Annotated[nm, {"min": 0.0, "max": 1000.0, "step": 0.1, "label": "trim (nm)"}] = 0.0,
    ):  # fmt: skip
        """
        Detect the changing point of the spline and split it there.

        This method is useful when (1) there's a change in the protofilament number, or
        (2) microtubules were polymerized from seeds.

        Parameters
        ----------
        {splines}
        estimate_by : str, default "radius"
            Local property to estimate the changing point. Must be one of the local
            property of the splines.
        diff_cutoff : float, default 2.0
            The cutoff value of the absolute difference between the two regions to be
            considered as a changing point.
        trim : float, default 0.0
            Trim the split parts by this length (nm). If any of the split parts is
            shorter than this length, the part will be discarded.
        """
        splines = self._norm_splines(splines)
        spl_map = dict[int, list[CylSpline]]()
        _Logger.print("`split_spine_at_changing_point`")
        for i in splines:
            spl = self.splines[i]
            if (loc := spl.props.get_loc(estimate_by, None)) is None:
                raise ValueError(
                    f"Spline-{i} does not have {estimate_by!r} local property. Call "
                    "`measure_local_radius` or `local_cft_analysis` first."
                )
            idx = utils.find_changing_point(loc)
            mean_diff = float(abs(loc[:idx].mean() - loc[idx:].mean()))
            _log = f"spline-{i}: {mean_diff=:.3g}"
            if mean_diff < diff_cutoff:
                _Logger.print(_log + " ==> skip")
                continue
            at = spl.length(0, (spl.anchors[idx - 1] + spl.anchors[idx]) / 2)
            _Logger.print(_log + f" ==> split at {at:.1f} nm")
            spl_map[i] = spl.split(at, from_start=True, trim=trim, allow_discard=True)

        for i, new_spls in sorted(spl_map.items(), key=lambda x: x[0], reverse=True):
            self.splines.pop(i)
            for new_spl in reversed(new_spls):
                self.splines.insert(i, new_spl)

        self._update_splines_in_images()
        self.reset_choices()
        return None

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    @confirm(
        text="Spline has properties. Are you sure to delete it?",
        condition=_confirm_delete,
    )
    def delete_spline(self, i: Annotated[int, {"bind": _get_spline_idx}]):
        """Delete currently selected spline."""
        if i < 0:
            i = len(self.tomogram.splines) - 1
        spl = self.tomogram.splines.pop(i)
        self.reset_choices()

        # update layer
        features = self._reserved_layers.prof.features
        old_data = self._reserved_layers.prof.data
        self._reserved_layers.select_spline(i, len(self.tomogram.splines))
        self._update_splines_in_images()
        if self.SplineControl.need_resample and len(self.tomogram.splines) > 0:
            self.sample_subtomograms()

        @undo_callback
        def out():
            self.tomogram.splines.insert(i, spl)
            self._reserved_layers.prof.data = old_data
            self._reserved_layers.prof.features = features
            self._add_spline_to_images(spl, i)
            self._update_splines_in_images()
            self.reset_choices()

        return out

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    def copy_spline(self, i: Annotated[int, {"bind": _get_spline_idx}]):
        """Make a copy of the current spline"""
        spl = self.tomogram.splines[i]
        self.tomogram.splines.append(spl.copy())
        self.reset_choices()
        self.SplineControl.num = len(self.tomogram.splines) - 1
        return undo_callback(self.delete_spline).with_args(-1)

    @set_design(text="Copy spline (new config)", location=_sw.SplinesMenu)
    def copy_spline_new_config(
        self,
        i: Annotated[int, {"bind": _get_spline_idx}],
        npf_range: Annotated[tuple[int, int], {"options": {"min": 2, "max": 100}}] = (11, 17),
        spacing_range: Annotated[tuple[nm, nm], {"options": {"step": 0.05}}] = (3.9, 4.3),
        twist_range: Annotated[tuple[float, float], {"options": {"min": -45.0, "max": 45.0, "step": 0.05}}] = (-1.0, 1.0),
        rise_range: Annotated[tuple[float, float], {"options": {"min": -45.0, "max": 45.0, "step": 0.1}}] = (0.0, 45.0),
        rise_sign: Literal[-1, 1] = -1,
        clockwise: Literal["PlusToMinus", "MinusToPlus"] = "MinusToPlus",
        thickness_inner: Annotated[nm, {"min": 0.0, "step": 0.1}] = 2.8,
        thickness_outer: Annotated[nm, {"min": 0.0, "step": 0.1}] = 2.8,
        fit_depth: Annotated[nm, {"min": 4.0, "step": 1}] = 49.0,
        fit_width: Annotated[nm, {"min": 4.0, "step": 1}] = 44.0,
        copy_props: bool = False,
    ):  # fmt: skip
        """Make a copy of the current spline with a new configuration."""
        config = locals()
        del config["i"], config["self"], config["copy_props"]
        spl = self.tomogram.splines[i]
        spl_new = spl.with_config(config, copy_props=copy_props)
        self.tomogram.splines.append(spl_new)
        self.reset_choices()
        self.SplineControl.num = len(self.tomogram.splines) - 1
        return undo_callback(self.delete_spline).with_args(-1)

    @set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
    @thread_worker.with_progress(desc="Spline Fitting", total=_NSPLINES)
    def fit_splines(
        self,
        splines: SplinesType = None,
        max_interval: Annotated[nm, {"label": "max interval (nm)"}] = 30,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1.0,
        err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 1.0,
        degree_precision: float = 0.5,
        edge_sigma: Annotated[Optional[nm], {"text": "Do not mask image", "label": "edge σ"}] = 2.0,
        max_shift: nm = 5.0,
    ):  # fmt: skip
        """
        Fit splines to the cylinder by auto-correlation.

        Parameters
        ----------
        {splines}{max_interval}{bin_size}{err_max}
        degree_precision : float, default 0.5
            Precision of xy-tilt degree in angular correlation.
        edge_sigma : bool, default 2.0
            Check if cylindric structures are densely packed. Initial spline position
            must be "almost" fitted in dense mode.
        max_shift : nm, default 5.0
            Maximum shift to be applied to each point of splines.
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)
        with SplineTracker(widget=self, indices=splines) as tracker:
            for i in splines:
                tomo.fit(
                    i,
                    max_interval=max_interval,
                    binsize=bin_size,
                    err_max=err_max,
                    degree_precision=degree_precision,
                    edge_sigma=edge_sigma,
                    max_shift=max_shift,
                )
                yield thread_worker.callback(self._update_splines_in_images)

            @thread_worker.callback
            def out():
                self._init_widget_state()
                self._update_splines_in_images()
                return tracker.as_undo_callback()

        return out

    @set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
    @thread_worker.with_progress(desc="Spline Fitting", total=_NSPLINES)
    def fit_splines_by_centroid(
        self,
        splines: SplinesType = None,
        max_interval: Annotated[nm, {"label": "max interval (nm)"}] = 30,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1.0,
        err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 1.0,
        max_shift: nm = 5.0,
    ):  # fmt: skip
        """
        Fit splines to the cylinder by centroid of sub-volumes.

        Parameters
        ----------
        {splines}{max_interval}{bin_size}{err_max}
        max_shift : nm, default 5.0
            Maximum shift to be applied to each point of splines.
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)
        with SplineTracker(widget=self, indices=splines) as tracker:
            for i in splines:
                tomo.fit_centroid(
                    i,
                    max_interval=max_interval,
                    binsize=bin_size,
                    err_max=err_max,
                    max_shift=max_shift,
                )
                yield thread_worker.callback(self._update_splines_in_images)

            @thread_worker.callback
            def out():
                self._init_widget_state()
                self._update_splines_in_images()
                return tracker.as_undo_callback()

        return out

    @set_design(text=capitalize, location=_sw.SplinesMenu)
    def add_anchors(
        self,
        splines: SplinesType = None,
        interval: Annotated[nm, {"label": "Interval between anchors (nm)", "min": 1.0}] = 25.0,
        how: Literal["pack", "equal"] = "pack",
    ):  # fmt: skip
        """
        Add anchors to splines.

        Parameters
        ----------
        {splines}{interval}
        how : str, default "pack"
            How to add anchors.

            - "pack": (x———x———x—) Pack anchors from the starting point of splines.
            - "equal": (x——x——x——x) Equally distribute anchors between the starting
              point and the end point of splines. Actual intervals will be smaller.
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)
        with SplineTracker(widget=self, indices=splines) as tracker:
            match how:
                case "pack":
                    tomo.make_anchors(splines, interval=interval)
                case "equal":
                    tomo.make_anchors(splines, max_interval=interval)
                case _:  # pragma: no cover
                    raise ValueError(f"Unknown method: {how}")

            self._update_splines_in_images()
            return tracker.as_undo_callback()

    @set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
    @thread_worker.with_progress(desc="Refining splines", total=_NSPLINES)
    def refine_splines(
        self,
        splines: SplinesType = None,
        max_interval: Annotated[nm, {"label": "maximum interval (nm)"}] = 30,
        err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 0.8,
        corr_allowed: Annotated[float, {"label": "correlation allowed", "max": 1.0, "step": 0.1}] = 0.9,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    ):  # fmt: skip
        """
        Refine splines using the global cylindric structural parameters.

        Parameters
        ----------
        {splines}{max_interval}{err_max}
        corr_allowed : float, default 0.9
            How many images will be used to make template for alignment. If 0.9, then
            top 90% will be used.
        {bin_size}
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)
        with SplineTracker(widget=self, indices=splines) as tracker:
            for i in splines:
                tomo.refine(
                    i,
                    max_interval=max_interval,
                    corr_allowed=corr_allowed,
                    err_max=err_max,
                    binsize=bin_size,
                )
                yield thread_worker.callback(self._update_splines_in_images)

            @thread_worker.callback
            def out():
                self._init_widget_state()
                self._update_splines_in_images()
                self._update_local_properties_in_widget()
                return tracker.as_undo_callback()

        return out

    @set_design(text="Set spline properties", location=_sw.SplinesMenu)
    def set_spline_props(
        self,
        spline: Annotated[int, {"bind": _get_spline_idx}],
        npf: Annotated[Optional[int], {"label": "number of PF", "text": "Do not update"}] = None,
        start: Annotated[Optional[int], {"label": "start number", "text": "Do not update"}] = None,
        orientation: Annotated[Optional[Literal["MinusToPlus", "PlusToMinus"]], {"text": "Do not update"}] = None,
    ):  # fmt: skip
        """
        Set spline global properties.

        This method will overwrite spline properties with the user input. You should
        not call this method unless there's a good reason to do so, e.g. the number
        of protofilaments is obviously wrong.

        Parameters
        ----------
        npf : int, optional
            If given, update the number of protofilaments.
        start : int, optional
            If given, update the start number of the spline.
        orientation : str, optional
            If given, update the spline orientation.
        """
        spl = self.tomogram.splines[spline]
        old_spl = spl.copy()
        spl.update_props(npf=npf, start=start, orientation=orientation)
        self.sample_subtomograms()
        self._update_splines_in_images()

        @undo_callback
        def out():
            self.tomogram.splines[spline] = old_spl
            self.sample_subtomograms()
            self._update_splines_in_images()

        return out

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    def molecules_to_spline(
        self,
        layers: MoleculesLayersType = (),
        err_max: Annotated[nm, {"label": "Max fit error (nm)", "step": 0.1}] = 0.8,
        delete_old: Annotated[bool, {"label": "Delete old splines"}] = True,
        inherits: Annotated[Optional[list[str]], {"label": "Properties to inherit", "text": "All properties"}] = None,
        missing_ok: Annotated[bool, {"label": "Missing OK"}] = False,
        update_sources: Annotated[bool, {"label": "Update all the spline sources"}] = True,
    ):  # fmt: skip
        """
        Create splines from molecules.

        This function is useful to refine splines using results of subtomogram
        alignment. If the molecules layer alreadly has a source spline, replace
        it with the new one.
        Note that this function only works with molecules that is correctly
        assembled by such as :func:`map_monomers`.

        Parameters
        ----------
        {layers}{err_max}
        delete_old : bool, default True
            If True, delete the old spline if the molecules has one. For instance, if
            "Mole-0" has the spline "Spline-0" as the source, and a spline "Spline-1" is
            created from "Mole-0", then "Spline-0" will be deleted from the list.
        inherits : bool, optional
            Which global properties to be copied to the new one. If None, all the properties
            will be copied.
        missing_ok : bool, default False
            If False, raise an error if the source spline is not found in the tomogram.
        update_sources : bool, default True
            If True, all the molecules with the out-of-date source spline will be updated
            to the newly created splines. For instance, if "Mole-0" and "Mole-1" have the
            spline "Spline-0" as the source, and a spline "Spline-1" is created from
            "Mole-1", then the source of "Mole-1" will be updated to "Spline-1" as well.
        """
        tomo = self.tomogram
        layers = assert_list_of_layers(layers, self.parent_viewer)

        # first check missing_ok=False case
        if not missing_ok:
            for layer in layers:
                # NOTE: The source spline may not exist in the list
                if _s := layer.source_spline:
                    tomo.splines.index(_s)  # raise error here if not found

        for layer in layers:
            if _s := layer.source_spline:
                _config = _s.config
            else:
                _config = self.default_config
            _shape = (*layer.regular_shape(), 3)
            coords = layer.molecules.pos.reshape(_shape).mean(axis=1)
            spl = CylSpline(config=_config).fit(coords, err_max=err_max)
            try:
                idx = tomo.splines.index(layer.source_spline)
            except ValueError:
                tomo.splines.append(spl)
            else:
                old_spl = tomo.splines[idx]
                if inherits is None:
                    spl.props.glob = old_spl.props.glob.clone()
                else:
                    glob = old_spl.props.glob
                    spl.props.glob = {k: glob[k] for k in glob.columns if k in inherits}

                # Must be updated here, otherwise each.source_component may return
                # None since GC may delete the old spline.
                if update_sources:
                    for each in self.mole_layers:
                        if each.source_component is old_spl:
                            each.source_component = spl
                if delete_old:
                    tomo.splines[idx] = spl
                else:
                    tomo.splines.append(spl)
            layer.source_component = spl

        self.reset_choices()
        self.sample_subtomograms()
        self._update_splines_in_images()
        return None

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    def protofilaments_to_spline(
        self,
        layer: MoleculesLayerType,
        err_max: Annotated[nm, {"label": "Max fit error (nm)", "step": 0.1}] = 0.8,
        ids: list[int] = (),
        config: Annotated[dict[str, Any] | SplineConfig, {"validator": _get_default_config}] = None,
    ):  # fmt: skip
        """
        Convert protofilaments to splines.

        If no IDs are given, all the molecules will be fitted to a spline, therefore
        essentially the same as manual filament picking. If IDs are given, selected
        protofilaments will be fitted to a spline separately.

        Parameters
        ----------
        {layer}{err_max}
        ids : list of int, default ()
            Protofilament IDs to be converted.
        """
        layer = assert_layer(layer, self.parent_viewer)
        tomo = self.tomogram
        mole = layer.molecules
        if len(ids) == 0:
            tomo.add_spline(mole.pos, err_max=err_max, config=config)
        for i in ids:
            sub = mole.filter(pl.col(Mole.pf) == i)
            if sub.count() == 0:
                continue
            tomo.add_spline(sub.sort(Mole.nth).pos, err_max=err_max, config=config)
        self.reset_choices()
        self._update_splines_in_images()
        return None

    @set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
    @thread_worker.with_progress(desc="Measuring Radius", total=_NSPLINES)
    def measure_radius(
        self,
        splines: SplinesType = None,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
        min_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 1.0,
        max_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 100.0,
    ):  # fmt: skip
        """
        Measure cylinder radius for each spline curve.

        Parameters
        ----------
        {splines}{bin_size}{min_radius}{max_radius}
        """
        splines = self._norm_splines(splines)
        with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
            for i in splines:
                self.tomogram.measure_radius(
                    i, binsize=bin_size, min_radius=min_radius, max_radius=max_radius
                )
                yield

            return tracker.as_undo_callback()

    @set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
    def set_radius(
        self,
        splines: SplinesType = None,
        radius: PolarsExprStrOrScalar = 10.0,
    ):  # fmt: skip
        """
        Set radius of the splines.

        Parameters
        ----------
        {splines}
        radius : float or str expression
            Radius of the spline. If a string expression is given, it will be evaluated to get
            the polars.Expr object. The returned expression will be evaluated with the global
            properties of the spline as the context.
        """
        radius_expr = widget_utils.norm_scalar_expr(radius)
        splines = self._norm_splines(splines)
        rdict = dict[int, float]()
        for i in splines:
            _radius = self.splines[i].props.get_glob(radius_expr)
            if not isinstance(_radius, (int, float)):
                raise ValueError(
                    f"Radius must be converted into a number, got {_radius!r}."
                )
            if _radius <= 0:
                raise ValueError(f"Radius must be positive, got {_radius}.")
            rdict[i] = _radius
        with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
            for i in splines:
                self.splines[i].radius = rdict[i]
            return tracker.as_undo_callback()

    @set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
    @thread_worker.with_progress(desc="Measuring local radii", total=_NSPLINES)
    def measure_local_radius(
        self,
        splines: SplinesType = None,
        interval: _Interval = None,
        depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
        min_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 1.0,
        max_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 100.0,
        update_glob: Annotated[bool, {"text": "Also update the global radius"}] = True,
    ):  # fmt: skip
        """
        Measure radius for each local region along splines.

        Parameters
        ----------
        {splines}{interval}{depth}{bin_size}{min_radius}{max_radius}{update_glob}
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)

        @thread_worker.callback
        def _on_yield():
            self._update_local_properties_in_widget(replot=True)

        with SplineTracker(widget=self, indices=splines) as tracker:
            for i in splines:
                if interval is not None:
                    tomo.make_anchors(i=i, interval=interval)
                tomo.local_radii(
                    i=i,
                    depth=depth,
                    binsize=bin_size,
                    min_radius=min_radius,
                    max_radius=max_radius,
                    update_glob=update_glob,
                )
                if i == splines[-1]:
                    yield _on_yield
                else:
                    yield

            return tracker.as_undo_callback()

    @set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
    def measure_radius_by_molecules(
        self,
        layers: MoleculesLayersType = (),
        interval: _Interval = None,
        depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
        update_glob: Annotated[bool, {"text": "Also update the global radius"}] = True,
    ):  # fmt: skip
        """
        Measure local and global radius for each layer.

        Please note that the radius defined by the peak of the radial profile is not always
        the same as the radius measured by this method. If the molecules are aligned using
        a template image whose mass density is not centered, these radii may differ a lot.

        Parameters
        ----------
        {layers}{interval}{depth}{update_glob}
        """
        layers = assert_list_of_layers(layers, self.parent_viewer)

        # check duplicated spline sources
        _splines = list[CylSpline]()
        _radius_df = list[pl.DataFrame]()
        _duplicated = list[CylSpline]()
        for layer in layers:
            spl = _assert_source_spline_exists(layer)
            if any(spl is each for each in _splines):
                _duplicated.append(spl)
            _splines.append(spl)
            mole = layer.molecules
            df = mole.features
            _radius_df.append(df.with_columns(cylmeasure.calc_radius(mole, spl)))

        if _duplicated:
            _layer_names = ", ".join(repr(l.name) for l in layers)
            raise ValueError(f"Layers {_layer_names} have duplicated spline sources.")

        indices = [self.tomogram.splines.index(spl) for spl in _splines]
        with SplineTracker(widget=self, indices=indices) as tracker:
            for i, spl, df in zip(indices, _splines, _radius_df, strict=True):
                if interval is not None:
                    self.tomogram.make_anchors(i=i, interval=interval)
                radii = list[float]()
                for pos in spl.anchors * spl.length():
                    lower, upper = pos - depth / 2, pos + depth / 2
                    pred = pl.col(Mole.position).is_between(lower, upper, closed="left")
                    radii.append(df.filter(pred)[Mole.radius].mean())
                radii = pl.Series(H.radius, radii, dtype=pl.Float32)
                if radii.is_nan().any():
                    _Logger.print_html(f"<b>Spline-{i} contains NaN radius.</b>")
                spl.props.update_loc([radii], depth, bin_size=1)
                if update_glob:
                    spl.radius = df[Mole.radius].mean()
            self._update_local_properties_in_widget(replot=True)
            return tracker.as_undo_callback()

    @set_design(text="Local CFT analysis", location=_sw.AnalysisMenu)
    @thread_worker.with_progress(desc="Local Cylindric Fourier transform", total=_NSPLINES)  # fmt: skip
    def local_cft_analysis(
        self,
        splines: SplinesType = None,
        interval: _Interval = None,
        depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
        radius: Literal["local", "global"] = "global",
        update_glob: Annotated[bool, {"text": "Also update the global properties"}] = False,
    ):  # fmt: skip
        """
        Determine local lattice parameters by local cylindric Fourier transformation.

        This method will sample subtomograms at given intervals and calculate the power
        spectra in a cylindrical coordinate. The peak position of the power spectra will
        used to determine the lattice parameters. Note that if the interval differs from
        the current spline anchors, the old local properties will be dropped.

        Parameters
        ----------
        {splines}{interval}{depth}{bin_size}
        radius : str, default "global"
            If "local", use the local radius for the analysis. If "global", use the
            global radius.
        {update_glob}
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)

        # first check radius
        match radius:
            case "global":
                for i in splines:
                    if tomo.splines[i].radius is None:
                        raise ValueError(
                            f"Global Radius of {i}-th spline is not measured yet. Please "
                            "measure the radius first from `Analysis > Radius`."
                        )
            case "local":
                for i in splines:
                    if not tomo.splines[i].props.has_loc(H.radius):
                        raise ValueError(
                            f"Local Radius of {i}-th spline is not measured yet. Please "
                            "measure the radius first from `Analysis > Radius`."
                        )
                if interval is not None:
                    raise ValueError(
                        "With `interval`, local radius values will be dropped. Please "
                        "set `radius='global'` or `interval=None`."
                    )
            case _:
                raise ValueError(f"radius must be 'local' or 'global', got {radius!r}.")

        @thread_worker.callback
        def _local_cft_analysis_on_yield(i: int):
            self._update_splines_in_images()
            if i == self.SplineControl.num:
                self.sample_subtomograms()

        with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
            for i in splines:
                if interval is not None:
                    tomo.make_anchors(i=i, interval=interval)
                tomo.local_cft_params(
                    i=i,
                    depth=depth,
                    binsize=bin_size,
                    radius=radius,
                    update_glob=update_glob,
                )
                yield _local_cft_analysis_on_yield.with_args(i)
            return tracker.as_undo_callback()

    @set_design(text="Global CFT analysis", location=_sw.AnalysisMenu)
    @thread_worker.with_progress(
        desc="Global Cylindric Fourier transform", total=_NSPLINES
    )
    def global_cft_analysis(
        self,
        splines: SplinesType = None,
        bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    ):  # fmt: skip
        """
        Determine cylindrical global structural parameters by Fourier transformation.

        Parameters
        ----------
        {splines}{bin_size}
        """
        tomo = self.tomogram
        splines = self._norm_splines(splines)

        with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
            for i in splines:
                spl = tomo.splines[i]
                if spl.radius is None:
                    tomo.measure_radius(i=i)
                tomo.global_cft_params(i=i, binsize=bin_size)
                yield

            # show all in a table
            @thread_worker.callback
            def _global_cft_analysis_on_return():
                df = (
                    pl.concat(
                        [tomo.splines[i].props.glob for i in splines],
                        how="vertical_relaxed",
                    )
                    .to_pandas()
                    .transpose()
                )
                df.columns = [f"Spline-{i}" for i in range(len(df.columns))]
                self.sample_subtomograms()
                _Logger.print_table(df, precision=3)
                self._update_global_properties_in_widget()

                return tracker.as_undo_callback()

        return _global_cft_analysis_on_return

    def _get_reanalysis_macro(self, path: Path):
        """Get the macro expression for reanalysis in the given project path."""
        _ui_sym = mk.symbol(self)
        project = CylindraProject.from_file(path)
        with project.open_project() as _dir:
            macro_path = _dir / "script.py"
            macro_expr = extract(macro_path.read_text())
        return _filter_macro_for_reanalysis(macro_expr, _ui_sym)

    @set_design(text="Re-analyze current tomogram", location=_sw.AnalysisMenu)
    @do_not_record
    def reanalyze_image(self):
        """
        Reanalyze the current tomogram.

        This method will extract the first manual operations from current session.
        """
        _ui_sym = mk.symbol(self)
        macro_expr = self._format_macro()[self._macro_image_load_offset :]
        macro = _filter_macro_for_reanalysis(macro_expr, _ui_sym)
        self.clear_all()
        mk.Expr(mk.Head.block, macro.args[1:]).eval({_ui_sym: self})
        return self.macro.clear_undo_stack()

    @set_design(text="Re-analyze with new config", location=_sw.AnalysisMenu)
    @do_not_record
    def reanalyze_image_config_updated(self):
        """
        Reanalyze the current tomogram with newly set default spline config.

        This method is useful when you have mistakenly drawn splines with wrong spline
        config.
        """
        _ui_sym = mk.symbol(self)
        macro_expr = self._format_macro()[self._macro_image_load_offset :]
        macro = _filter_macro_for_reanalysis(macro_expr, _ui_sym)
        macro = _remove_config_kwargs(macro)
        self.clear_all()
        mk.Expr(mk.Head.block, macro.args[1:]).eval({_ui_sym: self})
        return self.macro.clear_undo_stack()

    @set_design(text="Re-analyze project", location=_sw.AnalysisMenu)
    @do_not_record
    @bind_key("Ctrl+K, Ctrl+L")
    def load_project_for_reanalysis(self, path: Path.Read[FileFilter.PROJECT]):
        """
        Load a project file to re-analyze the data.

        This method will extract the first manual operations from a project file and
        run them. This is useful when you want to re-analyze the data with a different
        parameter set, or when there were some improvements in cylindra.
        """
        macro = self._get_reanalysis_macro(path)
        macro.eval({mk.symbol(self): self})
        return self.macro.clear_undo_stack()

    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
    #   Monomer mapping methods
    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    @bind_key("M")
    @thread_worker.with_progress(desc="Mapping monomers", total=_NSPLINES)
    def map_monomers(
        self,
        splines: SplinesType = None,
        orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
        offsets: _OffsetType = None,
        radius: Optional[nm] = None,
        extensions: Annotated[tuple[int, int], {"options": {"min": -100}}] = (0, 0),
        prefix: str = "Mole",
    ):  # fmt: skip
        """
        Map monomers as a regular cylindric grid assembly.

        This method uses the spline global properties.

        Parameters
        ----------
        {splines}{orientation}{offsets}
        radius : nm, optional
            Radius of the cylinder to position monomers.
        extensions : (int, int), default (0, 0)
            Number of molecules to extend. Should be a tuple of (prepend, append).
            Negative values will remove molecules.
        {prefix}
        """
        tomo = self.tomogram

        _Logger.print_html("<code>map_monomers</code>")
        _added_layers = list[MoleculesLayer]()

        @thread_worker.callback
        def _add_molecules(mol: Molecules, name: str, spl: CylSpline):
            layer = self.add_molecules(mol, name, source=spl)
            _added_layers.append(layer)
            _Logger.print(f"{name!r}: n = {len(mol)}")

        for i in self._norm_splines(splines):
            spl = tomo.splines[i]
            mol = tomo.map_monomers(
                i=i,
                orientation=orientation,
                offsets=normalize_offsets(offsets, spl),
                radius=normalize_radius(radius, spl),
                extensions=extensions,
            )

            cb = _add_molecules.with_args(mol, f"{prefix}-{i}", spl)
            yield cb
            cb.await_call()

        return self._undo_callback_for_layer(_added_layers)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    def map_monomers_with_extensions(
        self,
        spline: Annotated[int, {"choices": _get_splines}],
        n_extend: Annotated[dict[int, tuple[int, int]], {"label": "prepend/append", "widget_type": ProtofilamentEdit}] = {},
        orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
        offsets: _OffsetType = None,
        radius: Optional[nm] = None,
        prefix: str = "Mole",
    ):  # fmt: skip
        """
        Map monomers as a regular cylindric grid assembly.

        This method uses the spline global properties.

        Parameters
        ----------
        {spline}
        n_extend : dict[int, (int, int)]
            Number of molecules to extend. Should be mapping from the PF index to the (prepend,
            append) number of molecules to add. Remove molecules if negative values are given.
        {orientation}{offsets}
        radius : nm, optional
            Radius of the cylinder to position monomers.
        {prefix}
        """
        tomo = self.tomogram
        spl = tomo.splines[spline]
        coords = widget_utils.coordinates_with_extensions(spl, n_extend)
        mole = tomo.map_on_grid(
            i=spline,
            coords=coords,
            orientation=orientation,
            offsets=normalize_offsets(offsets, spl),
            radius=normalize_radius(radius, spl),
        )
        layer = self.add_molecules(mole, f"{prefix}-{spline}", source=spl)
        return self._undo_callback_for_layer(layer)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    def map_along_spline(
        self,
        splines: SplinesType = None,
        molecule_interval: PolarsExprStrOrScalar = "col('spacing')",
        orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
        rotate_molecules: bool = True,
        prefix: str = "Center",
    ):  # fmt: skip
        """
        Map molecules along splines. Each molecule is rotated by skewing.

        Parameters
        ----------
        {splines}{molecule_interval}{orientation}
        rotate_molecules : bool, default True
            If True, rotate molecules by the "twist" parameter of each spline.
        {prefix}
        """
        tomo = self.tomogram
        interv_expr = widget_utils.norm_scalar_expr(molecule_interval)
        splines = self._norm_splines(splines)
        _Logger.print_html("<code>map_along_spline</code>")
        _added_layers = list[MoleculesLayer]()
        for idx in splines:
            spl = tomo.splines[idx]
            interv = spl.props.get_glob(interv_expr)
            mole = tomo.map_centers(
                i=idx,
                interval=interv,
                orientation=orientation,
                rotate_molecules=rotate_molecules,
            )
            _name = f"{prefix}-{idx}"
            layer = self.add_molecules(mole, _name, source=spl)
            _added_layers.append(layer)
            _Logger.print(f"{_name!r}: n = {mole.count()}")
        return self._undo_callback_for_layer(_added_layers)

    @set_design(text="Map alogn PF", location=_sw.MoleculesMenu.FromToSpline)
    def map_along_pf(
        self,
        spline: Annotated[int, {"choices": _get_splines}],
        molecule_interval: PolarsExprStrOrScalar = "col('spacing')",
        offsets: _OffsetType = None,
        orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
        prefix: str = "PF",
    ):  # fmt: skip
        """
        Map molecules along the line of a protofilament.

        Parameters
        ----------
        {spline}{molecule_interval}{offsets}{orientation}{prefix}
        """
        tomo = self.tomogram
        interv_expr = widget_utils.norm_scalar_expr(molecule_interval)
        spl = tomo.splines[spline]
        _Logger.print_html("<code>map_along_PF</code>")
        mol = tomo.map_pf_line(
            i=spline,
            interval=spl.props.get_glob(interv_expr),
            offsets=normalize_offsets(offsets, spl),
            orientation=orientation,
        )
        _name = f"{prefix}-{spline}"
        layer = self.add_molecules(mol, _name, source=spl)
        _Logger.print(f"{_name!r}: n = {len(mol)}")
        return self._undo_callback_for_layer(layer)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
    def set_source_spline(
        self,
        layer: MoleculesLayerType,
        spline: Annotated[int, {"choices": _get_splines}],
    ):
        """
        Set source spline for a molecules layer.

        Parameters
        ----------
        {layer}{spline}
        """
        layer = assert_layer(layer, self.parent_viewer)
        old_spl = layer.source_component
        layer.source_component = self.tomogram.splines[spline]

        @undo_callback
        def _undo():
            layer.source_component = old_spl

        return _undo

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
    def concatenate_molecules(
        self,
        layers: MoleculesLayersType,
        name: str = "Mole-concat",
    ):  # fmt: skip
        """
        Concatenate selected molecules and create a new ones.

        Parameters
        ----------
        {layers}
        name : str, default "Mole-concat"
            Name of the new molecules layer.
        """
        layers = assert_list_of_layers(layers, self.parent_viewer)
        all_molecules = Molecules.concat([layer.molecules for layer in layers])
        points = add_molecules(self.parent_viewer, all_molecules, name=name)

        # logging
        layer_names = list[str]()
        for layer in layers:
            layer.visible = False
            layer_names.append(layer.name)

        _Logger.print_html("<code>concatenate_molecules</code>")
        _Logger.print("Concatenated:", ", ".join(layer_names))
        _Logger.print(f"{points.name!r}: n = {len(all_molecules)}")
        return self._undo_callback_for_layer(points)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
    def merge_molecule_info(
        self,
        pos: MoleculesLayerType,
        rotation: MoleculesLayerType,
        features: MoleculesLayerType,
    ):
        """
        Merge molecule info from different molecules.

        Parameters
        ----------
        pos : MoleculesLayer
            Molecules whose positions are used.
        rotation : MoleculesLayer
            Molecules whose rotations are used.
        features : MoleculesLayer
            Molecules whose features are used.
        """
        pos = assert_layer(pos, self.parent_viewer)
        rotation = assert_layer(rotation, self.parent_viewer)
        features = assert_layer(features, self.parent_viewer)
        _pos = pos.molecules
        _rot = rotation.molecules
        _feat = features.molecules
        mole = Molecules(_pos.pos, _rot.rotator, features=_feat.features)
        layer = self.add_molecules(
            mole, name="Mole-merged", source=pos.source_component
        )
        return self._undo_callback_for_layer(layer)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
    def copy_molecules_features(
        self,
        source: MoleculesLayerType,
        destinations: MoleculesLayersType,
        column: Annotated[str, {"choices": _choice_getter("copy_molecules_features")}],
        alias: str = "",
    ):  # fmt: skip
        """
        Copy molecules features from one layer to another.

        This method is useful when a layer feature (such as seam search result) should be
        shared by multiple molecules layers that were aligned in a different parameters.

        Parameters
        ----------
        source : MoleculesLayer
            Layer whose features will be copied.
        destinations : MoleculesLayersType
            To which layers the features should be copied.
        column : str
            Column name of the feature to be copied.
        alias : str, optional
            If given, the copied feature will be renamed to this name.
        """
        source = assert_layer(source, self.parent_viewer)
        destinations = assert_list_of_layers(destinations, self.parent_viewer)
        series = source.molecules.features[column]
        if alias:
            series = series.alias(alias)
        for dest in destinations:
            dest.molecules = dest.molecules.with_features([series])
        return None

    @set_design(text="Split molecules by feature", location=_sw.MoleculesMenu)
    def split_molecules(
        self,
        layer: MoleculesLayerType,
        by: Annotated[str, {"choices": _choice_getter("split_molecules")}],
    ):
        """
        Split molecules by a feature column.

        Parameters
        ----------
        {layer}
        by : str
            Name of the feature to split by.
        """
        layer = assert_layer(layer, self.parent_viewer)
        utils.assert_column_exists(layer.molecules.features, by)
        _added_layers = list[MoleculesLayer]()
        for _key, mole in layer.molecules.groupby(by):
            new = self.add_molecules(
                mole, name=f"{layer.name}_{_key}", source=layer.source_component
            )
            _added_layers.append(new)
        return self._undo_callback_for_layer(_added_layers)

    @set_design(text=capitalize, location=_sw.MoleculesMenu)
    def register_molecules(
        self,
        coords: Annotated[np.ndarray, {"validator": _get_spline_coordinates}] = None,
    ):
        """Register manually added points as molecules."""
        if coords is None or coords.size == 0:
            raise ValueError("No points are given.")
        mole = Molecules(coords)
        return self.add_molecules(mole, name="Mole-manual")

    @set_design(text=capitalize, location=_sw.MoleculesMenu)
    def translate_molecules(
        self,
        layers: MoleculesLayersType,
        translation: Annotated[tuple[nm, nm, nm], {"options": {"min": -1000, "max": 1000, "step": 0.1}, "label": "translation Z, Y, X (nm)"}],
        internal: bool = True,
        inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
    ):  # fmt: skip
        """
        Translate molecule coordinates without changing their rotations.

        Output molecules layer will be named as "<original name>-Shift".

        Parameters
        ----------
        {layers}
        translation : tuple of float
            Translation (nm) of the molecules in (Z, Y, X) order. Whether the world
            coordinate or the internal coordinate is used depends on the `internal`
            argument.
        internal : bool, default True
            If true, the translation is applied to the internal coordinates, i.e.
            molecules with different rotations are translated differently.
        {inherit_source}
        """
        layers = assert_list_of_layers(layers, self.parent_viewer)
        new_layers = list[MoleculesLayer]()
        for layer in layers:
            mole = layer.molecules
            if internal:
                out = mole.translate_internal(translation)
                if Mole.position in out.features.columns:
                    # update spline position feature
                    dy = translation[1]
                    out = out.with_features([pl.col(Mole.position) + dy])
            else:
                out = mole.translate(translation)
                if Mole.position in out.features.columns:
                    # spline position is not predictable.
                    out = out.drop_features([Mole.position])
            source = layer.source_component if inherit_source else None
            new = self.add_molecules(out, name=f"{layer.name}-Shift", source=source)
            new_layers.append(new)
        return self._undo_callback_for_layer(new_layers)

    @set_design(text=capitalize, location=_sw.MoleculesMenu)
    def rotate_molecules(
        self,
        layers: MoleculesLayersType,
        degrees: Annotated[
            list[tuple[Literal["z", "y", "x"], float]],
            {"layout": "vertical", "options": {"widget_type": SingleRotationEdit}},
        ],
        inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
    ):
        """
        Rotate molecules without changing their positions.

        Output molecules layer will be named as "<original name>-Rot".

        Parameters
        ----------
        {layers}
        degrees : list of (str, float)
            Rotation axes and degrees. For example, `[("z", 20), ("y", -10)]` means
            rotation by 20 degrees around the molecule Z axis and then by -10 degrees
            around the Y axis.
        {inherit_source}
        """
        layers = assert_list_of_layers(layers, self.parent_viewer)
        new_layers = list[MoleculesLayer]()
        rotvec = degrees_to_rotator(degrees).as_rotvec()
        for layer in layers:
            mole = layer.molecules.rotate_by_rotvec_internal(rotvec)
            source = layer.source_component if inherit_source else None
            new = self.add_molecules(mole, name=f"{layer.name}-Rot", source=source)
            new_layers.append(new)
        return self._undo_callback_for_layer(new_layers)

    @set_design(text="Rename molecule layers", location=_sw.MoleculesMenu)
    @do_not_record(recursive=False)
    def rename_molecules(
        self,
        old: str,
        new: str,
        include: str = "",
        exclude: str = "",
        pattern: str = "",
    ):
        """
        Rename multiple molecules layers at once.

        Parameters
        ----------
        old : str
            Old string to be replaced.
        new : str
            New string to replace `old`.
        include : str, optional
            Delete layers whose names contain this string.
        exclude : str, optional
            Delete layers whose names do not contain this string.
        pattern : str, optional
            String pattern to match the layer names. Use `*` as wildcard.
        """
        if old == "":
            raise ValueError("`old` is not given.")
        if new == "":
            raise ValueError("`new` is not given.")
        return self.mole_layers.rename(
            old, new, include=include, exclude=exclude, pattern=pattern
        )

    @set_design(text="Delete molecule layers", location=_sw.MoleculesMenu)
    @do_not_record(recursive=False)
    def delete_molecules(
        self,
        include: str = "",
        exclude: str = "",
        pattern: str = "",
    ):
        """
        Delete molecules by the layer names.

        Parameters
        ----------
        include : str, optional
            Delete layers whose names contain this string.
        exclude : str, optional
            Delete layers whose names do not contain this string.
        pattern : str, optional
            String pattern to match the layer names. Use `*` as wildcard.
        """
        self.mole_layers.delete(include=include, exclude=exclude, pattern=pattern)

    @set_design(text=capitalize, location=_sw.MoleculesMenu)
    def filter_molecules(
        self,
        layer: MoleculesLayerType,
        predicate: PolarsExprStr,
        inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
    ):
        """
        Filter molecules by their features.

        Parameters
        ----------
        {layer}
        predicate : ExprStr
            A polars-style filter predicate, such as `pl.col("pf-id") == 3`
        {inherit_source}
        """
        layer = assert_layer(layer, self.parent_viewer)
        mole = layer.molecules
        out = mole.filter(widget_utils.norm_expr(predicate))
        source = layer.source_component if inherit_source else None
        new = self.add_molecules(out, name=f"{layer.name}-Filt", source=source)
        return self._undo_callback_for_layer(new)

    @set_design(text=capitalize, location=_sw.MoleculesMenu)
    def drop_molecules(
        self,
        layer: MoleculesLayerType,
        indices: Annotated[str | Sequence[int | slice], {"widget_type": IndexEdit}] = "",
        inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
    ):  # fmt: skip
        """
        Drop a subset of molecules from a molecules layer by indices.

        Note that the indices start from 0. `ui.drop_molecules(layer, [0, 2, 6])` will
        drop 0th, 2nd, and 6th molecules.

        Parameters
        ----------
        {layer}
        indices : str, int, slice or sequence of int or slice, optional
            A sequence of molecule indices to drop. You can use `npf` for the number
            of protofilaments and `N` for the number of molecules. `slice` is also
            allowed for dropping a range of indices. In GUI, this parameter must a
            string of comma-separated integers/slices (e.g. `3, N - 3`,
            `1, slice(12, 12 + npf)`).
        {inherit_source}
        """
        layer = assert_layer(layer, self.parent_viewer)
        mole = layer.molecules
        _to_drop = set[int]()
        if isinstance(indices, str):
            if spl := layer.source_spline:
                npf = spl.props.get_glob(H.npf, None)
            else:
                npf = None
            indices = IndexEdit.eval(indices, npf=npf, N=mole.count())
        for i in indices:
            if isinstance(i, slice):
                _to_drop.update(range(*i.indices(mole.count())))
            elif isinstance(i, (int, np.integer)):
                _to_drop.add(int(i))
            else:
                raise ValueError(f"Indices must be integers, got {type(i)!r}.")
        sl = np.array([i for i in range(mole.count()) if i not in _to_drop])
        out = mole.subset(sl)
        source = layer.source_component if inherit_source else None
        new = self.add_molecules(out, name=f"{layer.name}-Drop", source=source)
        return self._undo_callback_for_layer(new)

    @set_design(text=capitalize, location=_sw.MoleculesMenu.View)
    @bind_key("Ctrl+K, C")
    def paint_molecules(
        self,
        layer: MoleculesLayerType,
        color_by: Annotated[str, {"choices": _choice_getter("paint_molecules")}],
        cmap: _CmapType = DEFAULT_COLORMAP,
        limits: Annotated[tuple[float, float], {"options": {"min": -20, "max": 20, "step": 0.01}}] = (4.00, 4.24),
    ):  # fmt: skip
        """
        Paint molecules by a feature.

        Parameters
        ----------
        {layer}{color_by}{cmap}{limits}
        """
        layer = assert_layer(layer, self.parent_viewer)
        info = layer.colormap_info
        layer.set_colormap(color_by, limits, cmap)

        match info:
            case str(color):
                return undo_callback(layer.face_color_setter).with_args(color)
            case info:
                return undo_callback(layer.set_colormap).with_args(
                    by=info.name, limits=info.clim, cmap=info.cmap
                )

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    @confirm(
        text="Column already exists. Overwrite?",
        condition="column_name in layer.molecules.features.columns",
    )
    def calculate_molecule_features(
        self,
        layer: MoleculesLayerType,
        column_name: str,
        expression: PolarsExprStr,
    ):
        """
        Calculate a new feature from the existing features.

        This method is identical to running `with_columns` on the features dataframe
        as a `polars.DataFrame`. For example,
        >>> ui.calculate_molecule_features(layer, "Y", "pl.col('X') + 1")
        is equivalent to
        >>> layer.features = layer.features.with_columns([(pl.col("X") + 1).alias("Y")])

        Parameters
        ----------
        {layer}
        column_name : str
            Name of the new column.
        expression : pl.Expr or str
            polars expression to calculate the new column.
        """
        layer = assert_layer(layer, self.parent_viewer)
        feat = layer.molecules.features
        expr = widget_utils.norm_expr(expression)
        new_feat = feat.with_columns(expr.alias(column_name))
        layer.features = new_feat
        self.reset_choices()  # choices regarding to features need update
        return undo_callback(layer.feature_setter(feat, layer.colormap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def interpolate_spline_properties(
        self,
        layer: MoleculesLayerType,
        interpolation: int = 3,
        suffix: str = "_spl",
    ):
        """
        Add new features by interpolating spline local properties.

        Parameters
        ----------
        {layer}{interpolation}
        suffix : str, default "_spl"
            Suffix of the new feature column names.
        """
        layer = assert_layer(layer, self.parent_viewer)
        spl = _assert_source_spline_exists(layer)
        feat = layer.molecules.features
        anc = spl.anchors
        interp = utils.interp(
            anc, spl.props.loc.to_numpy(), order=interpolation, axis=0
        )
        pos_nm = feat[Mole.position].to_numpy()
        values = interp(spl.y_to_position(pos_nm).clip(anc.min(), anc.max()))
        layer.molecules = layer.molecules.with_features(
            [
                pl.Series(f"{c}{suffix}", values[:, i])
                for i, c in enumerate(spl.props.loc.columns)
            ]
        )
        return undo_callback(layer.feature_setter(feat, layer.colormap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def calculate_lattice_structure(
        self,
        layer: MoleculesLayerType,
        props: Annotated[list[str], {"widget_type": CheckBoxes, "choices": cylmeasure.LatticeParameters.choices()}] = ("spacing",),
    ):  # fmt: skip
        """
        Calculate lattice structures and store the results as new feature columns.

        Parameters
        ----------
        {layer}
        props : list of str, optional
            Properties to calculate.
        """
        layer = assert_layer(layer, self.parent_viewer)
        spl = _assert_source_spline_exists(layer)
        mole = layer.molecules
        feat = mole.features

        def _calculate(p: str):
            return cylmeasure.LatticeParameters(p).calculate(mole, spl)

        layer.molecules = layer.molecules.with_features([_calculate(p) for p in props])
        self.reset_choices()  # choices regarding of features need update
        return undo_callback(layer.feature_setter(feat))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def convolve_feature(
        self,
        layer: MoleculesLayerType,
        target: Annotated[str, {"choices": _choice_getter("convolve_feature", dtype_kind="uifb")}],
        method: Literal["mean", "max", "min", "median"] = "mean",
        footprint: Annotated[Any, {"widget_type": KernelEdit}] = [[0, 1, 0], [1, 1, 1], [0, 1, 0]],
    ):  # fmt: skip
        """
        Run a convolution on the lattice.

        The convolution is similar to that in the context of image analysis, except for
        the cylindric boundary. During the convolution, the edges will not be considered,
        i.e., NaN value will be ignored and convolution will be the convolution of valid
        regions.

        Parameters
        ----------
        {layer}
        method : str
            Convolution method.
        {target}{footprint}
        """
        from cylindra import cylfilters

        layer = assert_layer(layer, self.parent_viewer)
        utils.assert_column_exists(layer.molecules.features, target)
        feat, cmap_info = layer.molecules.features, layer.colormap_info
        nrise = _assert_source_spline_exists(layer).nrise()
        out = cylfilters.run_filter(
            layer.molecules.features, footprint, target, nrise, method
        )
        feature_name = f"{target}_{method}"
        layer.molecules = layer.molecules.with_features(out.alias(feature_name))
        self.reset_choices()
        match layer.colormap_info:
            case str(color):
                layer.face_color = color
            case info:
                layer.set_colormap(feature_name, info.clim, info.cmap)
        return undo_callback(layer.feature_setter(feat, cmap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def count_neighbors(
        self,
        layer: MoleculesLayerType,
        footprint: Annotated[Any, {"widget_type": KernelEdit}] = [[0, 1, 0], [1, 0, 1], [0, 1, 0]],
        column_name: str = "neighbor_count",
    ):  # fmt: skip
        """
        Count the number of neighbors for each molecules.

        Parameters
        ----------
        {layer}{footprint}
        column_name : str
            Name of the new column that stores the number of counts.
        """
        from cylindra import cylfilters

        layer = assert_layer(layer, self.parent_viewer)
        feat, cmap_info = layer.molecules.features, layer.colormap_info
        nrise = _assert_source_spline_exists(layer).nrise()
        out = cylfilters.count_neighbors(layer.molecules.features, footprint, nrise)
        layer.molecules = layer.molecules.with_features(out.alias(column_name))
        self.reset_choices()
        return undo_callback(layer.feature_setter(feat, cmap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def distance_from_spline(
        self,
        layer: MoleculesLayerType,
        spline: Annotated[int, {"choices": _get_splines}],
        column_name: str = "distance",
        interval: nm = 1.0,
    ):
        """
        Add a new column that stores the shortest distance from the given spline.

        Parameters
        ----------
        {layer}{spline}
        interval: nm, default 1.0
            Sampling interval along the spline. Note that small value will increase the
            memory usage and computation time.
        """
        spl = self.tomogram.splines[spline]
        layer = assert_layer(layer, self.parent_viewer)
        if interval <= 0:
            raise ValueError("`precision` must be positive.")
        feat, cmap_info = layer.molecules.features, layer.colormap_info
        npartitions = utils.ceilint(spl.length() / interval)
        sample_points = spl.map(np.linspace(0, 1, npartitions))
        dist = utils.distance_matrix(layer.molecules.pos, sample_points)
        dist_min = pl.Series(column_name, np.min(dist, axis=1))
        layer.molecules = layer.molecules.with_features(dist_min)
        return undo_callback(layer.feature_setter(feat, cmap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def binarize_feature(
        self,
        layer: MoleculesLayerType,
        target: Annotated[str, {"choices": _choice_getter("binarize_feature", dtype_kind="uif")}],
        threshold: Annotated[float, {"widget_type": "FloatSlider"}] = 0.0,
        larger_true: bool = True,
        suffix: str = "_binarize",
    ):  # fmt: skip
        """
        Binarization of a layer feature by thresholding.

        Parameters
        ----------
        {layer}{target}
        threshold : float, optional
            Threshold value used for binarization.
        larger_true : bool, optional
            If true, values larger than `threshold` will be True.
        suffix : str, default "_binarize"
            Suffix of the new feature column name.
        """
        from cylindra import cylfilters

        layer = assert_layer(layer, self.parent_viewer)
        utils.assert_column_exists(layer.molecules.features, target)
        if suffix == "":
            raise ValueError("`suffix` cannot be empty.")
        feat, cmap_info = layer.molecules.features, layer.colormap_info
        ser = cylfilters.binarize(layer.molecules.features, threshold, target)
        if not larger_true:
            ser = -ser
        feature_name = f"{target}{suffix}"
        layer.molecules = layer.molecules.with_features(
            ser.alias(feature_name).cast(pl.Boolean)
        )
        self.reset_choices()
        layer.set_colormap(feature_name, (0, 1), {0: "#A5A5A5", 1: "#FF0000"})
        return undo_callback(layer.feature_setter(feat, cmap_info))

    @set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
    def label_feature_clusters(
        self,
        layer: MoleculesLayerType,
        target: Annotated[str, {"choices": _choice_getter("label_feature_clusters", dtype_kind="b")}],
        suffix: str = "_label",
    ):  # fmt: skip
        """
        Label a binarized feature column based on the molecules structure.

        This method does the similar task as `scipy.ndimage.label`, where the isolated
        "islands" of True values will be labeled by position integers.

        Parameters
        ----------
        {layer}{target}
        suffix : str, default "_binarize"
            Suffix of the new feature column name.
        """
        from napari.utils.colormaps import label_colormap

        from cylindra import cylfilters

        layer = assert_layer(layer, self.parent_viewer)
        utils.assert_column_exists(layer.molecules.features, target)
        if suffix == "":
            raise ValueError("`suffix` cannot be empty.")
        feat, cmap_info = layer.molecules.features, layer.colormap_info
        nrise = _assert_source_spline_exists(layer).nrise()
        out = cylfilters.label(layer.molecules.features, target, nrise)
        feature_name = f"{target}{suffix}"
        layer.molecules = layer.molecules.with_features(out.alias(feature_name))
        self.reset_choices()
        label_max = int(out.max())
        cmap = label_colormap(label_max, seed=0.9414)
        layer.set_colormap(feature_name, (0, label_max), cmap)
        return undo_callback(layer.feature_setter(feat, cmap_info))

    @set_design(text="Analyze region properties", location=_sw.MoleculesMenu.Features)
    def regionprops_features(
        self,
        layer: MoleculesLayerType,
        target: Annotated[str, {"choices": _choice_getter("regionprops_features", dtype_kind="uif")}],
        label: Annotated[str, {"choices": _choice_getter("regionprops_features", dtype_kind="ui")}],
        properties: Annotated[list[str], {"choices": cylmeasure.RegionProfiler.CHOICES, "widget_type": CheckBoxes}] = ("area", "mean"),
    ):  # fmt: skip
        """
        Analyze region properties using another feature column as the labels.

        For instance, if the target data is [0, 1, 2, 3, 4] and the labels are [0, 1, 1, 2, 2],
        the the property "mean" will be [1.5, 3.5]. For some properties such as "length" and
        "width", the monomer connection will be considered.

        Parameters
        ----------
        {layer}{target}
        label: str
            The feature name that will be used as the labels.
        properties : list of str
            Properties to calculate.
        """
        from magicclass.ext.polars import DataFrameView

        layer = assert_layer(layer, self.parent_viewer)
        utils.assert_column_exists(
            layer.molecules.features, [target, label, Mole.nth, Mole.pf]
        )
        spl = _assert_source_spline_exists(layer)
        reg = cylmeasure.RegionProfiler.from_components(
            layer.molecules, spl, target, label
        )
        df = reg.calculate(properties)
        view = DataFrameView(value=df)
        dock = self.parent_viewer.window.add_dock_widget(view, name="Region properties")
        dock.setFloating(True)
        return undo_callback(dock.close).with_redo(dock.show)

    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
    #   Non-GUI methods
    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

    @nogui
    @do_not_record
    def add_molecules(
        self,
        molecules: Molecules,
        name: "str | None" = None,
        source: "BaseComponent | None" = None,
        metadata: "dict[str, Any]" = {},
        cmap=None,
        **kwargs,
    ) -> MoleculesLayer:
        """Add molecules as a points layer to the viewer."""
        return add_molecules(
            self.parent_viewer,
            molecules,
            name,
            source=source,
            metadata=metadata,
            cmap=cmap,
            **kwargs,
        )

    @nogui
    @do_not_record
    def get_loader(
        self,
        name: str,
        output_shape: "tuple[nm, nm, nm] | None" = None,
        order: int = 1,
    ) -> SubtomogramLoader:
        """
        Create a subtomogram loader using current tomogram and a molecules layer.

        Parameters
        ----------
        name : str, optional
            Name of the molecules layer.
        order : int, default 1
            Interpolation order of the subtomogram loader.
        """
        mole = self.mole_layers[name].molecules
        return self.tomogram.get_subtomogram_loader(mole, output_shape, order=order)

    def _init_widget_state(self, _=None):
        """Initialize widget state of spline control and local properties for new plot."""
        self.SplineControl._init_widget()
        self.LocalProperties._init_text()
        self.LocalProperties._init_plot()
        return None

    def _try_removing_layer(self, layer: Layer):
        try:
            self.parent_viewer.layers.remove(layer)
        except ValueError as e:
            _Logger.print(f"ValueError: {e}")
        return None

    def _try_removing_layers(self, layers: "Layer | list[Layer]"):
        if isinstance(layers, Layer):
            layers = [layers]
        for layer in layers:
            self._try_removing_layer(layer)

    def _add_layers_future(self, layers: "Layer | list[Layer]"):
        def future_func():
            nonlocal layers
            if isinstance(layers, Layer):
                layers = [layers]
            with self._pend_reset_choices():
                for layer in layers:
                    self.parent_viewer.add_layer(layer)
            self.reset_choices()

        return future_func

    def _undo_callback_for_layer(self, layer: "Layer | list[Layer]"):
        return (
            undo_callback(self._try_removing_layers)
            .with_args(layer)
            .with_redo(self._add_layers_future(layer))
        )

    @thread_worker.callback
    def _send_tomogram_to_viewer(
        self,
        tomo: CylTomogram,
        filt: "ImageFilter | None" = None,
        invert: bool = False,
    ):
        viewer = self.parent_viewer
        self._tomogram = tomo
        self.GeneralInfo._refer_tomogram(tomo)

        bin_size = max(x[0] for x in tomo.multiscaled)
        self._current_binsize = bin_size
        imgb = tomo.get_multiscale(bin_size)
        self._update_reference_image(imgb)

        # update viewer dimensions
        viewer.scale_bar.unit = imgb.scale_unit
        viewer.dims.axis_labels = ("z", "y", "x")
        change_viewer_focus(viewer, np.asarray(imgb.shape) / 2, imgb.scale.x)

        try:
            parts = tomo.source.parts
            if len(parts) > 2:
                _name = "…/" + Path(*parts[-2:]).as_posix()
            else:
                _name = tomo.source.as_posix()
        except Exception:
            _name = f"Tomogram<{hex(id(tomo))}>"
        _Logger.print_html(f"<h2>{_name}</h2>")

        self.macro.clear_undo_stack()
        self.Overview.layers.clear()
        with self._pend_reset_choices():
            self._init_widget_state()
            self._init_layers()

            # backward compatibility
            if isinstance(filt, bool):
                if filt:
                    filt = ImageFilter.Lowpass
                else:
                    filt = None
            if filt is not None and not isinstance(imgb, ip.LazyImgArray):
                self.filter_reference_image(method=filt)
            if invert:
                self.invert_image()
        self.reset_choices()
        self.GeneralInfo.project_desc.value = ""  # clear the project description
        self._need_save = False
        self._macro_image_load_offset = len(self.macro)

    def _update_reference_image(
        self,
        img: ip.ImgArray | ip.LazyImgArray,
        bin_size: int | None = None,
    ):
        viewer = self.parent_viewer
        if bin_size is None:
            bin_size = round(img.scale.x / self.tomogram.scale, 2)
        _is_lazy = isinstance(img, ip.LazyImgArray)
        self._reserved_layers.is_lazy = _is_lazy
        if _is_lazy:
            img = ip.zeros(img.shape, dtype=np.int8, like=img)
            img[0, [0, 0, 1, 1], [0, 1, 0, 1]] = 1
            img[1, [0, 0, 1, 1], [0, 1, 0, 1]] = 1
        tr = self.tomogram.multiscale_translation(bin_size)
        # update image layer
        if self._reserved_layers.image not in viewer.layers:
            self._reserved_layers.reset_image(img, tr)
            with self._pend_reset_choices():
                viewer.add_layer(self._reserved_layers.image)
        else:
            self._reserved_layers.update_image(img, tr)
        if self._reserved_layers.highlight in viewer.layers:
            viewer.layers.remove(self._reserved_layers.highlight)
        self._reserved_layers.image.bounding_box.visible = _is_lazy

        # update overview
        if _is_lazy:
            self.Overview.image = np.zeros((1, 1), dtype=np.float32)
        else:
            self.Overview.image = img.mean(axis="z")
        self.Overview.ylim = (0, img.shape[1])

    def _on_layer_removing(self, event: "Event"):
        # NOTE: To make recorded macro completely reproducible, removing molecules
        # from the viewer layer list must always be monitored.
        if self.parent_viewer is None:
            return  # may happen during cleanup
        layer: Layer = self.parent_viewer.layers[event.index]
        if (
            isinstance(layer, MoleculesLayer)
            and self.macro.active
            and layer.name != PREVIEW_LAYER_NAME  # ignore preview layer
        ):
            expr = mk.Mock(mk.symbol(self)).parent_viewer.layers[layer.name].expr
            undo = self._add_layers_future(layer)
            self.macro.append_with_undo(mk.Expr("del", [expr]), undo)
        return

    def _on_molecules_layer_renamed(self, event: "Event"):
        """When layer name is renamed, record `ui.parent_viewer["old"].name = "new"`"""
        layer: MoleculesLayer = event.source
        if layer._undo_renaming or not self.macro.active:
            return
        old_name = layer._old_name
        new_name = layer.name
        assert old_name is not None
        viewer_ = mk.Mock(mk.symbol(self)).parent_viewer
        expr = mk.Expr(mk.Head.assign, [viewer_.layers[old_name].name.expr, layer.name])
        return self.macro.append_with_undo(
            expr,
            undo=lambda: layer._rename(old_name),
            redo=lambda: layer._rename(new_name),
        )

    def _on_layer_inserted(self, event: "Event"):
        layer: Layer = event.value
        layer.events.name.connect(self.reset_choices)
        if isinstance(layer, MoleculesLayer):
            layer.events.name.connect(self._on_molecules_layer_renamed)

    def _disconnect_layerlist_events(self):
        viewer = self.parent_viewer
        viewer.layers.events.removing.disconnect(self._on_layer_removing)
        viewer.layers.events.inserted.disconnect(self._on_layer_inserted)

    def _init_layers(self):
        viewer = self.parent_viewer
        self._disconnect_layerlist_events()

        # remove all the molecules layers
        _layers_to_remove = list[str]()
        for layer in viewer.layers:
            if isinstance(layer, (MoleculesLayer, LandscapeSurface)):
                _layers_to_remove.append(layer.name)
            elif layer in (self._reserved_layers.prof, self._reserved_layers.work):
                _layers_to_remove.append(layer.name)

        with self._pend_reset_choices():
            for name in _layers_to_remove:
                layer: Layer = viewer.layers[name]
                viewer.layers.remove(layer)

            self._reserved_layers.init_layers()
            for layer in self._reserved_layers.to_be_removed:
                if layer in viewer.layers:
                    viewer.layers.remove(layer)
            viewer.add_layer(self._reserved_layers.prof)
            viewer.add_layer(self._reserved_layers.work)
        self.GlobalProperties._init_text()
        self.reset_choices()

        # Connect layer events.
        viewer.layers.events.removing.connect(self._on_layer_removing)
        viewer.layers.events.inserted.connect(self._on_layer_inserted)
        return None

    @contextmanager
    def _pend_reset_choices(self):
        """Temporarily disable the reset_choices method for better performance."""
        reset_choices = self.reset_choices
        self.reset_choices = lambda *_: None
        try:
            yield
        finally:
            self.reset_choices = reset_choices

    def _highlight_spline(self):
        i = self.SplineControl.num
        if i is None:
            return

        for layer in self.Overview.layers:
            if f"spline-{i}" in layer.name:
                layer.color = SplineColor.SELECTED
            else:
                layer.color = SplineColor.DEFAULT

        self._reserved_layers.highlight_spline(i)
        return None

    def _update_global_properties_in_widget(self):
        """Show global property values in widgets."""
        i = self.SplineControl.num
        if i is None:
            return
        self.GlobalProperties._set_text(self.splines[i])

    def _update_local_properties_in_widget(self, *, replot: bool = False):
        i = self.SplineControl.num
        tomo = self.tomogram
        if i is None or i >= len(tomo.splines):
            return
        j = self.SplineControl.pos
        spl = tomo.splines[i]
        if spl.props.has_loc([H.spacing, H.twist, H.npf, H.start]):
            self.LocalProperties._set_text(spl, j)
        else:
            self.LocalProperties._init_plot()
            self.LocalProperties._init_text()
        if replot:
            self.LocalProperties._plot_properties(spl)
        return None

    def _add_spline_to_images(self, spl: CylSpline, i: int):
        scale = self._reserved_layers.scale
        fit = self._reserved_layers.add_spline(i, spl)
        self.Overview.add_curve(
            fit[:, 2] / scale,
            fit[:, 1] / scale,
            color=SplineColor.DEFAULT,
            lw=2,
            name=f"spline-{i}",
            antialias=True,
        )
        self._set_orientation_marker(i)
        return None

    def _set_orientation_marker(self, idx: int):
        spl = self.tomogram.splines[idx]
        return self._reserved_layers.set_orientation(idx, spl.orientation)

    def _update_splines_in_images(self, _=None):
        """Refresh splines in overview canvas and napari canvas."""
        self.Overview.layers.clear()
        self._reserved_layers.prof.data = []
        scale = self._reserved_layers.scale
        for i, spl in enumerate(self.tomogram.splines):
            self._add_spline_to_images(spl, i)
            if spl._anchors is None:
                continue
            coords = spl.map()
            self.Overview.add_scatter(
                coords[:, 2] / scale,
                coords[:, 1] / scale,
                color=SplineColor.DEFAULT,
                symbol="x",
                lw=2,
                size=10,
                name=f"spline-{i}-anc",
            )
        self._highlight_spline()
        return None

    def _refer_spline_config(self, cfg: SplineConfig):
        """Update GUI states that are related to global variables."""
        fgui = get_function_gui(self.set_spline_props)
        fgui.npf.min, fgui.npf.max = cfg.npf_range.astuple()
        fgui.npf.value = int(cfg.npf_range.center)
        fgui.npf.value = None

        # update GUI default values
        fgui = get_function_gui(self.simulator.generate_molecules)
        fgui.spacing.value = cfg.spacing_range.center
        fgui.twist.value = cfg.twist_range.center
        fgui.npf.value = int(cfg.npf_range.center)

        for method in [self.map_monomers, self.map_monomers_with_extensions, self.map_along_pf, self.map_along_spline]:  # fmt: skip
            get_function_gui(method)["orientation"].value = cfg.clockwise

batch: CylindraBatchWidget property

Return the batch analyzer.

default_config: SplineConfig property writable

Default spline configuration.

logger property

The logger instance.

project_dir: Path | None property

The project directory.

project_metadata: dict[str, Any] property

The project metadata.

splines property

The spline list.

sub_viewer: napari.Viewer property

The sub-viewer for subtomogram averages.

tomogram: CylTomogram property

The current tomogram instance.

add_anchors(splines=None, interval=25.0, how='pack')

Add anchors to splines.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
interval nm

Interval (nm) between spline anchors. Please note that resetting interval will discard all the existing local properties.

25.0
how str

How to add anchors.

  • "pack": (x———x———x—) Pack anchors from the starting point of splines.
  • "equal": (x——x——x——x) Equally distribute anchors between the starting point and the end point of splines. Actual intervals will be smaller.
"pack"
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu)
def add_anchors(
    self,
    splines: SplinesType = None,
    interval: Annotated[nm, {"label": "Interval between anchors (nm)", "min": 1.0}] = 25.0,
    how: Literal["pack", "equal"] = "pack",
):  # fmt: skip
    """
    Add anchors to splines.

    Parameters
    ----------
    {splines}{interval}
    how : str, default "pack"
        How to add anchors.

        - "pack": (x———x———x—) Pack anchors from the starting point of splines.
        - "equal": (x——x——x——x) Equally distribute anchors between the starting
          point and the end point of splines. Actual intervals will be smaller.
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)
    with SplineTracker(widget=self, indices=splines) as tracker:
        match how:
            case "pack":
                tomo.make_anchors(splines, interval=interval)
            case "equal":
                tomo.make_anchors(splines, max_interval=interval)
            case _:  # pragma: no cover
                raise ValueError(f"Unknown method: {how}")

        self._update_splines_in_images()
        return tracker.as_undo_callback()

add_molecules(molecules, name=None, source=None, metadata={}, cmap=None, **kwargs)

Add molecules as a points layer to the viewer.

Source code in cylindra/widgets/main.py
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
@nogui
@do_not_record
def add_molecules(
    self,
    molecules: Molecules,
    name: "str | None" = None,
    source: "BaseComponent | None" = None,
    metadata: "dict[str, Any]" = {},
    cmap=None,
    **kwargs,
) -> MoleculesLayer:
    """Add molecules as a points layer to the viewer."""
    return add_molecules(
        self.parent_viewer,
        molecules,
        name,
        source=source,
        metadata=metadata,
        cmap=cmap,
        **kwargs,
    )

add_multiscale(bin_size=4)

Add a new multi-scale image of current tomogram.

Parameters:

Name Type Description Default
bin_size int

Bin size of the new image

4
Source code in cylindra/widgets/main.py
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
@set_design(text="Add multi-scale", location=_sw.ImageMenu)
@dask_thread_worker.with_progress(desc=lambda bin_size: f"Adding multiscale (bin = {bin_size})")  # fmt: skip
def add_multiscale(
    self,
    bin_size: Annotated[int, {"choices": list(range(2, 17))}] = 4,
):
    """
    Add a new multi-scale image of current tomogram.

    Parameters
    ----------
    bin_size : int, default 4
        Bin size of the new image
    """
    tomo = self.tomogram
    tomo.get_multiscale(binsize=bin_size, add=True)
    return thread_worker.callback(self.set_multiscale).with_args(bin_size)

align_to_polarity(orientation='MinusToPlus')

Align all the splines in the direction parallel to the cylinder polarity.

Parameters:

Name Type Description Default
orientation Ori

To which direction splines will be aligned.

Ori.MinusToPlus
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
def align_to_polarity(
    self, orientation: Literal["MinusToPlus", "PlusToMinus"] = "MinusToPlus"
):
    """
    Align all the splines in the direction parallel to the cylinder polarity.

    Parameters
    ----------
    orientation : Ori, default Ori.MinusToPlus
        To which direction splines will be aligned.
    """
    need_resample = self.SplineControl.need_resample
    _old_orientations = [spl.orientation for spl in self.tomogram.splines]
    self.tomogram.align_to_polarity(orientation=orientation)
    self._update_splines_in_images()
    self._init_widget_state()
    self.reset_choices()
    if need_resample:
        self.sample_subtomograms()
    for i in range(len(self.tomogram.splines)):
        self._set_orientation_marker(i)
    _new_orientations = [spl.orientation for spl in self.tomogram.splines]
    return (
        undo_callback(self._set_orientations)
        .with_args(_old_orientations, need_resample)
        .with_redo(lambda: self._set_orientations(_new_orientations))
    )

binarize_feature(layer, target, threshold=0.0, larger_true=True, suffix='_binarize')

Binarization of a layer feature by thresholding.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
target str

Target column name on which calculation will run.

required
threshold float

Threshold value used for binarization.

0.0
larger_true bool

If true, values larger than threshold will be True.

True
suffix str

Suffix of the new feature column name.

"_binarize"
Source code in cylindra/widgets/main.py
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def binarize_feature(
    self,
    layer: MoleculesLayerType,
    target: Annotated[str, {"choices": _choice_getter("binarize_feature", dtype_kind="uif")}],
    threshold: Annotated[float, {"widget_type": "FloatSlider"}] = 0.0,
    larger_true: bool = True,
    suffix: str = "_binarize",
):  # fmt: skip
    """
    Binarization of a layer feature by thresholding.

    Parameters
    ----------
    {layer}{target}
    threshold : float, optional
        Threshold value used for binarization.
    larger_true : bool, optional
        If true, values larger than `threshold` will be True.
    suffix : str, default "_binarize"
        Suffix of the new feature column name.
    """
    from cylindra import cylfilters

    layer = assert_layer(layer, self.parent_viewer)
    utils.assert_column_exists(layer.molecules.features, target)
    if suffix == "":
        raise ValueError("`suffix` cannot be empty.")
    feat, cmap_info = layer.molecules.features, layer.colormap_info
    ser = cylfilters.binarize(layer.molecules.features, threshold, target)
    if not larger_true:
        ser = -ser
    feature_name = f"{target}{suffix}"
    layer.molecules = layer.molecules.with_features(
        ser.alias(feature_name).cast(pl.Boolean)
    )
    self.reset_choices()
    layer.set_colormap(feature_name, (0, 1), {0: "#A5A5A5", 1: "#FF0000"})
    return undo_callback(layer.feature_setter(feat, cmap_info))

calculate_lattice_structure(layer, props=('spacing'))

Calculate lattice structures and store the results as new feature columns.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
props list of str

Properties to calculate.

('spacing')
Source code in cylindra/widgets/main.py
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def calculate_lattice_structure(
    self,
    layer: MoleculesLayerType,
    props: Annotated[list[str], {"widget_type": CheckBoxes, "choices": cylmeasure.LatticeParameters.choices()}] = ("spacing",),
):  # fmt: skip
    """
    Calculate lattice structures and store the results as new feature columns.

    Parameters
    ----------
    {layer}
    props : list of str, optional
        Properties to calculate.
    """
    layer = assert_layer(layer, self.parent_viewer)
    spl = _assert_source_spline_exists(layer)
    mole = layer.molecules
    feat = mole.features

    def _calculate(p: str):
        return cylmeasure.LatticeParameters(p).calculate(mole, spl)

    layer.molecules = layer.molecules.with_features([_calculate(p) for p in props])
    self.reset_choices()  # choices regarding of features need update
    return undo_callback(layer.feature_setter(feat))

calculate_molecule_features(layer, column_name, expression)

Calculate a new feature from the existing features.

This method is identical to running with_columns on the features dataframe as a polars.DataFrame. For example,

ui.calculate_molecule_features(layer, "Y", "pl.col('X') + 1")
is equivalent to
layer.features = layer.features.with_columns([(pl.col("X") + 1).alias("Y")])

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
column_name str

Name of the new column.

required
expression Expr or str

polars expression to calculate the new column.

required
Source code in cylindra/widgets/main.py
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
@confirm(
    text="Column already exists. Overwrite?",
    condition="column_name in layer.molecules.features.columns",
)
def calculate_molecule_features(
    self,
    layer: MoleculesLayerType,
    column_name: str,
    expression: PolarsExprStr,
):
    """
    Calculate a new feature from the existing features.

    This method is identical to running `with_columns` on the features dataframe
    as a `polars.DataFrame`. For example,
    >>> ui.calculate_molecule_features(layer, "Y", "pl.col('X') + 1")
    is equivalent to
    >>> layer.features = layer.features.with_columns([(pl.col("X") + 1).alias("Y")])

    Parameters
    ----------
    {layer}
    column_name : str
        Name of the new column.
    expression : pl.Expr or str
        polars expression to calculate the new column.
    """
    layer = assert_layer(layer, self.parent_viewer)
    feat = layer.molecules.features
    expr = widget_utils.norm_expr(expression)
    new_feat = feat.with_columns(expr.alias(column_name))
    layer.features = new_feat
    self.reset_choices()  # choices regarding to features need update
    return undo_callback(layer.feature_setter(feat, layer.colormap_info))

clear_all()

Clear all the splines and results.

Source code in cylindra/widgets/main.py
403
404
405
406
407
408
409
410
411
412
413
414
415
416
@set_design(icon="material-symbols:bomb", location=Toolbar)
@confirm(text="Are you sure to clear all?\nYou cannot undo this.")
@do_not_record
def clear_all(self):
    """Clear all the splines and results."""
    self.macro.clear_undo_stack()
    self.Overview.layers.clear()
    self.tomogram.splines.clear()
    self._init_widget_state()
    self._init_layers()
    del self.macro[self._macro_image_load_offset + 1 :]
    self._need_save = False
    self.reset_choices()
    return None

clear_current()

Clear current selection.

Source code in cylindra/widgets/main.py
392
393
394
395
396
397
398
399
400
401
@set_design(icon="solar:eraser-bold", location=Toolbar)
@confirm(text="Spline has properties. Are you sure to delete it?", condition=_confirm_delete)  # fmt: skip
@do_not_record(recursive=False)
def clear_current(self):
    """Clear current selection."""
    if self._reserved_layers.work.data.size > 0:
        self._reserved_layers.work.data = []
    else:
        self.delete_spline(self.SplineControl.num)
    return None

clip_spline(spline, lengths=(0.0, 0.0))

Clip selected spline at its edges by given lengths.

Parameters:

Name Type Description Default
spline int

The ID of spline to be clipped.

required
lengths tuple of float

The length in nm to be clipped at the start and end of the spline.

(0., 0.)
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu)
@bind_key("Ctrl+K, Ctrl+X")
def clip_spline(
    self,
    spline: Annotated[int, {"choices": _get_splines}],
    lengths: Annotated[tuple[nm, nm], {"options": {"min": -1000.0, "max": 1000.0, "step": 0.1, "label": "clip length (nm)"}}] = (0.0, 0.0),
):  # fmt: skip
    """
    Clip selected spline at its edges by given lengths.

    Parameters
    ----------
    spline : int
        The ID of spline to be clipped.
    lengths : tuple of float, default (0., 0.)
        The length in nm to be clipped at the start and end of the spline.
    """
    if spline is None:
        return
    spl = self.tomogram.splines[spline]
    _old_spl = spl.copy()
    length = spl.length()
    start, stop = np.array(lengths) / length
    self.tomogram.splines[spline] = spl.clip(start, 1 - stop)
    self._update_splines_in_images()
    # current layer will be removed. Select another layer.
    self.parent_viewer.layers.selection = {self._reserved_layers.work}

    @undo_callback
    def out():
        self.tomogram.splines[spline] = _old_spl
        self._update_splines_in_images()

    return out

concatenate_molecules(layers, name='Mole-concat')

Concatenate selected molecules and create a new ones.

Parameters:

Name Type Description Default
layers list of MoleculesLayer

All the points layers of molecules to be used.

required
name str

Name of the new molecules layer.

"Mole-concat"
Source code in cylindra/widgets/main.py
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
@set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
def concatenate_molecules(
    self,
    layers: MoleculesLayersType,
    name: str = "Mole-concat",
):  # fmt: skip
    """
    Concatenate selected molecules and create a new ones.

    Parameters
    ----------
    {layers}
    name : str, default "Mole-concat"
        Name of the new molecules layer.
    """
    layers = assert_list_of_layers(layers, self.parent_viewer)
    all_molecules = Molecules.concat([layer.molecules for layer in layers])
    points = add_molecules(self.parent_viewer, all_molecules, name=name)

    # logging
    layer_names = list[str]()
    for layer in layers:
        layer.visible = False
        layer_names.append(layer.name)

    _Logger.print_html("<code>concatenate_molecules</code>")
    _Logger.print("Concatenated:", ", ".join(layer_names))
    _Logger.print(f"{points.name!r}: n = {len(all_molecules)}")
    return self._undo_callback_for_layer(points)

convolve_feature(layer, target, method='mean', footprint=[[0, 1, 0], [1, 1, 1], [0, 1, 0]])

Run a convolution on the lattice.

The convolution is similar to that in the context of image analysis, except for the cylindric boundary. During the convolution, the edges will not be considered, i.e., NaN value will be ignored and convolution will be the convolution of valid regions.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
method str

Convolution method.

'mean'
target str

Target column name on which calculation will run.

required
footprint array - like

2D binary array that define the convolution kernel structure.

[[0, 1, 0], [1, 1, 1], [0, 1, 0]]
Source code in cylindra/widgets/main.py
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def convolve_feature(
    self,
    layer: MoleculesLayerType,
    target: Annotated[str, {"choices": _choice_getter("convolve_feature", dtype_kind="uifb")}],
    method: Literal["mean", "max", "min", "median"] = "mean",
    footprint: Annotated[Any, {"widget_type": KernelEdit}] = [[0, 1, 0], [1, 1, 1], [0, 1, 0]],
):  # fmt: skip
    """
    Run a convolution on the lattice.

    The convolution is similar to that in the context of image analysis, except for
    the cylindric boundary. During the convolution, the edges will not be considered,
    i.e., NaN value will be ignored and convolution will be the convolution of valid
    regions.

    Parameters
    ----------
    {layer}
    method : str
        Convolution method.
    {target}{footprint}
    """
    from cylindra import cylfilters

    layer = assert_layer(layer, self.parent_viewer)
    utils.assert_column_exists(layer.molecules.features, target)
    feat, cmap_info = layer.molecules.features, layer.colormap_info
    nrise = _assert_source_spline_exists(layer).nrise()
    out = cylfilters.run_filter(
        layer.molecules.features, footprint, target, nrise, method
    )
    feature_name = f"{target}_{method}"
    layer.molecules = layer.molecules.with_features(out.alias(feature_name))
    self.reset_choices()
    match layer.colormap_info:
        case str(color):
            layer.face_color = color
        case info:
            layer.set_colormap(feature_name, info.clim, info.cmap)
    return undo_callback(layer.feature_setter(feat, cmap_info))

copy_molecules_features(source, destinations, column, alias='')

Copy molecules features from one layer to another.

This method is useful when a layer feature (such as seam search result) should be shared by multiple molecules layers that were aligned in a different parameters.

Parameters:

Name Type Description Default
source MoleculesLayer

Layer whose features will be copied.

required
destinations MoleculesLayersType

To which layers the features should be copied.

required
column str

Column name of the feature to be copied.

required
alias str

If given, the copied feature will be renamed to this name.

''
Source code in cylindra/widgets/main.py
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
@set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
def copy_molecules_features(
    self,
    source: MoleculesLayerType,
    destinations: MoleculesLayersType,
    column: Annotated[str, {"choices": _choice_getter("copy_molecules_features")}],
    alias: str = "",
):  # fmt: skip
    """
    Copy molecules features from one layer to another.

    This method is useful when a layer feature (such as seam search result) should be
    shared by multiple molecules layers that were aligned in a different parameters.

    Parameters
    ----------
    source : MoleculesLayer
        Layer whose features will be copied.
    destinations : MoleculesLayersType
        To which layers the features should be copied.
    column : str
        Column name of the feature to be copied.
    alias : str, optional
        If given, the copied feature will be renamed to this name.
    """
    source = assert_layer(source, self.parent_viewer)
    destinations = assert_list_of_layers(destinations, self.parent_viewer)
    series = source.molecules.features[column]
    if alias:
        series = series.alias(alias)
    for dest in destinations:
        dest.molecules = dest.molecules.with_features([series])
    return None

copy_spline(i)

Make a copy of the current spline

Source code in cylindra/widgets/main.py
1108
1109
1110
1111
1112
1113
1114
1115
@set_design(text=capitalize, location=_sw.SplinesMenu)
def copy_spline(self, i: Annotated[int, {"bind": _get_spline_idx}]):
    """Make a copy of the current spline"""
    spl = self.tomogram.splines[i]
    self.tomogram.splines.append(spl.copy())
    self.reset_choices()
    self.SplineControl.num = len(self.tomogram.splines) - 1
    return undo_callback(self.delete_spline).with_args(-1)

copy_spline_new_config(i, npf_range=(11, 17), spacing_range=(3.9, 4.3), twist_range=(-1.0, 1.0), rise_range=(0.0, 45.0), rise_sign=-1, clockwise='MinusToPlus', thickness_inner=2.8, thickness_outer=2.8, fit_depth=49.0, fit_width=44.0, copy_props=False)

Make a copy of the current spline with a new configuration.

Source code in cylindra/widgets/main.py
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
@set_design(text="Copy spline (new config)", location=_sw.SplinesMenu)
def copy_spline_new_config(
    self,
    i: Annotated[int, {"bind": _get_spline_idx}],
    npf_range: Annotated[tuple[int, int], {"options": {"min": 2, "max": 100}}] = (11, 17),
    spacing_range: Annotated[tuple[nm, nm], {"options": {"step": 0.05}}] = (3.9, 4.3),
    twist_range: Annotated[tuple[float, float], {"options": {"min": -45.0, "max": 45.0, "step": 0.05}}] = (-1.0, 1.0),
    rise_range: Annotated[tuple[float, float], {"options": {"min": -45.0, "max": 45.0, "step": 0.1}}] = (0.0, 45.0),
    rise_sign: Literal[-1, 1] = -1,
    clockwise: Literal["PlusToMinus", "MinusToPlus"] = "MinusToPlus",
    thickness_inner: Annotated[nm, {"min": 0.0, "step": 0.1}] = 2.8,
    thickness_outer: Annotated[nm, {"min": 0.0, "step": 0.1}] = 2.8,
    fit_depth: Annotated[nm, {"min": 4.0, "step": 1}] = 49.0,
    fit_width: Annotated[nm, {"min": 4.0, "step": 1}] = 44.0,
    copy_props: bool = False,
):  # fmt: skip
    """Make a copy of the current spline with a new configuration."""
    config = locals()
    del config["i"], config["self"], config["copy_props"]
    spl = self.tomogram.splines[i]
    spl_new = spl.with_config(config, copy_props=copy_props)
    self.tomogram.splines.append(spl_new)
    self.reset_choices()
    self.SplineControl.num = len(self.tomogram.splines) - 1
    return undo_callback(self.delete_spline).with_args(-1)

count_neighbors(layer, footprint=[[0, 1, 0], [1, 0, 1], [0, 1, 0]], column_name='neighbor_count')

Count the number of neighbors for each molecules.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
footprint array - like

2D binary array that define the convolution kernel structure.

[[0, 1, 0], [1, 0, 1], [0, 1, 0]]
column_name str

Name of the new column that stores the number of counts.

'neighbor_count'
Source code in cylindra/widgets/main.py
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def count_neighbors(
    self,
    layer: MoleculesLayerType,
    footprint: Annotated[Any, {"widget_type": KernelEdit}] = [[0, 1, 0], [1, 0, 1], [0, 1, 0]],
    column_name: str = "neighbor_count",
):  # fmt: skip
    """
    Count the number of neighbors for each molecules.

    Parameters
    ----------
    {layer}{footprint}
    column_name : str
        Name of the new column that stores the number of counts.
    """
    from cylindra import cylfilters

    layer = assert_layer(layer, self.parent_viewer)
    feat, cmap_info = layer.molecules.features, layer.colormap_info
    nrise = _assert_source_spline_exists(layer).nrise()
    out = cylfilters.count_neighbors(layer.molecules.features, footprint, nrise)
    layer.molecules = layer.molecules.with_features(out.alias(column_name))
    self.reset_choices()
    return undo_callback(layer.feature_setter(feat, cmap_info))

delete_molecules(include='', exclude='', pattern='')

Delete molecules by the layer names.

Parameters:

Name Type Description Default
include str

Delete layers whose names contain this string.

''
exclude str

Delete layers whose names do not contain this string.

''
pattern str

String pattern to match the layer names. Use * as wildcard.

''
Source code in cylindra/widgets/main.py
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
@set_design(text="Delete molecule layers", location=_sw.MoleculesMenu)
@do_not_record(recursive=False)
def delete_molecules(
    self,
    include: str = "",
    exclude: str = "",
    pattern: str = "",
):
    """
    Delete molecules by the layer names.

    Parameters
    ----------
    include : str, optional
        Delete layers whose names contain this string.
    exclude : str, optional
        Delete layers whose names do not contain this string.
    pattern : str, optional
        String pattern to match the layer names. Use `*` as wildcard.
    """
    self.mole_layers.delete(include=include, exclude=exclude, pattern=pattern)

delete_spline(i)

Delete currently selected spline.

Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu)
@confirm(
    text="Spline has properties. Are you sure to delete it?",
    condition=_confirm_delete,
)
def delete_spline(self, i: Annotated[int, {"bind": _get_spline_idx}]):
    """Delete currently selected spline."""
    if i < 0:
        i = len(self.tomogram.splines) - 1
    spl = self.tomogram.splines.pop(i)
    self.reset_choices()

    # update layer
    features = self._reserved_layers.prof.features
    old_data = self._reserved_layers.prof.data
    self._reserved_layers.select_spline(i, len(self.tomogram.splines))
    self._update_splines_in_images()
    if self.SplineControl.need_resample and len(self.tomogram.splines) > 0:
        self.sample_subtomograms()

    @undo_callback
    def out():
        self.tomogram.splines.insert(i, spl)
        self._reserved_layers.prof.data = old_data
        self._reserved_layers.prof.features = features
        self._add_spline_to_images(spl, i)
        self._update_splines_in_images()
        self.reset_choices()

    return out

distance_from_spline(layer, spline, column_name='distance', interval=1.0)

Add a new column that stores the shortest distance from the given spline.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
spline int

Index of splines to be used.

required
interval nm

Sampling interval along the spline. Note that small value will increase the memory usage and computation time.

1.0
Source code in cylindra/widgets/main.py
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def distance_from_spline(
    self,
    layer: MoleculesLayerType,
    spline: Annotated[int, {"choices": _get_splines}],
    column_name: str = "distance",
    interval: nm = 1.0,
):
    """
    Add a new column that stores the shortest distance from the given spline.

    Parameters
    ----------
    {layer}{spline}
    interval: nm, default 1.0
        Sampling interval along the spline. Note that small value will increase the
        memory usage and computation time.
    """
    spl = self.tomogram.splines[spline]
    layer = assert_layer(layer, self.parent_viewer)
    if interval <= 0:
        raise ValueError("`precision` must be positive.")
    feat, cmap_info = layer.molecules.features, layer.colormap_info
    npartitions = utils.ceilint(spl.length() / interval)
    sample_points = spl.map(np.linspace(0, 1, npartitions))
    dist = utils.distance_matrix(layer.molecules.pos, sample_points)
    dist_min = pl.Series(column_name, np.min(dist, axis=1))
    layer.molecules = layer.molecules.with_features(dist_min)
    return undo_callback(layer.feature_setter(feat, cmap_info))

drop_molecules(layer, indices='', inherit_source=True)

Drop a subset of molecules from a molecules layer by indices.

Note that the indices start from 0. ui.drop_molecules(layer, [0, 2, 6]) will drop 0th, 2nd, and 6th molecules.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
indices str, int, slice or sequence of int or slice

A sequence of molecule indices to drop. You can use npf for the number of protofilaments and N for the number of molecules. slice is also allowed for dropping a range of indices. In GUI, this parameter must a string of comma-separated integers/slices (e.g. 3, N - 3, 1, slice(12, 12 + npf)).

''
inherit_source bool

If True and the input molecules layer has its spline source, the new layer will inherit it.

True
Source code in cylindra/widgets/main.py
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
@set_design(text=capitalize, location=_sw.MoleculesMenu)
def drop_molecules(
    self,
    layer: MoleculesLayerType,
    indices: Annotated[str | Sequence[int | slice], {"widget_type": IndexEdit}] = "",
    inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
):  # fmt: skip
    """
    Drop a subset of molecules from a molecules layer by indices.

    Note that the indices start from 0. `ui.drop_molecules(layer, [0, 2, 6])` will
    drop 0th, 2nd, and 6th molecules.

    Parameters
    ----------
    {layer}
    indices : str, int, slice or sequence of int or slice, optional
        A sequence of molecule indices to drop. You can use `npf` for the number
        of protofilaments and `N` for the number of molecules. `slice` is also
        allowed for dropping a range of indices. In GUI, this parameter must a
        string of comma-separated integers/slices (e.g. `3, N - 3`,
        `1, slice(12, 12 + npf)`).
    {inherit_source}
    """
    layer = assert_layer(layer, self.parent_viewer)
    mole = layer.molecules
    _to_drop = set[int]()
    if isinstance(indices, str):
        if spl := layer.source_spline:
            npf = spl.props.get_glob(H.npf, None)
        else:
            npf = None
        indices = IndexEdit.eval(indices, npf=npf, N=mole.count())
    for i in indices:
        if isinstance(i, slice):
            _to_drop.update(range(*i.indices(mole.count())))
        elif isinstance(i, (int, np.integer)):
            _to_drop.add(int(i))
        else:
            raise ValueError(f"Indices must be integers, got {type(i)!r}.")
    sl = np.array([i for i in range(mole.count()) if i not in _to_drop])
    out = mole.subset(sl)
    source = layer.source_component if inherit_source else None
    new = self.add_molecules(out, name=f"{layer.name}-Drop", source=source)
    return self._undo_callback_for_layer(new)

filter_molecules(layer, predicate, inherit_source=True)

Filter molecules by their features.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
predicate ExprStr

A polars-style filter predicate, such as pl.col("pf-id") == 3

required
inherit_source bool

If True and the input molecules layer has its spline source, the new layer will inherit it.

True
Source code in cylindra/widgets/main.py
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
@set_design(text=capitalize, location=_sw.MoleculesMenu)
def filter_molecules(
    self,
    layer: MoleculesLayerType,
    predicate: PolarsExprStr,
    inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
):
    """
    Filter molecules by their features.

    Parameters
    ----------
    {layer}
    predicate : ExprStr
        A polars-style filter predicate, such as `pl.col("pf-id") == 3`
    {inherit_source}
    """
    layer = assert_layer(layer, self.parent_viewer)
    mole = layer.molecules
    out = mole.filter(widget_utils.norm_expr(predicate))
    source = layer.source_component if inherit_source else None
    new = self.add_molecules(out, name=f"{layer.name}-Filt", source=source)
    return self._undo_callback_for_layer(new)

filter_reference_image(method=ImageFilter.Lowpass)

Apply filter to enhance contrast of the reference image.

Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.ImageMenu)
@dask_thread_worker.with_progress(desc=_pdesc.filter_image_fmt)
@do_not_record
def filter_reference_image(
    self,
    method: ImageFilter = ImageFilter.Lowpass,
):  # fmt: skip
    """Apply filter to enhance contrast of the reference image."""
    method = ImageFilter(method)
    if self.tomogram.is_dummy:
        return
    t0 = timer()
    with utils.set_gpu():
        img = self._reserved_layers.image_data
        overlap = [min(s, 32) for s in img.shape]
        _tiled = img.tiled(chunks=(224, 224, 224), overlap=overlap)
        sigma = 1.6 / self._reserved_layers.scale
        match method:
            case ImageFilter.Lowpass:
                img_filt = _tiled.lowpass_filter(cutoff=0.2)
            case ImageFilter.Gaussian:
                img_filt = _tiled.gaussian_filter(sigma=sigma, fourier=True)
            case ImageFilter.DoG:
                img_filt = _tiled.dog_filter(low_sigma=sigma, fourier=True)
            case ImageFilter.LoG:
                img_filt = _tiled.log_filter(sigma=sigma)
            case _:  # pragma: no cover
                raise ValueError(f"No method matches {method!r}")

    contrast_limits = fast_percentile(img_filt, [1, 99.9])

    @thread_worker.callback
    def _filter_reference_image_on_return():
        self._reserved_layers.image.data = img_filt
        self._reserved_layers.image.contrast_limits = contrast_limits
        proj = self._reserved_layers.image.data.mean(axis="z")
        self.Overview.image = proj
        self.Overview.contrast_limits = contrast_limits

    t0.toc()
    return _filter_reference_image_on_return

fit_splines(splines=None, max_interval=30, bin_size=1.0, err_max=1.0, degree_precision=0.5, edge_sigma=2.0, max_shift=5.0)

Fit splines to the cylinder by auto-correlation.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
max_interval nm

Maximum interval (nm) between spline anchors.

30
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1.0
err_max float

S.D. allowed for spline fitting. Larger value will result in smoother spline, i.e. fewer spline knots.

1.0
degree_precision float

Precision of xy-tilt degree in angular correlation.

0.5
edge_sigma bool

Check if cylindric structures are densely packed. Initial spline position must be "almost" fitted in dense mode.

2.0
max_shift nm

Maximum shift to be applied to each point of splines.

5.0
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
@thread_worker.with_progress(desc="Spline Fitting", total=_NSPLINES)
def fit_splines(
    self,
    splines: SplinesType = None,
    max_interval: Annotated[nm, {"label": "max interval (nm)"}] = 30,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1.0,
    err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 1.0,
    degree_precision: float = 0.5,
    edge_sigma: Annotated[Optional[nm], {"text": "Do not mask image", "label": "edge σ"}] = 2.0,
    max_shift: nm = 5.0,
):  # fmt: skip
    """
    Fit splines to the cylinder by auto-correlation.

    Parameters
    ----------
    {splines}{max_interval}{bin_size}{err_max}
    degree_precision : float, default 0.5
        Precision of xy-tilt degree in angular correlation.
    edge_sigma : bool, default 2.0
        Check if cylindric structures are densely packed. Initial spline position
        must be "almost" fitted in dense mode.
    max_shift : nm, default 5.0
        Maximum shift to be applied to each point of splines.
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)
    with SplineTracker(widget=self, indices=splines) as tracker:
        for i in splines:
            tomo.fit(
                i,
                max_interval=max_interval,
                binsize=bin_size,
                err_max=err_max,
                degree_precision=degree_precision,
                edge_sigma=edge_sigma,
                max_shift=max_shift,
            )
            yield thread_worker.callback(self._update_splines_in_images)

        @thread_worker.callback
        def out():
            self._init_widget_state()
            self._update_splines_in_images()
            return tracker.as_undo_callback()

    return out

fit_splines_by_centroid(splines=None, max_interval=30, bin_size=1.0, err_max=1.0, max_shift=5.0)

Fit splines to the cylinder by centroid of sub-volumes.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
max_interval nm

Maximum interval (nm) between spline anchors.

30
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1.0
err_max float

S.D. allowed for spline fitting. Larger value will result in smoother spline, i.e. fewer spline knots.

1.0
max_shift nm

Maximum shift to be applied to each point of splines.

5.0
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
@thread_worker.with_progress(desc="Spline Fitting", total=_NSPLINES)
def fit_splines_by_centroid(
    self,
    splines: SplinesType = None,
    max_interval: Annotated[nm, {"label": "max interval (nm)"}] = 30,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1.0,
    err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 1.0,
    max_shift: nm = 5.0,
):  # fmt: skip
    """
    Fit splines to the cylinder by centroid of sub-volumes.

    Parameters
    ----------
    {splines}{max_interval}{bin_size}{err_max}
    max_shift : nm, default 5.0
        Maximum shift to be applied to each point of splines.
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)
    with SplineTracker(widget=self, indices=splines) as tracker:
        for i in splines:
            tomo.fit_centroid(
                i,
                max_interval=max_interval,
                binsize=bin_size,
                err_max=err_max,
                max_shift=max_shift,
            )
            yield thread_worker.callback(self._update_splines_in_images)

        @thread_worker.callback
        def out():
            self._init_widget_state()
            self._update_splines_in_images()
            return tracker.as_undo_callback()

    return out

get_loader(name, output_shape=None, order=1)

Create a subtomogram loader using current tomogram and a molecules layer.

Parameters:

Name Type Description Default
name str

Name of the molecules layer.

required
order int

Interpolation order of the subtomogram loader.

1
Source code in cylindra/widgets/main.py
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
@nogui
@do_not_record
def get_loader(
    self,
    name: str,
    output_shape: "tuple[nm, nm, nm] | None" = None,
    order: int = 1,
) -> SubtomogramLoader:
    """
    Create a subtomogram loader using current tomogram and a molecules layer.

    Parameters
    ----------
    name : str, optional
        Name of the molecules layer.
    order : int, default 1
        Interpolation order of the subtomogram loader.
    """
    mole = self.mole_layers[name].molecules
    return self.tomogram.get_subtomogram_loader(mole, output_shape, order=order)

global_cft_analysis(splines=None, bin_size=1)

Determine cylindrical global structural parameters by Fourier transformation.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
Source code in cylindra/widgets/main.py
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
@set_design(text="Global CFT analysis", location=_sw.AnalysisMenu)
@thread_worker.with_progress(
    desc="Global Cylindric Fourier transform", total=_NSPLINES
)
def global_cft_analysis(
    self,
    splines: SplinesType = None,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
):  # fmt: skip
    """
    Determine cylindrical global structural parameters by Fourier transformation.

    Parameters
    ----------
    {splines}{bin_size}
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)

    with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
        for i in splines:
            spl = tomo.splines[i]
            if spl.radius is None:
                tomo.measure_radius(i=i)
            tomo.global_cft_params(i=i, binsize=bin_size)
            yield

        # show all in a table
        @thread_worker.callback
        def _global_cft_analysis_on_return():
            df = (
                pl.concat(
                    [tomo.splines[i].props.glob for i in splines],
                    how="vertical_relaxed",
                )
                .to_pandas()
                .transpose()
            )
            df.columns = [f"Spline-{i}" for i in range(len(df.columns))]
            self.sample_subtomograms()
            _Logger.print_table(df, precision=3)
            self._update_global_properties_in_widget()

            return tracker.as_undo_callback()

    return _global_cft_analysis_on_return

infer_polarity(splines=None, depth=40, bin_size=1)

Automatically detect the cylinder polarities.

This function uses Fourier vorticity to detect the polarities of the splines. The subtomogram at the center of the spline will be sampled in the cylindric coordinate and the power spectra in (radius, angle) space will be calculated. The peak position of the angle = nPF line scan will be used to detect the polarity of the spline.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
depth nm

Depth (length parallel to the spline tangent) of the subtomograms to be sampled.

40
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
@thread_worker.with_progress(desc="Auto-detecting polarities...", total=_NSPLINES)
def infer_polarity(
    self,
    splines: SplinesType = None,
    depth: Annotated[nm, {"min": 5.0, "max": 500.0, "step": 5.0}] = 40,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
):  # fmt: skip
    """
    Automatically detect the cylinder polarities.

    This function uses Fourier vorticity to detect the polarities of the splines.
    The subtomogram at the center of the spline will be sampled in the cylindric
    coordinate and the power spectra in (radius, angle) space will be calculated.
    The peak position of the `angle = nPF` line scan will be used to detect the
    polarity of the spline.

    Parameters
    ----------
    {splines}{depth}{bin_size}
    """
    tomo = self.tomogram
    _old_orientations = [spl.orientation for spl in self.tomogram.splines]
    for i in self._norm_splines(splines):
        tomo.infer_polarity(i=i, binsize=bin_size, depth=depth, update=True)
        yield
    _new_orientations = [spl.orientation for spl in self.tomogram.splines]

    @thread_worker.callback
    def _on_return():
        self._update_splines_in_images()
        for i in range(len(tomo.splines)):
            self._set_orientation_marker(i)

        self.SplineControl._update_canvas()
        return (
            undo_callback(self._set_orientations)
            .with_args(_old_orientations)
            .with_redo(lambda: self._set_orientations(_new_orientations))
        )

    return _on_return

interpolate_spline_properties(layer, interpolation=3, suffix='_spl')

Add new features by interpolating spline local properties.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
interpolation int

Interpolation order.

3
suffix str

Suffix of the new feature column names.

"_spl"
Source code in cylindra/widgets/main.py
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def interpolate_spline_properties(
    self,
    layer: MoleculesLayerType,
    interpolation: int = 3,
    suffix: str = "_spl",
):
    """
    Add new features by interpolating spline local properties.

    Parameters
    ----------
    {layer}{interpolation}
    suffix : str, default "_spl"
        Suffix of the new feature column names.
    """
    layer = assert_layer(layer, self.parent_viewer)
    spl = _assert_source_spline_exists(layer)
    feat = layer.molecules.features
    anc = spl.anchors
    interp = utils.interp(
        anc, spl.props.loc.to_numpy(), order=interpolation, axis=0
    )
    pos_nm = feat[Mole.position].to_numpy()
    values = interp(spl.y_to_position(pos_nm).clip(anc.min(), anc.max()))
    layer.molecules = layer.molecules.with_features(
        [
            pl.Series(f"{c}{suffix}", values[:, i])
            for i, c in enumerate(spl.props.loc.columns)
        ]
    )
    return undo_callback(layer.feature_setter(feat, layer.colormap_info))

invert_image()

Invert the intensity of the images.

Source code in cylindra/widgets/main.py
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
@thread_worker.with_progress(desc="Inverting image")
@set_design(text=capitalize, location=_sw.ImageMenu)
def invert_image(self):
    """Invert the intensity of the images."""
    t0 = timer()
    self.tomogram.invert()
    if self._reserved_layers.is_lazy:

        @thread_worker.callback
        def _invert_image_on_return():
            return undo_callback(self.invert_image)

    else:
        img_inv = -self._reserved_layers.image.data
        cmin, cmax = fast_percentile(img_inv, [1, 99.9])
        if cmin >= cmax:
            cmax = cmin + 1

        @thread_worker.callback
        def _invert_image_on_return():
            self._reserved_layers.image.data = img_inv
            self._reserved_layers.image.contrast_limits = (cmin, cmax)
            clow, chigh = self.Overview.contrast_limits
            self.Overview.image = -self.Overview.image
            self.Overview.contrast_limits = -chigh, -clow
            return undo_callback(self.invert_image)

    t0.toc()
    return _invert_image_on_return

invert_spline(spline=None)

Invert current displayed spline in place.

Parameters:

Name Type Description Default
spline int

ID of splines to be inverted.

None
Source code in cylindra/widgets/main.py
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
@set_design(text=capitalize, location=_sw.SplinesMenu.Orientation)
def invert_spline(self, spline: Annotated[int, {"bind": _get_spline_idx}] = None):
    """
    Invert current displayed spline **in place**.

    Parameters
    ----------
    spline : int, optional
        ID of splines to be inverted.
    """
    if spline is None:
        return
    spl = self.tomogram.splines[spline]
    self.tomogram.splines[spline] = spl.invert()
    self._update_splines_in_images()
    self.reset_choices()

    need_resample = self.SplineControl.need_resample
    self._init_widget_state()
    if need_resample:
        self.sample_subtomograms()
    self._set_orientation_marker(spline)
    return undo_callback(self.invert_spline).with_args(spline)

label_feature_clusters(layer, target, suffix='_label')

Label a binarized feature column based on the molecules structure.

This method does the similar task as scipy.ndimage.label, where the isolated "islands" of True values will be labeled by position integers.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
target str

Target column name on which calculation will run.

required
suffix str

Suffix of the new feature column name.

"_binarize"
Source code in cylindra/widgets/main.py
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
@set_design(text=capitalize, location=_sw.MoleculesMenu.Features)
def label_feature_clusters(
    self,
    layer: MoleculesLayerType,
    target: Annotated[str, {"choices": _choice_getter("label_feature_clusters", dtype_kind="b")}],
    suffix: str = "_label",
):  # fmt: skip
    """
    Label a binarized feature column based on the molecules structure.

    This method does the similar task as `scipy.ndimage.label`, where the isolated
    "islands" of True values will be labeled by position integers.

    Parameters
    ----------
    {layer}{target}
    suffix : str, default "_binarize"
        Suffix of the new feature column name.
    """
    from napari.utils.colormaps import label_colormap

    from cylindra import cylfilters

    layer = assert_layer(layer, self.parent_viewer)
    utils.assert_column_exists(layer.molecules.features, target)
    if suffix == "":
        raise ValueError("`suffix` cannot be empty.")
    feat, cmap_info = layer.molecules.features, layer.colormap_info
    nrise = _assert_source_spline_exists(layer).nrise()
    out = cylfilters.label(layer.molecules.features, target, nrise)
    feature_name = f"{target}{suffix}"
    layer.molecules = layer.molecules.with_features(out.alias(feature_name))
    self.reset_choices()
    label_max = int(out.max())
    cmap = label_colormap(label_max, seed=0.9414)
    layer.set_colormap(feature_name, (0, label_max), cmap)
    return undo_callback(layer.feature_setter(feat, cmap_info))

load_molecules(paths)

Load molecules from a csv file.

Source code in cylindra/widgets/main.py
623
624
625
626
627
628
629
630
631
632
@set_design(text=capitalize, location=_sw.FileMenu)
def load_molecules(self, paths: Path.Multiple[FileFilter.MOLE]):
    """Load molecules from a csv file."""
    if isinstance(paths, (str, Path, bytes)):
        paths = [paths]
    moles = [Molecules.from_file(path) for path in paths]
    for mole, path in zip(moles, paths, strict=False):
        name = Path(path).stem
        add_molecules(self.parent_viewer, mole, name)
    return None

load_project(path, filter=ImageFilter.Lowpass, read_image=True, update_config=False)

Load a project file (project.json, tar file or zip file).

Parameters:

Name Type Description Default
path path - like or CylindraProject

Path to the project file, or the project directory that contains a project file, or a CylindraProject object.

required
filter ImageFilter

Filter to be applied to the reference image. This does not affect the image data itself.

  • Lowpass: butterworth low-pass filter.
  • Gaussian: Gaussian blur.
  • DoG: difference of Gaussian.
  • LoG: Laplacian of Gaussian.
Lowpass
read_image bool

Whether to read image data from the project directory. If false, image data will be memory-mapped and will not be shown in the viewer. Unchecking this is useful to decrease loading time.

True
update_config bool

Whether to update the default spline configuration with the one described in the project.

False
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.FileMenu)
@thread_worker.with_progress(desc="Reading project", total=0)
@confirm(text="You may have unsaved data. Open a new project?", condition="self._need_save")  # fmt: skip
@do_not_record
@bind_key("Ctrl+K, Ctrl+P")
def load_project(
    self,
    path: Path.Read[FileFilter.PROJECT],
    filter: ImageFilter | None = ImageFilter.Lowpass,
    read_image: Annotated[bool, {"label": "read image data"}] = True,
    update_config: bool = False,
):
    """
    Load a project file (project.json, tar file or zip file).

    Parameters
    ----------
    path : path-like or CylindraProject
        Path to the project file, or the project directory that contains a project
        file, or a CylindraProject object.
    {filter}
    read_image : bool, default True
        Whether to read image data from the project directory. If false, image data
        will be memory-mapped and will not be shown in the viewer. Unchecking this
        is useful to decrease loading time.
    update_config : bool, default False
        Whether to update the default spline configuration with the one described
        in the project.
    """
    if isinstance(path, CylindraProject):
        project = path
        project_path = project.project_path
    else:
        project = CylindraProject.from_file(path)
        project_path = project.project_path
    _Logger.print_html(
        f"<code>ui.load_project('{Path(project_path).as_posix()}', "
        f"filter={str(filter)!r}, {read_image=}, {update_config=})</code>"
    )
    if project_path is not None:
        _Logger.print(f"Project loaded: {project_path.as_posix()}")
        self._project_dir = project_path
    yield from project._to_gui(
        self,
        filter=filter,
        read_image=read_image,
        update_config=update_config,
    )

load_project_for_reanalysis(path)

Load a project file to re-analyze the data.

This method will extract the first manual operations from a project file and run them. This is useful when you want to re-analyze the data with a different parameter set, or when there were some improvements in cylindra.

Source code in cylindra/widgets/main.py
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
@set_design(text="Re-analyze project", location=_sw.AnalysisMenu)
@do_not_record
@bind_key("Ctrl+K, Ctrl+L")
def load_project_for_reanalysis(self, path: Path.Read[FileFilter.PROJECT]):
    """
    Load a project file to re-analyze the data.

    This method will extract the first manual operations from a project file and
    run them. This is useful when you want to re-analyze the data with a different
    parameter set, or when there were some improvements in cylindra.
    """
    macro = self._get_reanalysis_macro(path)
    macro.eval({mk.symbol(self): self})
    return self.macro.clear_undo_stack()

load_splines(paths)

Load splines from a list of json paths.

Parameters:

Name Type Description Default
paths list of path-like objects

Paths to json files that describe spline parameters in the correct format.

required
Source code in cylindra/widgets/main.py
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
@set_design(text=capitalize, location=_sw.FileMenu)
def load_splines(self, paths: Path.Multiple[FileFilter.JSON]):
    """
    Load splines from a list of json paths.

    Parameters
    ----------
    paths : list of path-like objects
        Paths to json files that describe spline parameters in the correct format.
    """
    if isinstance(paths, (str, Path, bytes)):
        paths = [paths]
    splines = [CylSpline.from_json(path) for path in paths]
    self.tomogram.splines.extend(splines)
    self._update_splines_in_images()
    self.reset_choices()
    return None

local_cft_analysis(splines=None, interval=None, depth=50.0, bin_size=1, radius='global', update_glob=False)

Determine local lattice parameters by local cylindric Fourier transformation.

This method will sample subtomograms at given intervals and calculate the power spectra in a cylindrical coordinate. The peak position of the power spectra will used to determine the lattice parameters. Note that if the interval differs from the current spline anchors, the old local properties will be dropped.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
interval nm

Interval (nm) between spline anchors. Please note that resetting interval will discard all the existing local properties.

None
depth nm

Depth (length parallel to the spline tangent) of the subtomograms to be sampled.

50.0
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
radius str

If "local", use the local radius for the analysis. If "global", use the global radius.

"global"
update_glob bool

If true, also update the global property to the mean of local properties.

False
Source code in cylindra/widgets/main.py
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
@set_design(text="Local CFT analysis", location=_sw.AnalysisMenu)
@thread_worker.with_progress(desc="Local Cylindric Fourier transform", total=_NSPLINES)  # fmt: skip
def local_cft_analysis(
    self,
    splines: SplinesType = None,
    interval: _Interval = None,
    depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    radius: Literal["local", "global"] = "global",
    update_glob: Annotated[bool, {"text": "Also update the global properties"}] = False,
):  # fmt: skip
    """
    Determine local lattice parameters by local cylindric Fourier transformation.

    This method will sample subtomograms at given intervals and calculate the power
    spectra in a cylindrical coordinate. The peak position of the power spectra will
    used to determine the lattice parameters. Note that if the interval differs from
    the current spline anchors, the old local properties will be dropped.

    Parameters
    ----------
    {splines}{interval}{depth}{bin_size}
    radius : str, default "global"
        If "local", use the local radius for the analysis. If "global", use the
        global radius.
    {update_glob}
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)

    # first check radius
    match radius:
        case "global":
            for i in splines:
                if tomo.splines[i].radius is None:
                    raise ValueError(
                        f"Global Radius of {i}-th spline is not measured yet. Please "
                        "measure the radius first from `Analysis > Radius`."
                    )
        case "local":
            for i in splines:
                if not tomo.splines[i].props.has_loc(H.radius):
                    raise ValueError(
                        f"Local Radius of {i}-th spline is not measured yet. Please "
                        "measure the radius first from `Analysis > Radius`."
                    )
            if interval is not None:
                raise ValueError(
                    "With `interval`, local radius values will be dropped. Please "
                    "set `radius='global'` or `interval=None`."
                )
        case _:
            raise ValueError(f"radius must be 'local' or 'global', got {radius!r}.")

    @thread_worker.callback
    def _local_cft_analysis_on_yield(i: int):
        self._update_splines_in_images()
        if i == self.SplineControl.num:
            self.sample_subtomograms()

    with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
        for i in splines:
            if interval is not None:
                tomo.make_anchors(i=i, interval=interval)
            tomo.local_cft_params(
                i=i,
                depth=depth,
                binsize=bin_size,
                radius=radius,
                update_glob=update_glob,
            )
            yield _local_cft_analysis_on_yield.with_args(i)
        return tracker.as_undo_callback()

map_along_pf(spline, molecule_interval="col('spacing')", offsets=None, orientation=None, prefix='PF')

Map molecules along the line of a protofilament.

Parameters:

Name Type Description Default
spline int

Index of splines to be used.

required
molecule_interval nm

Interval (nm) between molecules. col is available in this namespace to refer to the spline global properties. For example, col('spacing') * 2 means twice the spacing of the spline.

"col('spacing')"
offsets (float, float)

Offset values that will be used to define molecule positions.

None
orientation (None, PlusToMinus, MinusToPlus)

Orientation of molecules' y-axis. If none, use the current spline orientation as is.

None
prefix str

Prefix of the new molecules layer(s).

'PF'
Source code in cylindra/widgets/main.py
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
@set_design(text="Map alogn PF", location=_sw.MoleculesMenu.FromToSpline)
def map_along_pf(
    self,
    spline: Annotated[int, {"choices": _get_splines}],
    molecule_interval: PolarsExprStrOrScalar = "col('spacing')",
    offsets: _OffsetType = None,
    orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
    prefix: str = "PF",
):  # fmt: skip
    """
    Map molecules along the line of a protofilament.

    Parameters
    ----------
    {spline}{molecule_interval}{offsets}{orientation}{prefix}
    """
    tomo = self.tomogram
    interv_expr = widget_utils.norm_scalar_expr(molecule_interval)
    spl = tomo.splines[spline]
    _Logger.print_html("<code>map_along_PF</code>")
    mol = tomo.map_pf_line(
        i=spline,
        interval=spl.props.get_glob(interv_expr),
        offsets=normalize_offsets(offsets, spl),
        orientation=orientation,
    )
    _name = f"{prefix}-{spline}"
    layer = self.add_molecules(mol, _name, source=spl)
    _Logger.print(f"{_name!r}: n = {len(mol)}")
    return self._undo_callback_for_layer(layer)

map_along_spline(splines=None, molecule_interval="col('spacing')", orientation=None, rotate_molecules=True, prefix='Center')

Map molecules along splines. Each molecule is rotated by skewing.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
molecule_interval nm

Interval (nm) between molecules. col is available in this namespace to refer to the spline global properties. For example, col('spacing') * 2 means twice the spacing of the spline.

"col('spacing')"
orientation (None, PlusToMinus, MinusToPlus)

Orientation of molecules' y-axis. If none, use the current spline orientation as is.

None
rotate_molecules bool

If True, rotate molecules by the "twist" parameter of each spline.

True
prefix str

Prefix of the new molecules layer(s).

'Center'
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
def map_along_spline(
    self,
    splines: SplinesType = None,
    molecule_interval: PolarsExprStrOrScalar = "col('spacing')",
    orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
    rotate_molecules: bool = True,
    prefix: str = "Center",
):  # fmt: skip
    """
    Map molecules along splines. Each molecule is rotated by skewing.

    Parameters
    ----------
    {splines}{molecule_interval}{orientation}
    rotate_molecules : bool, default True
        If True, rotate molecules by the "twist" parameter of each spline.
    {prefix}
    """
    tomo = self.tomogram
    interv_expr = widget_utils.norm_scalar_expr(molecule_interval)
    splines = self._norm_splines(splines)
    _Logger.print_html("<code>map_along_spline</code>")
    _added_layers = list[MoleculesLayer]()
    for idx in splines:
        spl = tomo.splines[idx]
        interv = spl.props.get_glob(interv_expr)
        mole = tomo.map_centers(
            i=idx,
            interval=interv,
            orientation=orientation,
            rotate_molecules=rotate_molecules,
        )
        _name = f"{prefix}-{idx}"
        layer = self.add_molecules(mole, _name, source=spl)
        _added_layers.append(layer)
        _Logger.print(f"{_name!r}: n = {mole.count()}")
    return self._undo_callback_for_layer(_added_layers)

map_monomers(splines=None, orientation=None, offsets=None, radius=None, extensions=(0, 0), prefix='Mole')

Map monomers as a regular cylindric grid assembly.

This method uses the spline global properties.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
orientation (None, PlusToMinus, MinusToPlus)

Orientation of molecules' y-axis. If none, use the current spline orientation as is.

None
offsets (float, float)

Offset values that will be used to define molecule positions.

None
radius nm

Radius of the cylinder to position monomers.

None
extensions (int, int)

Number of molecules to extend. Should be a tuple of (prepend, append). Negative values will remove molecules.

(0, 0)
prefix str

Prefix of the new molecules layer(s).

'Mole'
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
@bind_key("M")
@thread_worker.with_progress(desc="Mapping monomers", total=_NSPLINES)
def map_monomers(
    self,
    splines: SplinesType = None,
    orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
    offsets: _OffsetType = None,
    radius: Optional[nm] = None,
    extensions: Annotated[tuple[int, int], {"options": {"min": -100}}] = (0, 0),
    prefix: str = "Mole",
):  # fmt: skip
    """
    Map monomers as a regular cylindric grid assembly.

    This method uses the spline global properties.

    Parameters
    ----------
    {splines}{orientation}{offsets}
    radius : nm, optional
        Radius of the cylinder to position monomers.
    extensions : (int, int), default (0, 0)
        Number of molecules to extend. Should be a tuple of (prepend, append).
        Negative values will remove molecules.
    {prefix}
    """
    tomo = self.tomogram

    _Logger.print_html("<code>map_monomers</code>")
    _added_layers = list[MoleculesLayer]()

    @thread_worker.callback
    def _add_molecules(mol: Molecules, name: str, spl: CylSpline):
        layer = self.add_molecules(mol, name, source=spl)
        _added_layers.append(layer)
        _Logger.print(f"{name!r}: n = {len(mol)}")

    for i in self._norm_splines(splines):
        spl = tomo.splines[i]
        mol = tomo.map_monomers(
            i=i,
            orientation=orientation,
            offsets=normalize_offsets(offsets, spl),
            radius=normalize_radius(radius, spl),
            extensions=extensions,
        )

        cb = _add_molecules.with_args(mol, f"{prefix}-{i}", spl)
        yield cb
        cb.await_call()

    return self._undo_callback_for_layer(_added_layers)

map_monomers_with_extensions(spline, n_extend={}, orientation=None, offsets=None, radius=None, prefix='Mole')

Map monomers as a regular cylindric grid assembly.

This method uses the spline global properties.

Parameters:

Name Type Description Default
spline int

Index of splines to be used.

required
n_extend dict[int, (int, int)]

Number of molecules to extend. Should be mapping from the PF index to the (prepend, append) number of molecules to add. Remove molecules if negative values are given.

{}
orientation (None, PlusToMinus, MinusToPlus)

Orientation of molecules' y-axis. If none, use the current spline orientation as is.

None
offsets (float, float)

Offset values that will be used to define molecule positions.

None
radius nm

Radius of the cylinder to position monomers.

None
prefix str

Prefix of the new molecules layer(s).

'Mole'
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
def map_monomers_with_extensions(
    self,
    spline: Annotated[int, {"choices": _get_splines}],
    n_extend: Annotated[dict[int, tuple[int, int]], {"label": "prepend/append", "widget_type": ProtofilamentEdit}] = {},
    orientation: Literal[None, "PlusToMinus", "MinusToPlus"] = None,
    offsets: _OffsetType = None,
    radius: Optional[nm] = None,
    prefix: str = "Mole",
):  # fmt: skip
    """
    Map monomers as a regular cylindric grid assembly.

    This method uses the spline global properties.

    Parameters
    ----------
    {spline}
    n_extend : dict[int, (int, int)]
        Number of molecules to extend. Should be mapping from the PF index to the (prepend,
        append) number of molecules to add. Remove molecules if negative values are given.
    {orientation}{offsets}
    radius : nm, optional
        Radius of the cylinder to position monomers.
    {prefix}
    """
    tomo = self.tomogram
    spl = tomo.splines[spline]
    coords = widget_utils.coordinates_with_extensions(spl, n_extend)
    mole = tomo.map_on_grid(
        i=spline,
        coords=coords,
        orientation=orientation,
        offsets=normalize_offsets(offsets, spl),
        radius=normalize_radius(radius, spl),
    )
    layer = self.add_molecules(mole, f"{prefix}-{spline}", source=spl)
    return self._undo_callback_for_layer(layer)

measure_local_radius(splines=None, interval=None, depth=50.0, bin_size=1, min_radius=1.0, max_radius=100.0, update_glob=True)

Measure radius for each local region along splines.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
interval nm

Interval (nm) between spline anchors. Please note that resetting interval will discard all the existing local properties.

None
depth nm

Depth (length parallel to the spline tangent) of the subtomograms to be sampled.

50.0
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
min_radius nm

Minimum possible radius in nm.

1.0
max_radius nm

Maximum possible radius in nm.

100.0
update_glob bool

If true, also update the global property to the mean of local properties.

True
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
@thread_worker.with_progress(desc="Measuring local radii", total=_NSPLINES)
def measure_local_radius(
    self,
    splines: SplinesType = None,
    interval: _Interval = None,
    depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    min_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 1.0,
    max_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 100.0,
    update_glob: Annotated[bool, {"text": "Also update the global radius"}] = True,
):  # fmt: skip
    """
    Measure radius for each local region along splines.

    Parameters
    ----------
    {splines}{interval}{depth}{bin_size}{min_radius}{max_radius}{update_glob}
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)

    @thread_worker.callback
    def _on_yield():
        self._update_local_properties_in_widget(replot=True)

    with SplineTracker(widget=self, indices=splines) as tracker:
        for i in splines:
            if interval is not None:
                tomo.make_anchors(i=i, interval=interval)
            tomo.local_radii(
                i=i,
                depth=depth,
                binsize=bin_size,
                min_radius=min_radius,
                max_radius=max_radius,
                update_glob=update_glob,
            )
            if i == splines[-1]:
                yield _on_yield
            else:
                yield

        return tracker.as_undo_callback()

measure_radius(splines=None, bin_size=1, min_radius=1.0, max_radius=100.0)

Measure cylinder radius for each spline curve.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
min_radius nm

Minimum possible radius in nm.

1.0
max_radius nm

Maximum possible radius in nm.

100.0
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
@thread_worker.with_progress(desc="Measuring Radius", total=_NSPLINES)
def measure_radius(
    self,
    splines: SplinesType = None,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
    min_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 1.0,
    max_radius: Annotated[nm, {"min": 0.1, "step": 0.1}] = 100.0,
):  # fmt: skip
    """
    Measure cylinder radius for each spline curve.

    Parameters
    ----------
    {splines}{bin_size}{min_radius}{max_radius}
    """
    splines = self._norm_splines(splines)
    with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
        for i in splines:
            self.tomogram.measure_radius(
                i, binsize=bin_size, min_radius=min_radius, max_radius=max_radius
            )
            yield

        return tracker.as_undo_callback()

measure_radius_by_molecules(layers=(), interval=None, depth=50.0, update_glob=True)

Measure local and global radius for each layer.

Please note that the radius defined by the peak of the radial profile is not always the same as the radius measured by this method. If the molecules are aligned using a template image whose mass density is not centered, these radii may differ a lot.

Parameters:

Name Type Description Default
layers list of MoleculesLayer

All the points layers of molecules to be used.

()
interval nm

Interval (nm) between spline anchors. Please note that resetting interval will discard all the existing local properties.

None
depth nm

Depth (length parallel to the spline tangent) of the subtomograms to be sampled.

50.0
update_glob bool

If true, also update the global property to the mean of local properties.

True
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
def measure_radius_by_molecules(
    self,
    layers: MoleculesLayersType = (),
    interval: _Interval = None,
    depth: Annotated[nm, {"min": 2.0, "step": 0.5}] = 50.0,
    update_glob: Annotated[bool, {"text": "Also update the global radius"}] = True,
):  # fmt: skip
    """
    Measure local and global radius for each layer.

    Please note that the radius defined by the peak of the radial profile is not always
    the same as the radius measured by this method. If the molecules are aligned using
    a template image whose mass density is not centered, these radii may differ a lot.

    Parameters
    ----------
    {layers}{interval}{depth}{update_glob}
    """
    layers = assert_list_of_layers(layers, self.parent_viewer)

    # check duplicated spline sources
    _splines = list[CylSpline]()
    _radius_df = list[pl.DataFrame]()
    _duplicated = list[CylSpline]()
    for layer in layers:
        spl = _assert_source_spline_exists(layer)
        if any(spl is each for each in _splines):
            _duplicated.append(spl)
        _splines.append(spl)
        mole = layer.molecules
        df = mole.features
        _radius_df.append(df.with_columns(cylmeasure.calc_radius(mole, spl)))

    if _duplicated:
        _layer_names = ", ".join(repr(l.name) for l in layers)
        raise ValueError(f"Layers {_layer_names} have duplicated spline sources.")

    indices = [self.tomogram.splines.index(spl) for spl in _splines]
    with SplineTracker(widget=self, indices=indices) as tracker:
        for i, spl, df in zip(indices, _splines, _radius_df, strict=True):
            if interval is not None:
                self.tomogram.make_anchors(i=i, interval=interval)
            radii = list[float]()
            for pos in spl.anchors * spl.length():
                lower, upper = pos - depth / 2, pos + depth / 2
                pred = pl.col(Mole.position).is_between(lower, upper, closed="left")
                radii.append(df.filter(pred)[Mole.radius].mean())
            radii = pl.Series(H.radius, radii, dtype=pl.Float32)
            if radii.is_nan().any():
                _Logger.print_html(f"<b>Spline-{i} contains NaN radius.</b>")
            spl.props.update_loc([radii], depth, bin_size=1)
            if update_glob:
                spl.radius = df[Mole.radius].mean()
        self._update_local_properties_in_widget(replot=True)
        return tracker.as_undo_callback()

merge_molecule_info(pos, rotation, features)

Merge molecule info from different molecules.

Parameters:

Name Type Description Default
pos MoleculesLayer

Molecules whose positions are used.

required
rotation MoleculesLayer

Molecules whose rotations are used.

required
features MoleculesLayer

Molecules whose features are used.

required
Source code in cylindra/widgets/main.py
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
@set_design(text=capitalize, location=_sw.MoleculesMenu.Combine)
def merge_molecule_info(
    self,
    pos: MoleculesLayerType,
    rotation: MoleculesLayerType,
    features: MoleculesLayerType,
):
    """
    Merge molecule info from different molecules.

    Parameters
    ----------
    pos : MoleculesLayer
        Molecules whose positions are used.
    rotation : MoleculesLayer
        Molecules whose rotations are used.
    features : MoleculesLayer
        Molecules whose features are used.
    """
    pos = assert_layer(pos, self.parent_viewer)
    rotation = assert_layer(rotation, self.parent_viewer)
    features = assert_layer(features, self.parent_viewer)
    _pos = pos.molecules
    _rot = rotation.molecules
    _feat = features.molecules
    mole = Molecules(_pos.pos, _rot.rotator, features=_feat.features)
    layer = self.add_molecules(
        mole, name="Mole-merged", source=pos.source_component
    )
    return self._undo_callback_for_layer(layer)

molecules_to_spline(layers=(), err_max=0.8, delete_old=True, inherits=None, missing_ok=False, update_sources=True)

Create splines from molecules.

This function is useful to refine splines using results of subtomogram alignment. If the molecules layer alreadly has a source spline, replace it with the new one. Note that this function only works with molecules that is correctly assembled by such as :func:map_monomers.

Parameters:

Name Type Description Default
layers list of MoleculesLayer

All the points layers of molecules to be used.

()
err_max float

S.D. allowed for spline fitting. Larger value will result in smoother spline, i.e. fewer spline knots.

0.8
delete_old bool

If True, delete the old spline if the molecules has one. For instance, if "Mole-0" has the spline "Spline-0" as the source, and a spline "Spline-1" is created from "Mole-0", then "Spline-0" will be deleted from the list.

True
inherits bool

Which global properties to be copied to the new one. If None, all the properties will be copied.

None
missing_ok bool

If False, raise an error if the source spline is not found in the tomogram.

False
update_sources bool

If True, all the molecules with the out-of-date source spline will be updated to the newly created splines. For instance, if "Mole-0" and "Mole-1" have the spline "Spline-0" as the source, and a spline "Spline-1" is created from "Mole-1", then the source of "Mole-1" will be updated to "Spline-1" as well.

True
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
def molecules_to_spline(
    self,
    layers: MoleculesLayersType = (),
    err_max: Annotated[nm, {"label": "Max fit error (nm)", "step": 0.1}] = 0.8,
    delete_old: Annotated[bool, {"label": "Delete old splines"}] = True,
    inherits: Annotated[Optional[list[str]], {"label": "Properties to inherit", "text": "All properties"}] = None,
    missing_ok: Annotated[bool, {"label": "Missing OK"}] = False,
    update_sources: Annotated[bool, {"label": "Update all the spline sources"}] = True,
):  # fmt: skip
    """
    Create splines from molecules.

    This function is useful to refine splines using results of subtomogram
    alignment. If the molecules layer alreadly has a source spline, replace
    it with the new one.
    Note that this function only works with molecules that is correctly
    assembled by such as :func:`map_monomers`.

    Parameters
    ----------
    {layers}{err_max}
    delete_old : bool, default True
        If True, delete the old spline if the molecules has one. For instance, if
        "Mole-0" has the spline "Spline-0" as the source, and a spline "Spline-1" is
        created from "Mole-0", then "Spline-0" will be deleted from the list.
    inherits : bool, optional
        Which global properties to be copied to the new one. If None, all the properties
        will be copied.
    missing_ok : bool, default False
        If False, raise an error if the source spline is not found in the tomogram.
    update_sources : bool, default True
        If True, all the molecules with the out-of-date source spline will be updated
        to the newly created splines. For instance, if "Mole-0" and "Mole-1" have the
        spline "Spline-0" as the source, and a spline "Spline-1" is created from
        "Mole-1", then the source of "Mole-1" will be updated to "Spline-1" as well.
    """
    tomo = self.tomogram
    layers = assert_list_of_layers(layers, self.parent_viewer)

    # first check missing_ok=False case
    if not missing_ok:
        for layer in layers:
            # NOTE: The source spline may not exist in the list
            if _s := layer.source_spline:
                tomo.splines.index(_s)  # raise error here if not found

    for layer in layers:
        if _s := layer.source_spline:
            _config = _s.config
        else:
            _config = self.default_config
        _shape = (*layer.regular_shape(), 3)
        coords = layer.molecules.pos.reshape(_shape).mean(axis=1)
        spl = CylSpline(config=_config).fit(coords, err_max=err_max)
        try:
            idx = tomo.splines.index(layer.source_spline)
        except ValueError:
            tomo.splines.append(spl)
        else:
            old_spl = tomo.splines[idx]
            if inherits is None:
                spl.props.glob = old_spl.props.glob.clone()
            else:
                glob = old_spl.props.glob
                spl.props.glob = {k: glob[k] for k in glob.columns if k in inherits}

            # Must be updated here, otherwise each.source_component may return
            # None since GC may delete the old spline.
            if update_sources:
                for each in self.mole_layers:
                    if each.source_component is old_spl:
                        each.source_component = spl
            if delete_old:
                tomo.splines[idx] = spl
            else:
                tomo.splines.append(spl)
        layer.source_component = spl

    self.reset_choices()
    self.sample_subtomograms()
    self._update_splines_in_images()
    return None

open_image(path, scale=None, tilt_range=None, bin_size=[1], filter=ImageFilter.Lowpass, invert=False, eager=False)

Load an image file and process it before sending it to the viewer.

Parameters:

Name Type Description Default
path Path

Path to the tomogram. Must be 3-D image.

required
scale float

Pixel size in nm/pixel unit.

1.0
tilt_range tuple of float

Range of tilt angles in degrees.

None
bin_size int or list of int

Initial bin size of image. Binned image will be used for visualization in the viewer. You can use both binned and non-binned image for analysis.

[1]
filter ImageFilter

Filter to be applied to the reference image. This does not affect the image data itself.

  • Lowpass: butterworth low-pass filter.
  • Gaussian: Gaussian blur.
  • DoG: difference of Gaussian.
  • LoG: Laplacian of Gaussian.
Lowpass
invert bool

If true, invert the intensity of the image.

False
eager bool

If true, the image will be loaded immediately. Otherwise, it will be loaded lazily.

False
Source code in cylindra/widgets/main.py
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
@set_design(text="Open", location=_image_loader)
@dask_thread_worker.with_progress(desc="Reading image")
@confirm(text="You may have unsaved data. Open a new tomogram?", condition="self._need_save")  # fmt: skip
def open_image(
    self,
    path: Annotated[str | Path, {"bind": _image_loader.path}],
    scale: Annotated[nm, {"bind": _image_loader.scale.scale_value}] = None,
    tilt_range: Annotated[Any, {"bind": _image_loader.tilt_model}] = None,
    bin_size: Annotated[int | Sequence[int], {"bind": _image_loader.bin_size}] = [1],
    filter: Annotated[ImageFilter | None, {"bind": _image_loader.filter}] = ImageFilter.Lowpass,
    invert: Annotated[bool, {"bind": _image_loader.invert}] = False,
    eager: Annotated[bool, {"bind": _image_loader.eager}] = False
):  # fmt: skip
    """
    Load an image file and process it before sending it to the viewer.

    Parameters
    ----------
    path : Path
        Path to the tomogram. Must be 3-D image.
    scale : float, default 1.0
        Pixel size in nm/pixel unit.
    tilt_range : tuple of float, default None
        Range of tilt angles in degrees.
    bin_size : int or list of int, default [1]
        Initial bin size of image. Binned image will be used for visualization in the viewer.
        You can use both binned and non-binned image for analysis.
    {filter}
    invert : bool, default False
        If true, invert the intensity of the image.
    eager : bool, default False
        If true, the image will be loaded immediately. Otherwise, it will be loaded
        lazily.
    """
    img = ip.lazy.imread(path, chunks=_config.get_config().dask_chunk)
    if scale is not None:
        scale = float(scale)
        img.scale.x = img.scale.y = img.scale.z = scale
    else:
        scale = img.scale.x
    if isinstance(bin_size, int):
        bin_size = [bin_size]
    elif len(bin_size) == 0:
        raise ValueError("You must specify at least one bin size.")
    else:
        bin_size = list(set(bin_size))  # delete duplication
    tomo = CylTomogram.imread(
        path=path,
        scale=scale,
        tilt=tilt_range,
        binsize=bin_size,
        eager=eager,
    )
    self._init_macro_state()
    self._project_dir = None
    return self._send_tomogram_to_viewer.with_args(tomo, filter, invert=invert)

open_label_image(path)

Open an image file as a label image of the current tomogram.

Source code in cylindra/widgets/main.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
@set_design(text=capitalize, location=_sw.FileMenu)
@do_not_record
def open_label_image(self, path: Path.Read[FileFilter.IMAGE]):
    """Open an image file as a label image of the current tomogram."""
    label = ip.imread(path)
    if label.ndim != 3:
        raise ValueError("Label image must be 3-D.")
    tr = self.tomogram.multiscale_translation(label.scale.x / self.tomogram.scale)
    label = self.parent_viewer.add_labels(
        label,
        name=label.name,
        translate=[tr, tr, tr],
        scale=list(label.scale.values()),
        opacity=0.4,
    )
    self._reserved_layers.to_be_removed.add(label)
    return label

open_reference_image(path)

Open an image as a reference image of the current tomogram.

The input image is usually a denoised image created by other softwares, or simply a filtered image. Please note that this method does not check that the input image is appropriate as a reference of the current tomogram, as potentially any 3D image can be used.

Parameters:

Name Type Description Default
path path - like

Path to the image file. The image must be 3-D.

required
Source code in cylindra/widgets/main.py
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
@set_design(text=capitalize, location=_sw.FileMenu)
@do_not_record
def open_reference_image(self, path: Path.Read[FileFilter.IMAGE]):
    """
    Open an image as a reference image of the current tomogram.

    The input image is usually a denoised image created by other softwares, or
    simply a filtered image. Please note that this method does not check that the
    input image is appropriate as a reference of the current tomogram, as
    potentially any 3D image can be used.

    Parameters
    ----------
    path : path-like
        Path to the image file. The image must be 3-D.
    """
    img = ip.imread(path)
    return self._update_reference_image(img)

overwrite_project()

Overwrite currently opened project.

Source code in cylindra/widgets/main.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
@set_design(text=capitalize, location=_sw.FileMenu)
@do_not_record
@bind_key("Ctrl+K, Ctrl+Shift+S")
def overwrite_project(self):
    """Overwrite currently opened project."""
    if self._project_dir is None:
        raise ValueError(
            "No project is loaded. You can use `Save project` "
            "(ui.save_project(...)) to save the current state."
        )
    project = CylindraProject.from_file(self._project_dir)
    if project.molecules_info:
        ext = Path(project.molecules_info[0].name).suffix
    else:
        ext = ".csv"
    return self.save_project(self._project_dir, ext)

paint_molecules(layer, color_by, cmap=DEFAULT_COLORMAP, limits=(4.0, 4.24))

Paint molecules by a feature.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
color_by str

Name of the feature to paint by.

required
cmap colormap

Colormap to be used for painting.

DEFAULT_COLORMAP
limits (float, float)

Lower and upper limits of the colormap.

(4.0, 4.24)
Source code in cylindra/widgets/main.py
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
@set_design(text=capitalize, location=_sw.MoleculesMenu.View)
@bind_key("Ctrl+K, C")
def paint_molecules(
    self,
    layer: MoleculesLayerType,
    color_by: Annotated[str, {"choices": _choice_getter("paint_molecules")}],
    cmap: _CmapType = DEFAULT_COLORMAP,
    limits: Annotated[tuple[float, float], {"options": {"min": -20, "max": 20, "step": 0.01}}] = (4.00, 4.24),
):  # fmt: skip
    """
    Paint molecules by a feature.

    Parameters
    ----------
    {layer}{color_by}{cmap}{limits}
    """
    layer = assert_layer(layer, self.parent_viewer)
    info = layer.colormap_info
    layer.set_colormap(color_by, limits, cmap)

    match info:
        case str(color):
            return undo_callback(layer.face_color_setter).with_args(color)
        case info:
            return undo_callback(layer.set_colormap).with_args(
                by=info.name, limits=info.clim, cmap=info.cmap
            )

protofilaments_to_spline(layer, err_max=0.8, ids=(), config=None)

Convert protofilaments to splines.

If no IDs are given, all the molecules will be fitted to a spline, therefore essentially the same as manual filament picking. If IDs are given, selected protofilaments will be fitted to a spline separately.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
err_max float

S.D. allowed for spline fitting. Larger value will result in smoother spline, i.e. fewer spline knots.

0.8
ids list of int

Protofilament IDs to be converted.

()
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
def protofilaments_to_spline(
    self,
    layer: MoleculesLayerType,
    err_max: Annotated[nm, {"label": "Max fit error (nm)", "step": 0.1}] = 0.8,
    ids: list[int] = (),
    config: Annotated[dict[str, Any] | SplineConfig, {"validator": _get_default_config}] = None,
):  # fmt: skip
    """
    Convert protofilaments to splines.

    If no IDs are given, all the molecules will be fitted to a spline, therefore
    essentially the same as manual filament picking. If IDs are given, selected
    protofilaments will be fitted to a spline separately.

    Parameters
    ----------
    {layer}{err_max}
    ids : list of int, default ()
        Protofilament IDs to be converted.
    """
    layer = assert_layer(layer, self.parent_viewer)
    tomo = self.tomogram
    mole = layer.molecules
    if len(ids) == 0:
        tomo.add_spline(mole.pos, err_max=err_max, config=config)
    for i in ids:
        sub = mole.filter(pl.col(Mole.pf) == i)
        if sub.count() == 0:
            continue
        tomo.add_spline(sub.sort(Mole.nth).pos, err_max=err_max, config=config)
    self.reset_choices()
    self._update_splines_in_images()
    return None

reanalyze_image()

Reanalyze the current tomogram.

This method will extract the first manual operations from current session.

Source code in cylindra/widgets/main.py
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
@set_design(text="Re-analyze current tomogram", location=_sw.AnalysisMenu)
@do_not_record
def reanalyze_image(self):
    """
    Reanalyze the current tomogram.

    This method will extract the first manual operations from current session.
    """
    _ui_sym = mk.symbol(self)
    macro_expr = self._format_macro()[self._macro_image_load_offset :]
    macro = _filter_macro_for_reanalysis(macro_expr, _ui_sym)
    self.clear_all()
    mk.Expr(mk.Head.block, macro.args[1:]).eval({_ui_sym: self})
    return self.macro.clear_undo_stack()

reanalyze_image_config_updated()

Reanalyze the current tomogram with newly set default spline config.

This method is useful when you have mistakenly drawn splines with wrong spline config.

Source code in cylindra/widgets/main.py
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
@set_design(text="Re-analyze with new config", location=_sw.AnalysisMenu)
@do_not_record
def reanalyze_image_config_updated(self):
    """
    Reanalyze the current tomogram with newly set default spline config.

    This method is useful when you have mistakenly drawn splines with wrong spline
    config.
    """
    _ui_sym = mk.symbol(self)
    macro_expr = self._format_macro()[self._macro_image_load_offset :]
    macro = _filter_macro_for_reanalysis(macro_expr, _ui_sym)
    macro = _remove_config_kwargs(macro)
    self.clear_all()
    mk.Expr(mk.Head.block, macro.args[1:]).eval({_ui_sym: self})
    return self.macro.clear_undo_stack()

refine_splines(splines=None, max_interval=30, err_max=0.8, corr_allowed=0.9, bin_size=1)

Refine splines using the global cylindric structural parameters.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
max_interval nm

Maximum interval (nm) between spline anchors.

30
err_max float

S.D. allowed for spline fitting. Larger value will result in smoother spline, i.e. fewer spline knots.

0.8
corr_allowed float

How many images will be used to make template for alignment. If 0.9, then top 90% will be used.

0.9
bin_size int

Bin size of multiscale image to be used. Set to >1 to boost performance.

1
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu.Fitting)
@thread_worker.with_progress(desc="Refining splines", total=_NSPLINES)
def refine_splines(
    self,
    splines: SplinesType = None,
    max_interval: Annotated[nm, {"label": "maximum interval (nm)"}] = 30,
    err_max: Annotated[nm, {"label": "max fit error (nm)", "step": 0.1}] = 0.8,
    corr_allowed: Annotated[float, {"label": "correlation allowed", "max": 1.0, "step": 0.1}] = 0.9,
    bin_size: Annotated[int, {"choices": _get_available_binsize}] = 1,
):  # fmt: skip
    """
    Refine splines using the global cylindric structural parameters.

    Parameters
    ----------
    {splines}{max_interval}{err_max}
    corr_allowed : float, default 0.9
        How many images will be used to make template for alignment. If 0.9, then
        top 90% will be used.
    {bin_size}
    """
    tomo = self.tomogram
    splines = self._norm_splines(splines)
    with SplineTracker(widget=self, indices=splines) as tracker:
        for i in splines:
            tomo.refine(
                i,
                max_interval=max_interval,
                corr_allowed=corr_allowed,
                err_max=err_max,
                binsize=bin_size,
            )
            yield thread_worker.callback(self._update_splines_in_images)

        @thread_worker.callback
        def out():
            self._init_widget_state()
            self._update_splines_in_images()
            self._update_local_properties_in_widget()
            return tracker.as_undo_callback()

    return out

regionprops_features(layer, target, label, properties=('area', 'mean'))

Analyze region properties using another feature column as the labels.

For instance, if the target data is [0, 1, 2, 3, 4] and the labels are [0, 1, 1, 2, 2], the the property "mean" will be [1.5, 3.5]. For some properties such as "length" and "width", the monomer connection will be considered.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
target str

Target column name on which calculation will run.

required
label Annotated[str, {choices: _choice_getter(regionprops_features, dtype_kind=ui)}]

The feature name that will be used as the labels.

required
properties list of str

Properties to calculate.

('area', 'mean')
Source code in cylindra/widgets/main.py
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
@set_design(text="Analyze region properties", location=_sw.MoleculesMenu.Features)
def regionprops_features(
    self,
    layer: MoleculesLayerType,
    target: Annotated[str, {"choices": _choice_getter("regionprops_features", dtype_kind="uif")}],
    label: Annotated[str, {"choices": _choice_getter("regionprops_features", dtype_kind="ui")}],
    properties: Annotated[list[str], {"choices": cylmeasure.RegionProfiler.CHOICES, "widget_type": CheckBoxes}] = ("area", "mean"),
):  # fmt: skip
    """
    Analyze region properties using another feature column as the labels.

    For instance, if the target data is [0, 1, 2, 3, 4] and the labels are [0, 1, 1, 2, 2],
    the the property "mean" will be [1.5, 3.5]. For some properties such as "length" and
    "width", the monomer connection will be considered.

    Parameters
    ----------
    {layer}{target}
    label: str
        The feature name that will be used as the labels.
    properties : list of str
        Properties to calculate.
    """
    from magicclass.ext.polars import DataFrameView

    layer = assert_layer(layer, self.parent_viewer)
    utils.assert_column_exists(
        layer.molecules.features, [target, label, Mole.nth, Mole.pf]
    )
    spl = _assert_source_spline_exists(layer)
    reg = cylmeasure.RegionProfiler.from_components(
        layer.molecules, spl, target, label
    )
    df = reg.calculate(properties)
    view = DataFrameView(value=df)
    dock = self.parent_viewer.window.add_dock_widget(view, name="Region properties")
    dock.setFloating(True)
    return undo_callback(dock.close).with_redo(dock.show)

register_molecules(coords=None)

Register manually added points as molecules.

Source code in cylindra/widgets/main.py
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
@set_design(text=capitalize, location=_sw.MoleculesMenu)
def register_molecules(
    self,
    coords: Annotated[np.ndarray, {"validator": _get_spline_coordinates}] = None,
):
    """Register manually added points as molecules."""
    if coords is None or coords.size == 0:
        raise ValueError("No points are given.")
    mole = Molecules(coords)
    return self.add_molecules(mole, name="Mole-manual")

register_path(coords=None, config=None, err_max=0.5)

Register points as a spline path.

Source code in cylindra/widgets/main.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
@set_design(icon="mdi:pen-add", location=Toolbar)
@bind_key("F1")
def register_path(
    self,
    coords: Annotated[np.ndarray, {"validator": _get_spline_coordinates}] = None,
    config: Annotated[dict[str, Any] | SplineConfig, {"validator": _get_default_config}] = None,
    err_max: Annotated[nm, {"bind": 0.5}] = 0.5,
):  # fmt: skip
    """Register points as a spline path."""
    if coords is None or coords.size == 0:
        raise ValueError("No points are given.")

    tomo = self.tomogram
    tomo.add_spline(coords, config=config, err_max=err_max)
    self._add_spline_instance(tomo.splines[-1])
    return undo_callback(self.delete_spline).with_args(-1)

rename_molecules(old, new, include='', exclude='', pattern='')

Rename multiple molecules layers at once.

Parameters:

Name Type Description Default
old str

Old string to be replaced.

required
new str

New string to replace old.

required
include str

Delete layers whose names contain this string.

''
exclude str

Delete layers whose names do not contain this string.

''
pattern str

String pattern to match the layer names. Use * as wildcard.

''
Source code in cylindra/widgets/main.py
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
@set_design(text="Rename molecule layers", location=_sw.MoleculesMenu)
@do_not_record(recursive=False)
def rename_molecules(
    self,
    old: str,
    new: str,
    include: str = "",
    exclude: str = "",
    pattern: str = "",
):
    """
    Rename multiple molecules layers at once.

    Parameters
    ----------
    old : str
        Old string to be replaced.
    new : str
        New string to replace `old`.
    include : str, optional
        Delete layers whose names contain this string.
    exclude : str, optional
        Delete layers whose names do not contain this string.
    pattern : str, optional
        String pattern to match the layer names. Use `*` as wildcard.
    """
    if old == "":
        raise ValueError("`old` is not given.")
    if new == "":
        raise ValueError("`new` is not given.")
    return self.mole_layers.rename(
        old, new, include=include, exclude=exclude, pattern=pattern
    )

rotate_molecules(layers, degrees, inherit_source=True)

Rotate molecules without changing their positions.

Output molecules layer will be named as "-Rot".

Parameters:

Name Type Description Default
layers list of MoleculesLayer

All the points layers of molecules to be used.

required
degrees list of (str, float)

Rotation axes and degrees. For example, [("z", 20), ("y", -10)] means rotation by 20 degrees around the molecule Z axis and then by -10 degrees around the Y axis.

required
inherit_source bool

If True and the input molecules layer has its spline source, the new layer will inherit it.

True
Source code in cylindra/widgets/main.py
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
@set_design(text=capitalize, location=_sw.MoleculesMenu)
def rotate_molecules(
    self,
    layers: MoleculesLayersType,
    degrees: Annotated[
        list[tuple[Literal["z", "y", "x"], float]],
        {"layout": "vertical", "options": {"widget_type": SingleRotationEdit}},
    ],
    inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
):
    """
    Rotate molecules without changing their positions.

    Output molecules layer will be named as "<original name>-Rot".

    Parameters
    ----------
    {layers}
    degrees : list of (str, float)
        Rotation axes and degrees. For example, `[("z", 20), ("y", -10)]` means
        rotation by 20 degrees around the molecule Z axis and then by -10 degrees
        around the Y axis.
    {inherit_source}
    """
    layers = assert_list_of_layers(layers, self.parent_viewer)
    new_layers = list[MoleculesLayer]()
    rotvec = degrees_to_rotator(degrees).as_rotvec()
    for layer in layers:
        mole = layer.molecules.rotate_by_rotvec_internal(rotvec)
        source = layer.source_component if inherit_source else None
        new = self.add_molecules(mole, name=f"{layer.name}-Rot", source=source)
        new_layers.append(new)
    return self._undo_callback_for_layer(new_layers)

run_workflow(filename, *args, **kwargs)

Run a user-defined workflow.

This method will run a .py file that was defined by the user from Workflow > Define workflow. args and *kwargs follow the signature of the main function of the workflow.

Source code in cylindra/widgets/main.py
424
425
426
427
428
429
430
431
432
433
434
435
436
@do_not_record(recursive=False)
@nogui
def run_workflow(self, filename: str, *args, **kwargs):
    """
    Run a user-defined workflow.

    This method will run a .py file that was defined by the user from
    `Workflow > Define workflow`. *args and **kwargs follow the signature of the
    main function of the workflow.
    """
    main = _config.get_main_function(filename)
    out = main(self, *args, **kwargs)
    return out

sample_subtomograms()

Sample subtomograms at the anchor points on splines

Source code in cylindra/widgets/main.py
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
@set_design(text=capitalize, location=_sw.ImageMenu)
def sample_subtomograms(self):
    """Sample subtomograms at the anchor points on splines"""
    self.spline_fitter.close()

    # initialize GUI
    if len(self.tomogram.splines) == 0:
        raise ValueError("No spline found.")
    spl = self.tomogram.splines[0]
    if spl.has_anchors:
        self.SplineControl["pos"].max = spl.anchors.size - 1
    self.SplineControl._num_changed()
    self._reserved_layers.work.mode = "pan_zoom"

    self._update_local_properties_in_widget()
    self._update_global_properties_in_widget()
    self._highlight_spline()

    # reset contrast limits
    self.SplineControl._reset_contrast_limits()
    return None

save_molecules(layer, save_path)

Save monomer coordinates, orientation and features as a csv file.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
save_path Path

Where to save the molecules.

required
Source code in cylindra/widgets/main.py
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
@do_not_record
@set_design(text=capitalize, location=_sw.FileMenu)
def save_molecules(
    self, layer: MoleculesLayerType, save_path: Path.Save[FileFilter.MOLE]
):
    """
    Save monomer coordinates, orientation and features as a csv file.

    Parameters
    ----------
    {layer}
    save_path : Path
        Where to save the molecules.
    """
    return assert_layer(layer, self.parent_viewer).molecules.to_csv(save_path)

save_project(path, molecules_ext='.csv', save_landscape=False)

Save current project state and the results in a directory.

The json file contains paths of images and results, parameters of splines, scales and version. Local and global properties will be exported as csv files. Molecule coordinates and features will be exported as the molecules_ext format. If results are saved at the default directory, they will be written as relative paths in the project json file so that moving root directory does not affect the loading behavior.

Parameters:

Name Type Description Default
path Path

Path of json file.

required
molecules_ext str

Extension of the molecule file. Can be ".csv" or ".parquet".

".csv"
save_landscape bool

Save landscape layers if any. False by default because landscape layers are usually large.

False
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.FileMenu)
@do_not_record
@bind_key("Ctrl+K, Ctrl+S")
def save_project(
    self,
    path: Path.Save,
    molecules_ext: Literal[".csv", ".parquet"] = ".csv",
    save_landscape: Annotated[bool, {"label": "Save landscape layers"}] = False,
):
    """
    Save current project state and the results in a directory.

    The json file contains paths of images and results, parameters of splines,
    scales and version. Local and global properties will be exported as csv files.
    Molecule coordinates and features will be exported as the `molecules_ext`
    format. If results are saved at the default directory, they will be
    written as relative paths in the project json file so that moving root
    directory does not affect the loading behavior.

    Parameters
    ----------
    path : Path
        Path of json file.
    molecules_ext : str, default ".csv"
        Extension of the molecule file. Can be ".csv" or ".parquet".
    save_landscape : bool, default False
        Save landscape layers if any. False by default because landscape layers are
        usually large.
    """
    path = Path(path)
    CylindraProject.save_gui(self, path, molecules_ext, save_landscape)
    _Logger.print(f"Project saved: {path.as_posix()}")
    self._need_save = False
    self._project_dir = path
    autosave_path = _config.autosave_path()
    if autosave_path.exists():
        with suppress(Exception):
            autosave_path.unlink()
    return None

save_spline(spline, save_path)

Save splines as a json file.

Source code in cylindra/widgets/main.py
634
635
636
637
638
639
640
641
642
643
644
@set_design(text=capitalize, location=_sw.FileMenu)
@do_not_record
def save_spline(
    self,
    spline: Annotated[int, {"choices": _get_splines}],
    save_path: Path.Save[FileFilter.JSON],
):
    """Save splines as a json file."""
    spl = self.tomogram.splines[spline]
    spl.to_json(save_path)
    return None

set_multiscale(bin_size)

Set multiscale used for image display.

Parameters:

Name Type Description Default
bin_size Annotated[int, {choices: _get_available_binsize}]

Bin size of multiscaled image.

required
Source code in cylindra/widgets/main.py
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
@set_design(text="Set multi-scale", location=_sw.ImageMenu)
def set_multiscale(self, bin_size: Annotated[int, {"choices": _get_available_binsize}]):  # fmt: skip
    """
    Set multiscale used for image display.

    Parameters
    ----------
    bin_size: int
        Bin size of multiscaled image.
    """
    tomo = self.tomogram
    _old_bin_size = self._current_binsize
    imgb = tomo.get_multiscale(bin_size)
    factor = self._reserved_layers.scale / imgb.scale.x
    self._reserved_layers.update_image(imgb, tomo.multiscale_translation(bin_size))
    current_z = self.parent_viewer.dims.current_step[0]
    self.parent_viewer.dims.set_current_step(axis=0, value=current_z * factor)

    # update overview
    self.Overview.image = imgb.mean(axis="z")
    self.Overview.xlim = [x * factor for x in self.Overview.xlim]
    self.Overview.ylim = [y * factor for y in self.Overview.ylim]
    self._current_binsize = bin_size
    self.reset_choices()
    return undo_callback(self.set_multiscale).with_args(_old_bin_size)

set_radius(splines=None, radius=10.0)

Set radius of the splines.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
radius float or str expression

Radius of the spline. If a string expression is given, it will be evaluated to get the polars.Expr object. The returned expression will be evaluated with the global properties of the spline as the context.

10.0
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.AnalysisMenu.Radius)
def set_radius(
    self,
    splines: SplinesType = None,
    radius: PolarsExprStrOrScalar = 10.0,
):  # fmt: skip
    """
    Set radius of the splines.

    Parameters
    ----------
    {splines}
    radius : float or str expression
        Radius of the spline. If a string expression is given, it will be evaluated to get
        the polars.Expr object. The returned expression will be evaluated with the global
        properties of the spline as the context.
    """
    radius_expr = widget_utils.norm_scalar_expr(radius)
    splines = self._norm_splines(splines)
    rdict = dict[int, float]()
    for i in splines:
        _radius = self.splines[i].props.get_glob(radius_expr)
        if not isinstance(_radius, (int, float)):
            raise ValueError(
                f"Radius must be converted into a number, got {_radius!r}."
            )
        if _radius <= 0:
            raise ValueError(f"Radius must be positive, got {_radius}.")
        rdict[i] = _radius
    with SplineTracker(widget=self, indices=splines, sample=True) as tracker:
        for i in splines:
            self.splines[i].radius = rdict[i]
        return tracker.as_undo_callback()

set_source_spline(layer, spline)

Set source spline for a molecules layer.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
spline int

Index of splines to be used.

required
Source code in cylindra/widgets/main.py
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
@set_design(text=capitalize, location=_sw.MoleculesMenu.FromToSpline)
def set_source_spline(
    self,
    layer: MoleculesLayerType,
    spline: Annotated[int, {"choices": _get_splines}],
):
    """
    Set source spline for a molecules layer.

    Parameters
    ----------
    {layer}{spline}
    """
    layer = assert_layer(layer, self.parent_viewer)
    old_spl = layer.source_component
    layer.source_component = self.tomogram.splines[spline]

    @undo_callback
    def _undo():
        layer.source_component = old_spl

    return _undo

set_spline_props(spline, npf=None, start=None, orientation=None)

Set spline global properties.

This method will overwrite spline properties with the user input. You should not call this method unless there's a good reason to do so, e.g. the number of protofilaments is obviously wrong.

Parameters:

Name Type Description Default
npf int

If given, update the number of protofilaments.

None
start int

If given, update the start number of the spline.

None
orientation str

If given, update the spline orientation.

None
Source code in cylindra/widgets/main.py
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
@set_design(text="Set spline properties", location=_sw.SplinesMenu)
def set_spline_props(
    self,
    spline: Annotated[int, {"bind": _get_spline_idx}],
    npf: Annotated[Optional[int], {"label": "number of PF", "text": "Do not update"}] = None,
    start: Annotated[Optional[int], {"label": "start number", "text": "Do not update"}] = None,
    orientation: Annotated[Optional[Literal["MinusToPlus", "PlusToMinus"]], {"text": "Do not update"}] = None,
):  # fmt: skip
    """
    Set spline global properties.

    This method will overwrite spline properties with the user input. You should
    not call this method unless there's a good reason to do so, e.g. the number
    of protofilaments is obviously wrong.

    Parameters
    ----------
    npf : int, optional
        If given, update the number of protofilaments.
    start : int, optional
        If given, update the start number of the spline.
    orientation : str, optional
        If given, update the spline orientation.
    """
    spl = self.tomogram.splines[spline]
    old_spl = spl.copy()
    spl.update_props(npf=npf, start=start, orientation=orientation)
    self.sample_subtomograms()
    self._update_splines_in_images()

    @undo_callback
    def out():
        self.tomogram.splines[spline] = old_spl
        self.sample_subtomograms()
        self._update_splines_in_images()

    return out

split_molecules(layer, by)

Split molecules by a feature column.

Parameters:

Name Type Description Default
layer MoleculesLayer

Points layer of molecules to be used.

required
by str

Name of the feature to split by.

required
Source code in cylindra/widgets/main.py
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
@set_design(text="Split molecules by feature", location=_sw.MoleculesMenu)
def split_molecules(
    self,
    layer: MoleculesLayerType,
    by: Annotated[str, {"choices": _choice_getter("split_molecules")}],
):
    """
    Split molecules by a feature column.

    Parameters
    ----------
    {layer}
    by : str
        Name of the feature to split by.
    """
    layer = assert_layer(layer, self.parent_viewer)
    utils.assert_column_exists(layer.molecules.features, by)
    _added_layers = list[MoleculesLayer]()
    for _key, mole in layer.molecules.groupby(by):
        new = self.add_molecules(
            mole, name=f"{layer.name}_{_key}", source=layer.source_component
        )
        _added_layers.append(new)
    return self._undo_callback_for_layer(_added_layers)

split_spline(spline, at=100.0, from_start=True, trim=0.0)

Split the spline into two at the given position.

Parameters:

Name Type Description Default
spline int

Index of splines to be used.

required
at float

Position to split the spline in nm.

100.0
from_start bool

If True, the split position will be measured from the start of the spline.

True
trim float

Trim the split parts by this length (nm).

0.0
Source code in cylindra/widgets/main.py
 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
@set_design(text=capitalize, location=_sw.SplinesMenu)
def split_spline(
    self,
    spline: Annotated[int, {"choices": _get_splines}],
    at: Annotated[nm, {"min": 0.0, "max": 10000.0, "step": 0.1, "label": "split at (nm)"}] = 100.0,
    from_start: bool = True,
    trim: Annotated[nm, {"min": 0.0, "max": 100.0, "step": 0.1, "label": "trim (nm)"}] = 0.0,
):  # fmt: skip
    """
    Split the spline into two at the given position.

    Parameters
    ----------
    {spline}
    at : float, default 100.0
        Position to split the spline in nm.
    from_start : bool, default True
        If True, the split position will be measured from the start of the spline.
    trim : float, default 0.0
        Trim the split parts by this length (nm).
    """
    spl = self.splines[spline]
    spls = spl.split(at, from_start=from_start, trim=trim)
    self.splines.pop(spline)
    for new_spl in reversed(spls):
        self.splines.insert(spline, new_spl)
    self._update_splines_in_images()
    self.reset_choices()

    @undo_callback
    def _out():
        del self.splines[-2:]
        self.splines.insert(spline, spl)
        self._update_splines_in_images()
        self.reset_choices()

    return _out

split_splines_at_changing_point(splines=None, estimate_by='radius', diff_cutoff=0.4, trim=0.0)

Detect the changing point of the spline and split it there.

This method is useful when (1) there's a change in the protofilament number, or (2) microtubules were polymerized from seeds.

Parameters:

Name Type Description Default
splines list of int

Indices of splines to be used.

None
estimate_by str

Local property to estimate the changing point. Must be one of the local property of the splines.

"radius"
diff_cutoff float

The cutoff value of the absolute difference between the two regions to be considered as a changing point.

2.0
trim float

Trim the split parts by this length (nm). If any of the split parts is shorter than this length, the part will be discarded.

0.0
Source code in cylindra/widgets/main.py
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
@set_design(text=capitalize, location=_sw.SplinesMenu)
def split_splines_at_changing_point(
    self,
    splines: SplinesType = None,
    estimate_by: str = "radius",
    diff_cutoff: Annotated[float, {"min": 0.0, "max": 1000.0, "step": 0.01}] = 0.4,
    trim: Annotated[nm, {"min": 0.0, "max": 1000.0, "step": 0.1, "label": "trim (nm)"}] = 0.0,
):  # fmt: skip
    """
    Detect the changing point of the spline and split it there.

    This method is useful when (1) there's a change in the protofilament number, or
    (2) microtubules were polymerized from seeds.

    Parameters
    ----------
    {splines}
    estimate_by : str, default "radius"
        Local property to estimate the changing point. Must be one of the local
        property of the splines.
    diff_cutoff : float, default 2.0
        The cutoff value of the absolute difference between the two regions to be
        considered as a changing point.
    trim : float, default 0.0
        Trim the split parts by this length (nm). If any of the split parts is
        shorter than this length, the part will be discarded.
    """
    splines = self._norm_splines(splines)
    spl_map = dict[int, list[CylSpline]]()
    _Logger.print("`split_spine_at_changing_point`")
    for i in splines:
        spl = self.splines[i]
        if (loc := spl.props.get_loc(estimate_by, None)) is None:
            raise ValueError(
                f"Spline-{i} does not have {estimate_by!r} local property. Call "
                "`measure_local_radius` or `local_cft_analysis` first."
            )
        idx = utils.find_changing_point(loc)
        mean_diff = float(abs(loc[:idx].mean() - loc[idx:].mean()))
        _log = f"spline-{i}: {mean_diff=:.3g}"
        if mean_diff < diff_cutoff:
            _Logger.print(_log + " ==> skip")
            continue
        at = spl.length(0, (spl.anchors[idx - 1] + spl.anchors[idx]) / 2)
        _Logger.print(_log + f" ==> split at {at:.1f} nm")
        spl_map[i] = spl.split(at, from_start=True, trim=trim, allow_discard=True)

    for i, new_spls in sorted(spl_map.items(), key=lambda x: x[0], reverse=True):
        self.splines.pop(i)
        for new_spl in reversed(new_spls):
            self.splines.insert(i, new_spl)

    self._update_splines_in_images()
    self.reset_choices()
    return None

translate_molecules(layers, translation, internal=True, inherit_source=True)

Translate molecule coordinates without changing their rotations.

Output molecules layer will be named as "-Shift".

Parameters:

Name Type Description Default
layers list of MoleculesLayer

All the points layers of molecules to be used.

required
translation tuple of float

Translation (nm) of the molecules in (Z, Y, X) order. Whether the world coordinate or the internal coordinate is used depends on the internal argument.

required
internal bool

If true, the translation is applied to the internal coordinates, i.e. molecules with different rotations are translated differently.

True
inherit_source bool

If True and the input molecules layer has its spline source, the new layer will inherit it.

True
Source code in cylindra/widgets/main.py
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
@set_design(text=capitalize, location=_sw.MoleculesMenu)
def translate_molecules(
    self,
    layers: MoleculesLayersType,
    translation: Annotated[tuple[nm, nm, nm], {"options": {"min": -1000, "max": 1000, "step": 0.1}, "label": "translation Z, Y, X (nm)"}],
    internal: bool = True,
    inherit_source: Annotated[bool, {"label": "Inherit source spline"}] = True,
):  # fmt: skip
    """
    Translate molecule coordinates without changing their rotations.

    Output molecules layer will be named as "<original name>-Shift".

    Parameters
    ----------
    {layers}
    translation : tuple of float
        Translation (nm) of the molecules in (Z, Y, X) order. Whether the world
        coordinate or the internal coordinate is used depends on the `internal`
        argument.
    internal : bool, default True
        If true, the translation is applied to the internal coordinates, i.e.
        molecules with different rotations are translated differently.
    {inherit_source}
    """
    layers = assert_list_of_layers(layers, self.parent_viewer)
    new_layers = list[MoleculesLayer]()
    for layer in layers:
        mole = layer.molecules
        if internal:
            out = mole.translate_internal(translation)
            if Mole.position in out.features.columns:
                # update spline position feature
                dy = translation[1]
                out = out.with_features([pl.col(Mole.position) + dy])
        else:
            out = mole.translate(translation)
            if Mole.position in out.features.columns:
                # spline position is not predictable.
                out = out.drop_features([Mole.position])
        source = layer.source_component if inherit_source else None
        new = self.add_molecules(out, name=f"{layer.name}-Shift", source=source)
        new_layers.append(new)
    return self._undo_callback_for_layer(new_layers)