Compare commits
736 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe37ff388 | |||
|
|
f7612cdd0a | ||
|
|
125a174298 | ||
|
|
b880d2acf9 | ||
|
|
03e17d764a | ||
|
|
3d70db2d3b | ||
|
|
110b293aa0 | ||
|
|
70421225d7 | ||
|
|
3c0e245398 | ||
|
|
00bb77d940 | ||
|
|
01939e2236 | ||
|
|
f0e2f64397 | ||
|
|
43226d9191 | ||
|
|
dc9df56be4 | ||
|
|
9c76afa075 | ||
|
|
6d8a275171 | ||
|
|
1eee3e6119 | ||
|
|
2c54616136 | ||
|
|
9329dd5b55 | ||
|
|
75105ced04 | ||
|
|
b266c0a86a | ||
|
|
f79055f98e | ||
|
|
6b6761088b | ||
|
|
dbb8b1526e | ||
|
|
b996070583 | ||
|
|
1b6fa2194e | ||
|
|
84c7d17b13 | ||
|
|
76687cf496 | ||
|
|
3dd5c70cb2 | ||
|
|
3d9a17bc0c | ||
|
|
333d73e334 | ||
|
|
e381d22200 | ||
|
|
f8546ce0e0 | ||
|
|
973631b7e8 | ||
|
|
e0f42d4f1d | ||
|
|
2800f82e3c | ||
|
|
38214f26de | ||
|
|
cb6cdafd24 | ||
|
|
aa1ba771e0 | ||
|
|
d4153f12b2 | ||
|
|
71c50db9b5 | ||
|
|
4cdcd34a33 | ||
|
|
0dfd321326 | ||
|
|
0fcebe05c6 | ||
|
|
ac45a9497f | ||
|
|
03b180f185 | ||
|
|
e1bc625ab8 | ||
|
|
661ecb5b2e | ||
|
|
b7c8b865dd | ||
|
|
ee165b4d8e | ||
|
|
589d6a1506 | ||
|
|
95200ec6e8 | ||
|
|
8f6947b75d | ||
|
|
79cb2356e1 | ||
|
|
20632f97cf | ||
|
|
c205965f87 | ||
|
|
75488298cd | ||
|
|
b24f039735 | ||
|
|
1f6670c602 | ||
|
|
b9cde4981a | ||
|
|
fc994fe525 | ||
|
|
ada2da34f0 | ||
|
|
5955151ba1 | ||
|
|
419366bd25 | ||
|
|
b59eb30b0f | ||
|
|
57d03fc722 | ||
|
|
17e12d53e5 | ||
|
|
ffd7884ed1 | ||
|
|
0dd6571505 | ||
|
|
6e2d53f280 | ||
|
|
6acbbc0271 | ||
|
|
780d172201 | ||
|
|
256b8347e7 | ||
|
|
da9fd9057d | ||
|
|
5cb2fb41f8 | ||
|
|
a219822ad1 | ||
|
|
e391e6de5f | ||
|
|
a64fceb009 | ||
|
|
f1cc2ee13a | ||
|
|
571c2b4ff0 | ||
|
|
6dd150d401 | ||
|
|
a27f779f0b | ||
|
|
99c6154343 | ||
|
|
0885468bb9 | ||
|
|
9396e01107 | ||
|
|
1aaae125ea | ||
|
|
ff3e855809 | ||
|
|
550dec742d | ||
|
|
daae4c144e | ||
|
|
9e7a3586ce | ||
|
|
bdcafc12de | ||
|
|
5ea22fc34f | ||
|
|
0c1ca6b04a | ||
|
|
9a2d2c3d13 | ||
|
|
5762095cfa | ||
|
|
becd208924 | ||
|
|
2258c7957a | ||
|
|
d186749c5a | ||
|
|
09093b3f0a | ||
|
|
ef8bb5559a | ||
|
|
95251ab670 | ||
|
|
1e91285c60 | ||
|
|
27af6821b5 | ||
|
|
6f92234111 | ||
|
|
527cfb0ae8 | ||
|
|
64fa240dfa | ||
|
|
77cd9394d2 | ||
|
|
8e8f574174 | ||
|
|
fe16e80cf3 | ||
|
|
cb0cbe702c | ||
|
|
55b46f39c6 | ||
|
|
01b5c86826 | ||
|
|
f231eff598 | ||
|
|
b058ec7c72 | ||
|
|
397aa4d0e3 | ||
|
|
8cd8ccc163 | ||
|
|
37e19f734a | ||
|
|
13305de99c | ||
|
|
9cf3149d2a | ||
|
|
fbea158473 | ||
|
|
5e624f1387 | ||
|
|
d792bf7f80 | ||
|
|
979d619f7c | ||
|
|
5bccf0a753 | ||
|
|
691e26731f | ||
|
|
e6816f6de4 | ||
|
|
468e5734a9 | ||
|
|
90abd00936 | ||
|
|
62620afb9b | ||
|
|
03860030bd | ||
|
|
f8ccf9ff2e | ||
|
|
c9c4f11b82 | ||
|
|
f3cca21e49 | ||
|
|
5ba8339037 | ||
|
|
1fc97fea43 | ||
|
|
7008029cdc | ||
|
|
8fc0dc16e9 | ||
|
|
8a2fee4d8a | ||
|
|
eede569a88 | ||
|
|
51e6b35dbb | ||
|
|
792f78e35f | ||
|
|
cc5dc20164 | ||
|
|
31727c0668 | ||
|
|
760adc50d6 | ||
|
|
6e4ef68a83 | ||
|
|
f5a66b9eea | ||
|
|
3069d33c99 | ||
|
|
f6bbd5c779 | ||
|
|
66c8e0c195 | ||
|
|
961eea1c39 | ||
|
|
0ddf32eef2 | ||
|
|
d219171e25 | ||
|
|
6cbad4fab7 | ||
|
|
0e933c256e | ||
|
|
74e656b174 | ||
|
|
68b10d365a | ||
|
|
91a8715795 | ||
|
|
fdf26aa8b9 | ||
|
|
84ddf78c87 | ||
|
|
d7ab507f5b | ||
|
|
5358a3985a | ||
|
|
d670fb168c | ||
|
|
b9029ef6f8 | ||
|
|
a875d7de1f | ||
|
|
7929f0898f | ||
|
|
f999f3eab5 | ||
|
|
e66fb5a2b8 | ||
|
|
efac34dfe5 | ||
|
|
2a728b244c | ||
|
|
485f0d75d9 | ||
|
|
d7dd12ad67 | ||
|
|
9718ecc407 | ||
|
|
fe762b68e8 | ||
|
|
87eeefdf1b | ||
|
|
1d4802c2a3 | ||
|
|
69d8ad63f3 | ||
|
|
1df671e9ab | ||
|
|
15ef4c2e0f | ||
|
|
7a38903b19 | ||
|
|
40a472a9b1 | ||
|
|
d8d376edaf | ||
|
|
d0bab783c7 | ||
|
|
fe1ecfe0cc | ||
|
|
54d9ea39bf | ||
|
|
7b5d0ce40e | ||
|
|
15f14e34e4 | ||
|
|
d015b2e32a | ||
|
|
197b80eefd | ||
|
|
b83e978df8 | ||
|
|
4c2bd8d5ed | ||
|
|
889026f8a9 | ||
|
|
1aaf569825 | ||
|
|
6f773f9972 | ||
|
|
0f822aaf48 | ||
|
|
61a1fccd7d | ||
|
|
c842642341 | ||
|
|
70f76aeba1 | ||
|
|
30bb5d0652 | ||
|
|
2358a73c3c | ||
|
|
df22109013 | ||
|
|
7e10830d58 | ||
|
|
6db0bc2d4c | ||
|
|
be8e266a80 | ||
|
|
b3e5f64694 | ||
|
|
f2ed4c9e6f | ||
|
|
b6d362f375 | ||
|
|
92011c587f | ||
|
|
c9dea4e6cc | ||
|
|
6a3caa1ac9 | ||
|
|
2b486f16ed | ||
|
|
5370b9382e | ||
|
|
67f9de4ab6 | ||
|
|
d11d6d401b | ||
|
|
a8f709982f | ||
|
|
9e4c0bb7a2 | ||
|
|
eeca1f7a7b | ||
|
|
279ae0dfe0 | ||
|
|
928d592333 | ||
|
|
3330d2b52b | ||
|
|
b5573caaa6 | ||
|
|
cb4397fe31 | ||
|
|
d359a3b784 | ||
|
|
ce9f159c21 | ||
|
|
27229609cd | ||
|
|
83e687fb02 | ||
|
|
b35354120a | ||
|
|
c75cbc2a89 | ||
|
|
53c67f3612 | ||
|
|
c85ca0e177 | ||
|
|
3d59c0d9d3 | ||
|
|
8b9cb06115 | ||
|
|
cca1a8683b | ||
|
|
f668848230 | ||
|
|
f590fc05a8 | ||
|
|
c6e670e575 | ||
|
|
dbc31a5d4e | ||
|
|
cfe7efd3ef | ||
|
|
739e3239cf | ||
|
|
f81a7543fc | ||
|
|
9a2691a71f | ||
|
|
4dfd80f1fd | ||
|
|
baef707e46 | ||
|
|
7cdc54c86d | ||
|
|
c92bdd5b48 | ||
|
|
10b0560170 | ||
|
|
4e9b5c2e22 | ||
|
|
17b12dc932 | ||
|
|
fb7329f3fe | ||
|
|
912715407c | ||
|
|
99ff2453db | ||
|
|
b8adfdfc84 | ||
|
|
eb5065ca6c | ||
|
|
0a751461b3 | ||
|
|
02f99d5638 | ||
|
|
05d979f88d | ||
|
|
fdeb59d635 | ||
|
|
037402f7f6 | ||
|
|
5837e6507e | ||
|
|
42634da189 | ||
|
|
7f9e59ee30 | ||
|
|
3c0dd39054 | ||
|
|
009e0e875d | ||
|
|
f70c9b932d | ||
|
|
7bcb6b8a94 | ||
|
|
7c8ca269d3 | ||
|
|
94c9e162f3 | ||
|
|
c10f0592f6 | ||
|
|
cbd0181523 | ||
|
|
47092fe1ac | ||
|
|
891d6570c1 | ||
|
|
48f8e806c3 | ||
|
|
45739b31a2 | ||
|
|
54ff608605 | ||
|
|
710fefe3e8 | ||
|
|
48f2f6fb13 | ||
|
|
3b9d3babf9 | ||
|
|
e71a20334a | ||
|
|
512aa3b07f | ||
|
|
c40ebd2124 | ||
|
|
ceb61fce15 | ||
|
|
9a97095d5c | ||
|
|
a448bafbc5 | ||
|
|
2da384ccbc | ||
|
|
02423c5bf3 | ||
|
|
5807889f79 | ||
|
|
a4c99e6926 | ||
|
|
f4ac95f96f | ||
|
|
76199884de | ||
|
|
64cd189cce | ||
|
|
8f13d92fa8 | ||
|
|
6dedf5573e | ||
|
|
b69abf25ee | ||
|
|
891012397b | ||
|
|
06612fe177 | ||
|
|
0321361e2c | ||
|
|
1bc0dece64 | ||
|
|
0fd517d6ab | ||
|
|
b2f6e1f237 | ||
|
|
dbb7873238 | ||
|
|
afe184ec78 | ||
|
|
3cfddc1f64 | ||
|
|
1142cdbb46 | ||
|
|
320f0a7463 | ||
|
|
16267d9ddd | ||
|
|
88e4a9fb7b | ||
|
|
4a1f509865 | ||
|
|
c2efa5701e | ||
|
|
7f1c2910eb | ||
|
|
eb6b814fc9 | ||
|
|
612ced0ea1 | ||
|
|
0995616567 | ||
|
|
210ebf26f6 | ||
|
|
82e6a18735 | ||
|
|
83c9ce0f5f | ||
|
|
e06b1786e0 | ||
|
|
aba4855276 | ||
|
|
6f847cb053 | ||
|
|
b55f4985e0 | ||
|
|
00d04162b2 | ||
|
|
33575b769e | ||
|
|
36581696c9 | ||
|
|
6429a5518a | ||
|
|
c34ede8570 | ||
|
|
0474168b44 | ||
|
|
ecc74a53e0 | ||
|
|
18636734ec | ||
|
|
0cf3b0b335 | ||
|
|
38a8406a66 | ||
|
|
a51552a2e7 | ||
|
|
7386d1fc01 | ||
|
|
a74648f8ec | ||
|
|
fec661318c | ||
|
|
09c8af50b7 | ||
|
|
6931c6b7fa | ||
|
|
233f73b7eb | ||
|
|
017c7dff27 | ||
|
|
ca364a3af7 | ||
|
|
50f6b52bbd | ||
|
|
3b34296d2b | ||
|
|
635de2bfcf | ||
|
|
8626906b0e | ||
|
|
6a185bfb6e | ||
|
|
cd253f20bd | ||
|
|
e7bc688290 | ||
|
|
21ab058fdb | ||
|
|
3440f66ebd | ||
|
|
f8f09c2c68 | ||
|
|
68cf1bb81e | ||
|
|
658994c854 | ||
|
|
0af4c2ae47 | ||
|
|
fad77aa188 | ||
|
|
f3beb8e711 | ||
|
|
fcfc2b4eb8 | ||
|
|
f296247cc1 | ||
|
|
ef98516b4f | ||
|
|
df40ffbf12 | ||
|
|
5fc1c44b1e | ||
|
|
b2c469d087 | ||
|
|
42168442b9 | ||
|
|
f01be6aedc | ||
|
|
2de81efa7e | ||
|
|
84d0d8866b | ||
|
|
7056148ada | ||
|
|
6c628e0937 | ||
|
|
31ac27d5a0 | ||
|
|
46d5bc6cf6 | ||
|
|
d917e74e68 | ||
|
|
7b12dcf048 | ||
|
|
062d18c950 | ||
|
|
181c0f32c5 | ||
|
|
ba140a05f7 | ||
|
|
88188ad0c4 | ||
|
|
3609b162be | ||
|
|
eef755ef05 | ||
|
|
c0f39aeb51 | ||
|
|
3a5def6eb7 | ||
|
|
7e148ef197 | ||
|
|
8dde2237a2 | ||
|
|
442ee68599 | ||
|
|
dc91c07fd5 | ||
|
|
c4e5a4616d | ||
|
|
fd86a9e3e8 | ||
|
|
b85141ed2f | ||
|
|
77f9f8e1c4 | ||
|
|
14bc99124d | ||
|
|
d1388ac9c8 | ||
|
|
6566354986 | ||
|
|
ff5ead6284 | ||
|
|
58c2cc70cf | ||
|
|
1aa4431121 | ||
|
|
4e5d5a7701 | ||
|
|
6b18aa085d | ||
|
|
740dd7fe5f | ||
|
|
5b75a6a56a | ||
|
|
3dba8dbc9f | ||
|
|
4471416c30 | ||
|
|
a7229bb022 | ||
|
|
55ea706768 | ||
|
|
6d6ce0ddae | ||
|
|
0f26588c4b | ||
|
|
446f58eb76 | ||
|
|
411553ee91 | ||
|
|
e0d74a6674 | ||
|
|
7d0a3e6ce7 | ||
|
|
29cfb2c299 | ||
|
|
94b0358f18 | ||
|
|
173f42010b | ||
|
|
c5a51607c9 | ||
|
|
57205703bb | ||
|
|
9c5043ca2c | ||
|
|
9899d3f623 | ||
|
|
62d6bf437b | ||
|
|
4dc8debb02 | ||
|
|
f72c47568b | ||
|
|
bdad2264ed | ||
|
|
1c991037ee | ||
|
|
44eafe16c1 | ||
|
|
9ca02b4071 | ||
|
|
0be3b950b7 | ||
|
|
186e9caeaa | ||
|
|
5c17c262ea | ||
|
|
62797acd9a | ||
|
|
b15c002f60 | ||
|
|
9b4e66c868 | ||
|
|
02f6744dab | ||
|
|
5c44fe06dc | ||
|
|
b71952ec86 | ||
|
|
834ae3c762 | ||
|
|
79fab8d70d | ||
|
|
ace476649f | ||
|
|
58aa0d1eaa | ||
|
|
bb45cb24b5 | ||
|
|
2a07964002 | ||
|
|
16233d980f | ||
|
|
9413219206 | ||
|
|
14d62ce180 | ||
|
|
a0556b08be | ||
|
|
bee60a5482 | ||
|
|
5c496c7f02 | ||
|
|
29bd87c8c0 | ||
|
|
0c8ab01d34 | ||
|
|
f15887c9bf | ||
|
|
2481208f31 | ||
|
|
74c92fa601 | ||
|
|
beb6a73bc7 | ||
|
|
000e42e9a6 | ||
|
|
9ad7623a25 | ||
|
|
eb564aec5e | ||
|
|
35bfc9b0cc | ||
|
|
ab440f086a | ||
|
|
1d33bcf229 | ||
|
|
76f7d4e23a | ||
|
|
afa9131da1 | ||
|
|
5b5bca5e91 | ||
|
|
1f11c83006 | ||
|
|
1dd4f60034 | ||
|
|
2013c87ddb | ||
|
|
1009c60911 | ||
|
|
b517944cb5 | ||
|
|
67d6622ae4 | ||
|
|
a1544432db | ||
|
|
e0e1763472 | ||
|
|
b844b56839 | ||
|
|
8b9445c442 | ||
|
|
8193311841 | ||
|
|
c83710fa78 | ||
|
|
e07a87dcae | ||
|
|
2bc0647861 | ||
|
|
2a01dcce29 | ||
|
|
1df4f843fe | ||
|
|
cf0b561a49 | ||
|
|
f1675559ee | ||
|
|
ffd9ec3273 | ||
|
|
66e6d73ad6 | ||
|
|
07c45d0364 | ||
|
|
503780247c | ||
|
|
82c4a0a8d0 | ||
|
|
6a523ff78d | ||
|
|
170e860aa1 | ||
|
|
3e1d50c8d2 | ||
|
|
05b80305dc | ||
|
|
4620cc3acf | ||
|
|
cf8f4aa120 | ||
|
|
f75273ece6 | ||
|
|
42aa2df626 | ||
|
|
be6f3db1ce | ||
|
|
c41dbc8236 | ||
|
|
53694da216 | ||
|
|
9d68b6ef31 | ||
|
|
2300c70314 | ||
|
|
86e924fb88 | ||
|
|
e988216b2b | ||
|
|
23c8fdca00 | ||
|
|
faea09e7ef | ||
|
|
05ecdfe512 | ||
|
|
0b6c38e5e0 | ||
|
|
3137aed017 | ||
|
|
a1c6b5f3ec | ||
|
|
be13e7506a | ||
|
|
0d2f4e715a | ||
|
|
37231a5ffe | ||
|
|
c53ee335ee | ||
|
|
fbd9371884 | ||
|
|
5d57c05217 | ||
|
|
ec38af8c06 | ||
|
|
c7d2017f08 | ||
|
|
3d393df45d | ||
|
|
2f94e27d3b | ||
|
|
e9b57d5731 | ||
|
|
ff4ffbe975 | ||
|
|
458311252b | ||
|
|
3fe67d5c98 | ||
|
|
87f78069e7 | ||
|
|
f8f7a175c4 | ||
|
|
41c028781b | ||
|
|
f6cf4d127d | ||
|
|
131fb57cc9 | ||
|
|
efe97cd01d | ||
|
|
17e1641226 | ||
|
|
be1b44a4ba | ||
|
|
84f27593a0 | ||
|
|
2af95988df | ||
|
|
e2901eb30f | ||
|
|
377d5e2fb6 | ||
|
|
d3adeec109 | ||
|
|
fc3aec7a79 | ||
|
|
78df8ff03a | ||
|
|
c4902f2b9e | ||
|
|
1423ecc9d8 | ||
|
|
5040c947d2 | ||
|
|
c05698c5ec | ||
|
|
3dd18a453a | ||
|
|
2c6ce21c8f | ||
|
|
0803dce328 | ||
|
|
990ae853b0 | ||
|
|
3c421f124e | ||
|
|
44beac799f | ||
|
|
3b4b59c32b | ||
|
|
7f12f398fb | ||
|
|
891b752e69 | ||
|
|
42bf9c1484 | ||
|
|
4d04dd5651 | ||
|
|
9e79e76bab | ||
|
|
a58371e83b | ||
|
|
d2ae13b03f | ||
|
|
c9b687900e | ||
|
|
282633f907 | ||
|
|
e2964bf669 | ||
|
|
fae3b25b7b | ||
|
|
64ee6d529f | ||
|
|
792db26ff1 | ||
|
|
60633a8f3f | ||
|
|
07eeb8e5fe | ||
|
|
851e74d7b8 | ||
|
|
639863a8ef | ||
|
|
a00f610b2d | ||
|
|
9e951e8800 | ||
|
|
71c16615d1 | ||
|
|
b1fc82315c | ||
|
|
6a1df9de4a | ||
|
|
06445dcaa2 | ||
|
|
a344183355 | ||
|
|
2f28a0deab | ||
|
|
e04310a02d | ||
|
|
4efb7efd0e | ||
|
|
9ad0d7fc8c | ||
|
|
71d21ee184 | ||
|
|
9677dfdb90 | ||
|
|
5b361fb289 | ||
|
|
d9c5b840a5 | ||
|
|
ec3a70b5a7 | ||
|
|
a46b00f76b | ||
|
|
f0e9865ca1 | ||
|
|
0d37705435 | ||
|
|
12ac509c40 | ||
|
|
9353a840ee | ||
|
|
8030577fb2 | ||
|
|
dd86a8eabd | ||
|
|
47164463cc | ||
|
|
00b74664f9 | ||
|
|
c91752d575 | ||
|
|
41d4398342 | ||
|
|
f0a5795b68 | ||
|
|
80f314a422 | ||
|
|
a6408d6033 | ||
|
|
5332d3e5f5 | ||
|
|
d41eca4ed5 | ||
|
|
a5ddcf43de | ||
|
|
bd19ab983d | ||
|
|
d88a341884 | ||
|
|
6db50d26b2 | ||
|
|
3906c754f1 | ||
|
|
7783d0f558 | ||
|
|
add69e5e4f | ||
|
|
d32edcca45 | ||
|
|
69dbe27784 | ||
|
|
de201d567f | ||
|
|
26a042d8b9 | ||
|
|
74b82bd934 | ||
|
|
a7576e07a7 | ||
|
|
66b2f16c9e | ||
|
|
cf10e3cf67 | ||
|
|
3ea981b40c | ||
|
|
e1ce9c63ee | ||
|
|
06073af035 | ||
|
|
19f7bba5d8 | ||
|
|
5f7045eb09 | ||
|
|
8610c862a9 | ||
|
|
8c8a4c2222 | ||
|
|
57cc0b0ae3 | ||
|
|
56c5828b02 | ||
|
|
f6d05bc21c | ||
|
|
a5d72a130a | ||
|
|
d6ea179ac4 | ||
|
|
7de1c78fc4 | ||
|
|
5f8f559426 | ||
|
|
79473c2ea4 | ||
|
|
ecf91bdcd4 | ||
|
|
2df818c8cd | ||
|
|
0f6f2bb432 | ||
|
|
9f2835be2d | ||
|
|
01a1265291 | ||
|
|
83ee20d249 | ||
|
|
a0a571cb4b | ||
|
|
d45fd931b3 | ||
|
|
fc0e7f8572 | ||
|
|
cb9c099f36 | ||
|
|
ccb8a0dcc3 | ||
|
|
6a1838bf0d | ||
|
|
df0593aac9 | ||
|
|
aa9b881c54 | ||
|
|
d91cc230e5 | ||
|
|
e0f7b5b5db | ||
|
|
ada7fb45e1 | ||
|
|
368a176c09 | ||
|
|
25d411ecf9 | ||
|
|
6f839142de | ||
|
|
bedb1854f9 | ||
|
|
7f8cc666a5 | ||
|
|
132db911c6 | ||
|
|
d2694048a4 | ||
|
|
722efe9587 | ||
|
|
7e8f702eb3 | ||
|
|
21d6ba2bb3 | ||
|
|
9b39719149 | ||
|
|
2f66c7746c | ||
|
|
b9d6f558cb | ||
|
|
e071ed740f | ||
|
|
0b4020a7e0 | ||
|
|
840b5d37d1 | ||
|
|
49dddc50d3 | ||
|
|
396f75b906 | ||
|
|
6fe9df6ec9 | ||
|
|
16759506e5 | ||
|
|
0688fac419 | ||
|
|
e1112b7abe | ||
|
|
a2133ef6cb | ||
|
|
824816154b | ||
|
|
2fe9866b28 | ||
|
|
2ed6818171 | ||
|
|
0164ad5519 | ||
|
|
244a91fda5 | ||
|
|
becba7861f | ||
|
|
730de56a5f | ||
|
|
72372c022f | ||
|
|
266b1f58f0 | ||
|
|
ede7c4e3ee | ||
|
|
a055cfe924 | ||
|
|
0e83f4515c | ||
|
|
51b2303b0a | ||
|
|
078cac76bf | ||
|
|
07b310d6e6 | ||
|
|
f36eeb182c | ||
|
|
6242f28bc5 | ||
|
|
2553b54432 | ||
|
|
6a54eee330 | ||
|
|
6b676750c0 | ||
|
|
f1d158bbe8 | ||
|
|
96862d64fc | ||
|
|
0f201ecf16 | ||
|
|
479d26f7c7 | ||
|
|
481c2ff781 | ||
|
|
0687120542 | ||
|
|
1bfb680b6b | ||
|
|
974ef29aa1 | ||
|
|
a4d1f56c01 | ||
|
|
35bc074a53 | ||
|
|
4d74611eb1 | ||
|
|
79cfc58533 | ||
|
|
735405a855 | ||
|
|
af40f06524 | ||
|
|
568cd0b39d | ||
|
|
8643c868fd | ||
|
|
eede2945ae | ||
|
|
1fc38c7379 | ||
|
|
271b84f78f | ||
|
|
dd896fea25 | ||
|
|
1e3f69d977 | ||
|
|
341f1c4ce7 | ||
|
|
b8ff0a80f0 | ||
|
|
760194ac80 | ||
|
|
d24fe54d2b | ||
|
|
1cc78112e5 | ||
|
|
0f816e4733 | ||
|
|
04dcacd3a5 | ||
|
|
47e231f061 | ||
|
|
4b7c26e273 | ||
|
|
d45dc473ad | ||
|
|
c766f3c054 | ||
|
|
03941dca8e | ||
|
|
be6e228c36 | ||
|
|
570e087007 | ||
|
|
afac23a79a | ||
|
|
3322f652fe | ||
|
|
d0cf9e02f4 | ||
|
|
84e3e43ae3 | ||
|
|
71b685364f | ||
|
|
3c8446a5e5 | ||
|
|
4e6530d1d8 | ||
|
|
0b87691d50 | ||
|
|
730046f83c | ||
|
|
763002f2fe | ||
|
|
76e630cf61 | ||
|
|
428b6ffbe7 | ||
|
|
78597aacac | ||
|
|
eacebad315 | ||
|
|
c8261d9883 | ||
|
|
eedaa9a2e2 | ||
|
|
fa37acc2fb | ||
|
|
05526e5cdc | ||
|
|
cf2e58bdb9 | ||
|
|
9b953f0942 | ||
|
|
75cc6eff32 | ||
|
|
c271e869c2 | ||
|
|
b71b14efdd | ||
|
|
7ffe17deea |
346
.agents/skills/ohif-test-agent/SKILL.md
Normal file
346
.agents/skills/ohif-test-agent/SKILL.md
Normal file
@ -0,0 +1,346 @@
|
||||
---
|
||||
name: ohif-test-agent
|
||||
description: Generate runnable Playwright E2E tests for the OHIF Viewer using its custom fixture system, page objects, and normalized WebGL viewport coordinates. Use this skill whenever the user asks to write, add, modify, or debug tests in an OHIF/Viewers context — including vague asks like "write a test for X" when working in the OHIF repo, tests touching platform/app/tests/, or anything involving Cornerstone viewports, DICOM studies, measurements, segmentations, or OHIF modes/extensions. Prefer this skill over generic test-writing even if the user doesn't say "Playwright" or "E2E" explicitly.
|
||||
---
|
||||
|
||||
# OHIF Test Agent
|
||||
|
||||
This skill teaches you to generate correct, runnable Playwright end-to-end tests for the OHIF Viewer. Follow the workflow below.
|
||||
|
||||
## Environment model
|
||||
|
||||
This package follows the agentskills.io SKILL.md convention. `SKILL.md` is the entire behavior contract — there is no separate runtime entrypoint.
|
||||
|
||||
## Workflow: how to write a new OHIF test
|
||||
|
||||
1. **Classify the feature.** What area does the test belong to — a measurement tool, segmentation hydration, contour panel interaction, MPR layout, crosshairs, tag browser, etc.? The area determines the mode, the StudyInstanceUID, and the seed spec you'll read.
|
||||
2. **Read the seed spec.** Consult [references/patterns-by-feature.md](references/patterns-by-feature.md) to find the canonical existing spec for that area. Read it end-to-end before writing. This is the single most important step — OHIF specs follow consistent idioms that are easier to mimic than to reconstruct from first principles. (This mirrors Playwright's own agent guidance: use seed tests as the example for generated tests.)
|
||||
3. **Scaffold from the template.** Start from [assets/spec-template.ts](assets/spec-template.ts) — or copy the seed spec and adapt.
|
||||
4. **Look up specifics in the source, not from memory.** The reference files [page-objects.md](references/page-objects.md) and [utilities.md](references/utilities.md) capture the **stable rules** — fixture keys, import conventions, access idioms, the reasons certain things trip people up. They deliberately do not enumerate methods. For the current method surface or a utility's exact signature, open the relevant file under `tests/pages/` or `tests/utils/` — the source evolves, and the source is always right. The seed spec you picked in step 2 is usually the fastest second source, because it co-evolves with the API.
|
||||
5. **Run the test when execution is available.** `pnpm run test:e2e:ci` runs the whole suite, but for iteration use `TEST_ENV=true pnpm exec playwright test tests/YourNew.spec.ts` (or the Playwright VS Code extension). Invoke Playwright directly for targeted flags; `pnpm run test:e2e -- ...` inserts a `--` separator that can prevent Playwright from parsing options such as `--update-snapshots` and `--reporter`.
|
||||
6. **If runtime execution is unavailable, do static validation.** Validate import source, fixture keys, normalized viewport usage, UID/mode pairing, and hydration/tracking prompt handling. Then report clearly that execution was not performed.
|
||||
7. **If it fails, triage before debugging.** Use [references/failure-triage.md](references/failure-triage.md) — most OHIF test failures are timing / hydration, not real regressions.
|
||||
|
||||
## Architecture
|
||||
|
||||
OHIF uses Playwright with a custom fixture system. Tests are **not** vanilla Playwright — they import `test`, `expect`, and utilities from `./utils`, which re-exports an extended test runner that injects page objects.
|
||||
|
||||
```text
|
||||
playwright.config.ts → Chromium-only, port 3335, data-cy as testId
|
||||
└─ tests/utils/fixture.ts → Extends playwright-test-coverage, injects page objects
|
||||
└─ tests/*.spec.ts → Each imports { test, expect, ... } from './utils'
|
||||
├─ tests/pages/ → Page objects (ViewportPageObject, MainToolbarPageObject, …)
|
||||
└─ tests/utils/ → Utilities (visitStudy, checkForScreenshot, screenShotPaths, …)
|
||||
```
|
||||
|
||||
Why the custom fixture matters: the page objects are created for each test and bound to the right Playwright `page`. If you `new ViewportPageObject(page)` manually, you skip the fixture wiring and some sub-objects won't resolve correctly.
|
||||
|
||||
### Import rule
|
||||
|
||||
```ts
|
||||
// Correct
|
||||
import { test, expect, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
|
||||
|
||||
// Wrong — will compile but fixtures won't be injected
|
||||
import { test, expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
A few utilities (`press`, `downloadAsString`, the `assert*` helpers) are NOT re-exported from `./utils`. See [references/utilities.md](references/utilities.md) for the correct import path per utility.
|
||||
|
||||
## The viewport is WebGL
|
||||
|
||||
OHIF renders medical images onto a WebGL canvas. You cannot query *canvas* pixels by CSS selector. Use **normalized coordinates** (0–1 range, top-left is `{x:0, y:0}`) for clicks and drags, and **visual regression** (screenshot comparison) for canvas assertions. (Not everything in the viewport is canvas — some overlays render as SVG you *can* query via DOM, e.g. a vector overlay's color through `getSvgAttribute`. The canvas rule is about raster output painted onto the WebGL surface.)
|
||||
|
||||
```ts
|
||||
const activeViewport = await viewportPageObject.active;
|
||||
|
||||
await activeViewport.normalizedClickAt([{ x: 0.5, y: 0.5 }]); // click center
|
||||
await activeViewport.normalizedClickAt([{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.7 }]); // draw two points
|
||||
await activeViewport.normalizedClickAt([{ x: 0.5, y: 0.5 }], 'right'); // right-click
|
||||
await activeViewport.normalizedDragAt({
|
||||
start: { x: 0.3, y: 0.3 },
|
||||
end: { x: 0.7, y: 0.7 },
|
||||
});
|
||||
```
|
||||
|
||||
Pixel coordinates (`clickAt`, `doubleClickAt`) exist but prefer normalized for portability across viewport sizes.
|
||||
|
||||
For DOM-rendered state (panel counts, dialog text, overlay text values, button enabled states), assert directly:
|
||||
|
||||
```ts
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('17/');
|
||||
const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||
expect(count).toBe(1);
|
||||
```
|
||||
|
||||
## Study loading lifecycle
|
||||
|
||||
Every test follows this sequence. Skipping steps causes flakiness:
|
||||
|
||||
```ts
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
|
||||
const mode = 'viewer';
|
||||
await visitStudy(page, studyInstanceUID, mode, 2000); // 2s delay is the community norm
|
||||
});
|
||||
```
|
||||
|
||||
`visitStudy` navigates to `/{mode}/ohif?StudyInstanceUIDs={uid}`, waits for `domcontentloaded`, then `networkidle`, then the explicit delay. Default delay is `0`, but most specs pass `2000` to let the first render settle.
|
||||
|
||||
Delay by scene type (observed across the current suite, not a rule to apply blindly):
|
||||
|
||||
| Scene | Delay |
|
||||
|-------|-------|
|
||||
| Default (viewer mode, 2D/MPR/3D layouts, crosshairs) | `2000` |
|
||||
| `mode: 'tmtv'` | `10000` — PET fusion + SUV calculation takes noticeably longer |
|
||||
|
||||
Start at the convention for your scene; ramp only if the test flakes on initial render. 3D layouts in `viewer` mode already stay at `2000` — the stabilization problem there is solved with `attemptAction(() => reduce3DViewportSize(page), 10, 100)`, not with a longer `visitStudy` delay.
|
||||
|
||||
If the study has DICOM SEG, RT, or SR data, OHIF asks whether to hydrate. Handle it:
|
||||
|
||||
```ts
|
||||
await leftPanelPageObject.loadSeriesByModality('SEG'); // or 'RTSTRUCT', 'SR'
|
||||
await page.waitForTimeout(3000); // allow the prompt to appear
|
||||
await expect(DOMOverlayPageObject.viewport.segmentationHydration.locator).toBeVisible();
|
||||
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
|
||||
```
|
||||
|
||||
The first measurement you create triggers a "start tracking?" prompt:
|
||||
|
||||
```ts
|
||||
await DOMOverlayPageObject.viewport.measurementTracking.confirm.click();
|
||||
```
|
||||
|
||||
For 3D / MPR scenes, wrap stabilization in `attemptAction(() => reduce3DViewportSize(page), 10, 100)` or insert a `page.waitForTimeout(...)` after the layout change before asserting.
|
||||
|
||||
## Wait for renders, don't sleep
|
||||
|
||||
`page.waitForTimeout(...)` after an action that re-renders the viewport is a smell. The viewports tell us when they're done — use that signal. `tests/utils/waitForViewportsRendered.ts` exposes three helpers, all barrel-exported from `./utils`:
|
||||
|
||||
- `waitForViewportRenderCycle(page)` — wait for the next full cycle: a viewport enters `needsRender`, then **all** viewports report `rendered` (and volumes are loaded, by default).
|
||||
- `waitForViewportsRendered(page)` — only the second half: wait until all viewports are `rendered`. Use this when the action has already requested a render before you started waiting (e.g. a layout change or `loadSeriesByDescription`).
|
||||
- `waitForAnyViewportNeedsRender(page)` — only the first half. Rarely needed directly.
|
||||
|
||||
The canonical idiom — **start the watcher before the action, await it after**:
|
||||
|
||||
```ts
|
||||
// start watching for the next render cycle
|
||||
const viewportRenderCycle = waitForViewportRenderCycle(page);
|
||||
|
||||
await action(); // e.g. segmentationHydration.yes.click(), layoutSelection.MPR.click(), addSegmentation, etc.
|
||||
|
||||
// wait for the render to finish
|
||||
await viewportRenderCycle;
|
||||
|
||||
await check(); // e.g. checkForScreenshot, count assertion, overlay text
|
||||
```
|
||||
|
||||
Why "start before"? `waitForViewportRenderCycle` first waits for a viewport to enter `needsRender`. If you start it **after** the action, that transition may already be over and you'll hang until the timeout. Starting it first captures the cycle the action is about to trigger.
|
||||
|
||||
When to use which:
|
||||
|
||||
| Situation | Helper |
|
||||
|-----------|--------|
|
||||
| Click that triggers a re-render and you want to assert after | `waitForViewportRenderCycle(page)` started before the click |
|
||||
| Layout switch / series load — render already in flight | `await waitForViewportsRendered(page)` after the call |
|
||||
| Compose with another await (e.g. screenshot the same time as load) | Save the promise, `await` it later |
|
||||
|
||||
Replace patterns like this:
|
||||
|
||||
```ts
|
||||
// ❌ Sleep-and-pray
|
||||
await action();
|
||||
await page.waitForTimeout(5000);
|
||||
await checkForScreenshot(...);
|
||||
|
||||
// ✅ Wait on the actual signal
|
||||
const cycle = waitForViewportRenderCycle(page);
|
||||
await action();
|
||||
await cycle;
|
||||
await checkForScreenshot(...);
|
||||
```
|
||||
|
||||
This shaves real wall-clock time off the suite and removes a class of flake (sleep too short → flake; sleep too long → slow). `tests/SEGHydrationFromMPR.spec.ts` is the canonical seed for this pattern.
|
||||
|
||||
Caveats:
|
||||
- These helpers wait on Cornerstone viewport state. They won't help for purely DOM-side state (panel rows appearing, dialogs opening) — for those, prefer `expect(locator).toHaveCount(n)` / `toBeVisible()` which auto-retry, or `expect.toPass({ timeout })`.
|
||||
- For some actions (hanging-protocol changes are the documented example) the viewport doesn't transition through `needsRender` synchronously — those still need a short `waitForTimeout`. The source comment in `waitForViewportsRendered.ts` calls this out.
|
||||
|
||||
## Fixture-injected page objects
|
||||
|
||||
Destructure these from the test function argument. **Never `new` them manually.**
|
||||
|
||||
```ts
|
||||
test('my test', async ({
|
||||
page,
|
||||
viewportPageObject,
|
||||
mainToolbarPageObject,
|
||||
leftPanelPageObject,
|
||||
rightPanelPageObject,
|
||||
DOMOverlayPageObject, // note the capital D — this matches the fixture key
|
||||
notFoundStudyPageObject,
|
||||
}) => { ... });
|
||||
```
|
||||
|
||||
Two page objects are **not** fixture-injected:
|
||||
- `DataOverlayPageObject` — reach via `viewportPageObject.getById(id).overlayMenu.dataOverlay`.
|
||||
- `DicomTagBrowserPageObject` — reach via `DOMOverlayPageObject.dialog.dicomTagBrowser`.
|
||||
|
||||
See [references/page-objects.md](references/page-objects.md) for fixture rules and a map of which file covers which concern; read the `.ts` file under `tests/pages/` for the current method surface.
|
||||
|
||||
## When the control you need has no page object yet
|
||||
|
||||
A spec must not reach for `page.getByTestId(...)` / `getByRole(...)` directly for
|
||||
application controls. If the button, menu, dialog, or field you need isn't already
|
||||
exposed by a page object, **add it to one — or create a new page object — instead of
|
||||
inlining a raw selector.** Raw selectors in a spec are the clearest sign a test was
|
||||
written without reading the existing suite: they duplicate locators, bypass the
|
||||
fixture wiring, and rot silently when the DOM changes.
|
||||
|
||||
Where new coverage goes:
|
||||
|
||||
| What you need | Where it belongs |
|
||||
|---|---|
|
||||
| A toolbar button or tool (Zoom, Pan) | a getter on `MainToolbarPageObject`, next to `crosshairs` / `measurementTools` |
|
||||
| A menu, prompt, context menu, or small dialog | `DOMOverlayPageObject` |
|
||||
| A substantial dialog with its own fields (User Preferences) | its **own** page object class, reached through `DOMOverlayPageObject` — follow `DicomTagBrowserPageObject` (`DOMOverlayPageObject.dialog.dicomTagBrowser`) |
|
||||
| Each field/row inside that dialog | a method or sub-object on the dialog's page object — not a raw selector in the spec |
|
||||
| A side-panel control | `LeftPanelPageObject` / `RightPanelPageObject` |
|
||||
|
||||
If the control has no `data-cy`, **add `data-cy` to the source component** and target
|
||||
it — don't fall back to `getByRole`/text selectors, which are brittle and
|
||||
locale-sensitive (`testIdAttribute` is `data-cy`, so `getByTestId('Zoom')` resolves
|
||||
`[data-cy="Zoom"]`). Call out any `data-cy` you add so it ships in the same PR.
|
||||
|
||||
**Worked example — "set the Zoom hotkey in User Preferences":** the options menu, the
|
||||
preferences dialog, each preference field, and the Zoom toolbar button should all be
|
||||
page-object surface — e.g. `mainToolbarPageObject.zoom`,
|
||||
`DOMOverlayPageObject.optionsMenu.settings.click()`,
|
||||
`DOMOverlayPageObject.dialog.userPreferences.hotkey('Zoom').set('q')`. The spec then
|
||||
reads as intent, not as a pile of `getByTestId` calls.
|
||||
|
||||
## Assert the effect, not just the attribute
|
||||
|
||||
Prefer asserting the actual rendered result over a proxy attribute. Activating Zoom and
|
||||
checking `data-active="true"` confirms the *button* toggled — not that zoom works. Drag
|
||||
on the viewport and assert the image actually zoomed (a viewport-scoped screenshot, or a
|
||||
measurable state change). Attribute checks are fine as a secondary signal, not the whole
|
||||
test.
|
||||
|
||||
## Visual regression
|
||||
|
||||
**Direction:** the suite is moving off *full-app* screenshots — not off screenshots
|
||||
altogether. "Avoid screenshots" means: don't screenshot the whole page, and don't
|
||||
screenshot something that has a faithful DOM/state signal. It does **not** mean avoid
|
||||
screenshots for output that is genuinely canvas-only — for that output a screenshot is
|
||||
the correct and required tool, and you should use it without apology. Older specs that
|
||||
screenshot the whole page are the legacy pattern being phased out; viewport-scoped
|
||||
screenshots are not.
|
||||
|
||||
### Screenshot vs. DOM assertion — how to choose
|
||||
|
||||
Reach for the cheapest *faithful* signal, in this order:
|
||||
|
||||
1. **A faithful DOM/SVG/state signal exists → assert on it.** Panel counts, dialog and
|
||||
overlay text, enabled/disabled state, and any overlay that renders as SVG (a vector
|
||||
overlay's color is readable via `getSvgAttribute`) all have a DOM representation — assert
|
||||
on it directly, no screenshot.
|
||||
2. **The thing under test is painted onto the WebGL canvas with no DOM representation → a
|
||||
screenshot is correct and required.** Raster output on the canvas exposes no attribute to
|
||||
read for a painted pixel. Scope a `checkForScreenshot` to the viewport (pane or grid) and
|
||||
assert it — this is the right tool, not a last resort, whenever what you're verifying is
|
||||
the rendered canvas itself.
|
||||
3. **Never substitute a service/state read for a render assertion.** Reading a service's
|
||||
state (any `window.services...`) asserts the *data model*, not the pixels the user sees —
|
||||
it passes even when rendering is broken. `page.evaluate(() => window.services...)` is an
|
||||
escape hatch for *setup*, not for *appearance* assertions.
|
||||
|
||||
For anything drawn onto the WebGL canvas with no DOM signal, compare a screenshot scoped to a specific viewport or the viewport grid:
|
||||
|
||||
```ts
|
||||
await checkForScreenshot({
|
||||
page,
|
||||
locator: viewportPageObject.grid, // scope to the viewport grid — not the whole page
|
||||
screenshotPath: screenShotPaths.length.lengthDisplayedCorrectly,
|
||||
});
|
||||
```
|
||||
|
||||
`checkForScreenshot` retries up to 10 times at 500 ms intervals. Use `screenShotPaths.<category>.<name>` rather than a hand-typed string — the tree of valid keys lives in `tests/utils/screenShotPaths.ts`.
|
||||
|
||||
Rules (apply to all new screenshot assertions):
|
||||
|
||||
- **Use the object form.** The positional form is legacy; don't introduce it in new code, and don't treat existing positional-form usage as a pattern to copy.
|
||||
- **Never screenshot the full app.** Full-page screenshots include panels, toolbars, and dialogs that drift independently of what's under test and make baselines fragile. Scope by passing a `locator` — `viewportPageObject.grid` for the grid, or a specific viewport pane. A bare `normalizedClip: { x: 0, y: 0, width: 1, height: 1 }` with no `locator` is **not** scoping — it clips to the full page. Use `normalizedClip` only to target a sub-region *of a locator* (e.g. a scrollbar strip). If you reach for `fullPage: true`, stop and pick a locator.
|
||||
- **Do not tune `maxDiffPixelRatio` or `threshold`** to make a screenshot pass. If a baseline mismatches, regenerate it after a human review of the diff, or fix the underlying flake.
|
||||
|
||||
## Playwright config facts worth remembering
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| `baseURL` | `http://localhost:3335` | OHIF e2e uses 3335, not 3000 |
|
||||
| `testIdAttribute` | `data-cy` | `getByTestId(...)` maps to `[data-cy="..."]` |
|
||||
| `browser` | Chromium only | Firefox/WebKit disabled (SharedArrayBuffer + stability) |
|
||||
| `retries` | 3 in CI, 0 locally | Flaky rendering needs CI retries |
|
||||
| `workers` | 6 in CI, undefined locally | Parallel execution |
|
||||
| `globalTimeout` / `timeout` | 800_000 ms | Medical image loads are slow |
|
||||
| `actionTimeout` | 10_000 ms | Per-action cap |
|
||||
| `webServer.command` | `cross-env APP_CONFIG=config/e2e.js COVERAGE=true OHIF_PORT=3335 nyc yarn start` | e2e config + coverage |
|
||||
|
||||
## Test data: which study for which test
|
||||
|
||||
| StudyInstanceUID | Mode | Used for |
|
||||
|------------------|------|----------|
|
||||
| `1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5` | `viewer` | General measurements, annotations, context menu |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785` | `viewer` | 3D, MPR, crosshairs |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.256467663913010332776401703474716742458` | `viewer` or `segmentation` | Labelmap SEG |
|
||||
| `1.2.840.113619.2.290.3.3767434740.226.1600859119.501` | `viewer` / `segmentation` / `tmtv` | RTSTRUCT/contour and TMTV |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.7695.4007.324475281161490036195179843543` | `viewer` | SR hydration |
|
||||
|
||||
Full mapping in [references/patterns-by-feature.md](references/patterns-by-feature.md). **Do not invent UIDs** — they must exist on the e2e data server.
|
||||
|
||||
## Rules (short, so they're actually read)
|
||||
|
||||
1. Import `test`, `expect`, and utilities from `./utils`, not `@playwright/test`.
|
||||
2. Destructure fixture-injected page objects; don't `new` them.
|
||||
3. Use normalized coordinates (0–1) for viewport interactions.
|
||||
4. Use `visitStudy` with a real UID, correct mode, and a non-zero delay (2000 is conventional).
|
||||
5. Handle hydration and measurement-tracking prompts where applicable.
|
||||
6. Choose the faithful signal: DOM/SVG assertions where the rendered result has one (panels, dialogs, overlay text, SVG/vector overlays), a viewport-scoped screenshot when what you're verifying is canvas-only raster output, and never a `window.services` state read standing in for a render check. Screenshots use the object form, scoped via a `locator` — never the full app.
|
||||
7. Use `data-cy` selectors (already wired via `testIdAttribute`).
|
||||
8. When an assertion needs retry tolerance, wrap it in `expect.toPass({ timeout })`.
|
||||
9. Test in the correct mode — segmentation tools aren't available in `viewer` mode.
|
||||
10. If a utility isn't exported from `./utils`, import from the deeper path (see [references/utilities.md](references/utilities.md)).
|
||||
11. After an action that re-renders the viewport, prefer `waitForViewportRenderCycle(page)` (started before the action) over `page.waitForTimeout(...)`. See the "Wait for renders, don't sleep" section.
|
||||
12. Don't inline raw `page.getByTestId(...)` / `getByRole(...)` for app controls. If a control has no page object, create or augment one (see "When the control you need has no page object yet"), adding a `data-cy` to the source if needed.
|
||||
13. Assert the actual effect (e.g. the image zoomed), not just a proxy attribute like `data-active`.
|
||||
|
||||
## Pre-output self-check (mandatory)
|
||||
|
||||
Before returning a generated OHIF test, confirm all items:
|
||||
|
||||
1. Imports `test`/`expect` from `./utils` (not `@playwright/test`).
|
||||
2. Uses fixture-injected keys and exact casing (especially `DOMOverlayPageObject`).
|
||||
3. Uses normalized viewport interactions (`normalizedClickAt` / `normalizedDragAt`) unless there is a strong reason otherwise.
|
||||
4. Uses a valid canonical StudyInstanceUID and compatible mode.
|
||||
5. Handles hydration or measurement tracking prompts when the workflow requires them.
|
||||
6. Uses the faithful signal for each assertion — DOM/SVG where the result has a DOM representation, a viewport-scoped screenshot when what's verified is canvas-only raster output, and never a `window.services` state read in place of a render check. Any `checkForScreenshot` call uses the object form, scoped via a `locator` (viewport pane or grid) — no full-app screenshots.
|
||||
7. Replaces `page.waitForTimeout(...)` after viewport-rendering actions with `waitForViewportRenderCycle(page)` (started before the action) — keeps `waitForTimeout` only for non-render waits like the hydration prompt in `beforeEach`.
|
||||
8. If execution was skipped, states that explicitly and provides concrete run commands.
|
||||
9. Every application control is reached through a page object — no raw `getByTestId`/`getByRole` in the spec for buttons, menus, dialogs, or fields. Any control not already covered was added to the right page object (or a new one), with a source `data-cy` if it lacked one.
|
||||
10. Assertions verify the real effect where feasible (e.g. the image visibly zoomed), not only an attribute toggle.
|
||||
|
||||
## Output contract (for non-executing agents)
|
||||
|
||||
When execution cannot be performed in the current environment, the response should include:
|
||||
|
||||
1. The test code.
|
||||
2. Assumptions made (if any).
|
||||
3. Static checks that were verified.
|
||||
4. What still must be run locally and exact commands to run.
|
||||
|
||||
## When to consult each reference
|
||||
|
||||
- **Before writing** → [references/patterns-by-feature.md](references/patterns-by-feature.md). Pick the seed spec for the feature area and read it. The seed spec is the closest thing to a live API example because it co-evolves with the code.
|
||||
- **For a stable rule or idiom** (fixture keys, import paths, panel-access order, capital-D quirk, object-param convention) → [references/page-objects.md](references/page-objects.md), [references/utilities.md](references/utilities.md).
|
||||
- **For a method name, property, or signature** → read the source under `tests/pages/` or `tests/utils/`. Do not rely on a static table for these; they drift as the code is refactored.
|
||||
- **When a test fails** → [references/failure-triage.md](references/failure-triage.md).
|
||||
54
.agents/skills/ohif-test-agent/assets/spec-template.ts
Normal file
54
.agents/skills/ohif-test-agent/assets/spec-template.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {
|
||||
checkForScreenshot,
|
||||
expect,
|
||||
screenShotPaths,
|
||||
test,
|
||||
visitStudy,
|
||||
waitForViewportRenderCycle,
|
||||
} from './utils';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Pick the right UID + mode for your feature (see references/patterns-by-feature.md)
|
||||
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
|
||||
const mode = 'viewer';
|
||||
await visitStudy(page, studyInstanceUID, mode, 2000);
|
||||
});
|
||||
|
||||
test.describe('FEATURE NAME', () => {
|
||||
test('describes the behaviour in one sentence', async ({
|
||||
page,
|
||||
viewportPageObject,
|
||||
mainToolbarPageObject,
|
||||
rightPanelPageObject,
|
||||
DOMOverlayPageObject, // capital D on purpose
|
||||
}) => {
|
||||
// 1. Arrange — select a tool, open a panel, etc.
|
||||
|
||||
// 2. Act — interact with the viewport.
|
||||
// Prefer normalized (0–1) coordinates:
|
||||
// const activeViewport = await viewportPageObject.active;
|
||||
// await activeViewport.normalizedClickAt([{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.7 }]);
|
||||
|
||||
// For actions that re-render the viewport, gate on the render cycle
|
||||
// instead of sleeping — start the watcher BEFORE the action:
|
||||
// const cycle = waitForViewportRenderCycle(page);
|
||||
// await action();
|
||||
// await cycle;
|
||||
|
||||
// 3. Handle prompts (first measurement prompts for tracking; SEG/RT/SR prompts for hydration)
|
||||
// await DOMOverlayPageObject.viewport.measurementTracking.confirm.click();
|
||||
|
||||
// 4. Assert canvas output via visual regression — object form, scoped to the
|
||||
// viewport via a locator (not the full page). normalizedClip is only for
|
||||
// clipping to a sub-region of that locator.
|
||||
// await checkForScreenshot({
|
||||
// page,
|
||||
// locator: viewportPageObject.grid,
|
||||
// screenshotPath: screenShotPaths.YOUR_CATEGORY.YOUR_KEY,
|
||||
// });
|
||||
|
||||
// 5. Assert DOM state directly
|
||||
// const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||
// expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
72
.agents/skills/ohif-test-agent/references/failure-triage.md
Normal file
72
.agents/skills/ohif-test-agent/references/failure-triage.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Failure triage
|
||||
|
||||
Before debugging, classify. Most OHIF test failures are timing or hydration — not real regressions.
|
||||
|
||||
| Category | Symptom | Fix |
|
||||
|----------|---------|-----|
|
||||
| Timing | Element not visible, action timeout | Add / increase the `delay` param of `visitStudy`; for actions that re-render viewports, use `waitForViewportRenderCycle(page)` (started before the action) instead of `waitForTimeout`; wrap the assertion in `expect.toPass({ timeout })` |
|
||||
| Selector | Element not found | Verify `data-cy` on the target; confirm the panel is open (`toggle()` / `select()` before interacting); check for capital `D` in `DOMOverlayPageObject` when destructuring |
|
||||
| Hydration | Segmentation/RT/SR not interactive | Ensure the `segmentationHydration.yes.click()` fired; wait for an observable hydrated state such as measurement/segment rows or the target series overlay, then wait for the resulting viewport render |
|
||||
| Data | Study not found, empty viewport | Confirm the UID is in the canonical list (see [patterns-by-feature.md](patterns-by-feature.md)); confirm the mode supports the feature (segmentation tools aren't in `viewer` mode) |
|
||||
| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `TEST_ENV=true pnpm exec playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. |
|
||||
| Real regression | Feature is actually broken | Report as a bug — this is the test doing its job |
|
||||
|
||||
## Prefer render-cycle waits over sleeps
|
||||
|
||||
If you're tempted to add `await page.waitForTimeout(2000)` after an action, ask whether the action re-rendered the viewport. If it did, use:
|
||||
|
||||
```ts
|
||||
const cycle = waitForViewportRenderCycle(page);
|
||||
await action();
|
||||
await cycle;
|
||||
await check();
|
||||
```
|
||||
|
||||
The watcher must be created **before** the action — it waits for `needsRender` first, and that transition is gone by the time the action returns. See the "Wait for renders, don't sleep" section in [SKILL.md](../SKILL.md) and `tests/SEGHydrationFromMPR.spec.ts`.
|
||||
|
||||
### When the cycle helper times out at `waitForAnyViewportNeedsRender`
|
||||
|
||||
Symptom: the test fails inside `waitForAnyViewportNeedsRender` after 5s, with the action having actually completed in the UI. The action just doesn't transition the viewport through `needsRender` synchronously. Known cases:
|
||||
|
||||
- Hanging-protocol changes.
|
||||
- **RTSTRUCT / contour segmentation hydration confirm.** SEG (labelmap) hydration does fire `needsRender`; contour does not. The fix is to gate on the actual end-state — for hydration in `beforeEach`, `await expect(page.getByTestId('data-row')).toHaveCount(N)` is the right wait.
|
||||
|
||||
Don't react by raising the cycle's timeout — the transition isn't coming. Replace the cycle wrapper with an auto-retrying DOM/SVG assertion, or `expect.toPass({ timeout })` around the assertion block.
|
||||
|
||||
An immediate `waitForViewportsRendered(page)` can also return too early when a click dispatches work through an asynchronous state machine: the old viewport is already `rendered` before the new series or annotations are applied. In that case, first wait for the target state (for example, hydrated measurement rows or the expected series overlay), then call `waitForViewportsRendered(page)` to settle that state's render.
|
||||
|
||||
## The `toPass` pattern
|
||||
|
||||
When an assertion needs to wait for async render / propagation:
|
||||
|
||||
```ts
|
||||
await expect(async () => {
|
||||
await rightPanelPageObject.labelMapSegmentationPanel.panel.segmentByText('Spleen').click();
|
||||
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('17/');
|
||||
}).toPass({ timeout: 10_000 });
|
||||
```
|
||||
|
||||
`toPass` reruns the assertion block until it succeeds or the timeout expires — cleaner than a hand-rolled retry loop and surfaces the last failure reason if it times out.
|
||||
|
||||
## Common `DOMOverlayPageObject` mistake
|
||||
|
||||
The fixture key is capital-D `DOMOverlayPageObject`, not lowercase. If your destructure is silently `undefined`, check the casing.
|
||||
|
||||
## `press` import mistake
|
||||
|
||||
`press` is NOT re-exported from `./utils`. `import { press } from './utils'` resolves to `undefined` and fails at runtime. Use:
|
||||
|
||||
```ts
|
||||
import { press } from './utils/keyboardUtils';
|
||||
await press({ page, key: 'ArrowDown', nTimes: 50 }); // object param, not (page, key)
|
||||
```
|
||||
|
||||
## Visual regression iteration
|
||||
|
||||
Screenshots live under `tests/screenshots/chromium/<testFilePath>/`. To accept new output as the baseline:
|
||||
|
||||
```sh
|
||||
TEST_ENV=true pnpm exec playwright test tests/YourSpec.spec.ts --update-snapshots
|
||||
```
|
||||
|
||||
Review the resulting PNGs carefully — an agent-accepted baseline that's subtly wrong is worse than a failing test.
|
||||
120
.agents/skills/ohif-test-agent/references/page-objects.md
Normal file
120
.agents/skills/ohif-test-agent/references/page-objects.md
Normal file
@ -0,0 +1,120 @@
|
||||
# OHIF Page Object guide
|
||||
|
||||
> This file documents the **stable structural rules** of the page object system. For the current list of methods and properties on any class, **read the source under `tests/pages/`** — it is always authoritative, and it evolves as the product does. A static method table in a reference file goes stale the moment someone refactors; the source does not.
|
||||
|
||||
## How to discover the API of a page object
|
||||
|
||||
1. Find the relevant class in `tests/pages/`. File names match class names.
|
||||
2. Read it end-to-end once — most are under a few hundred lines.
|
||||
3. Some classes compose sub-objects (e.g. `RightPanelPageObject` holds a measurementsPanel, contourSegmentationPanel, labelMapSegmentationPanel, tmtvPanel, etc.). Those sub-objects usually live in the same file or a sibling under `tests/pages/`.
|
||||
4. To see how a method is actually used, grep `tests/` or open the seed spec listed in [patterns-by-feature.md](patterns-by-feature.md). Real usage beats a synthesized signature every time.
|
||||
|
||||
Do not try to memorize a method surface from this file — it intentionally does not list one. It lists only the rules you cannot derive from the source by reading a single file.
|
||||
|
||||
---
|
||||
|
||||
## Stable rules
|
||||
|
||||
### Fixture keys (case-sensitive)
|
||||
|
||||
These are injected via `tests/utils/fixture.ts`. Destructure them from the test function's first argument — do not `new` them, because the fixture wires sub-objects to the correct `page` and hand-constructed instances skip that wiring.
|
||||
|
||||
- `viewportPageObject`
|
||||
- `mainToolbarPageObject`
|
||||
- `leftPanelPageObject`
|
||||
- `rightPanelPageObject`
|
||||
- `DOMOverlayPageObject` — **capital D**. A silent `undefined` destructure is almost always a casing typo here.
|
||||
- `notFoundStudyPageObject`
|
||||
|
||||
If the fixture file is updated and new keys are added, they will show up there first — check it if something feels missing.
|
||||
|
||||
### Non-fixture page objects
|
||||
|
||||
Some page object classes are not fixture-injected. They are reached through an injected fixture:
|
||||
|
||||
- `DicomTagBrowserPageObject` → via `DOMOverlayPageObject.dialog.dicomTagBrowser`
|
||||
- `DataOverlayPageObject` → via `viewportPageObject.getById(viewportId).overlayMenu.dataOverlay`
|
||||
|
||||
Both can be constructed manually (`new DataOverlayPageObject(page)`) if a test really needs a fresh instance, but the accessor path is the idiomatic one.
|
||||
|
||||
### Viewport wrapper vs. viewport instance
|
||||
|
||||
`viewportPageObject` is a **wrapper**. You almost always want a specific viewport out of it first:
|
||||
|
||||
- `await viewportPageObject.active` — the currently focused viewport
|
||||
- `viewportPageObject.getAll()` — every viewport in the grid
|
||||
- `viewportPageObject.getNth(i)` — zero-indexed
|
||||
- `viewportPageObject.getById(cornerstoneViewportId)` — e.g. `'default'`, `'ctAXIAL'`
|
||||
|
||||
The object these return is the one with `normalizedClickAt`, `normalizedDragAt`, `overlayText`, `nthAnnotation`, etc. Reach for the viewport instance first, then call methods on it.
|
||||
|
||||
### Panel access order
|
||||
|
||||
Every `rightPanelPageObject` sub-panel follows the same three-step idiom: **open the side panel, `.select()` the sub-panel tab, then interact with `.panel.*`**. Skipping either of the first two is the most common cause of "element not found".
|
||||
|
||||
```ts
|
||||
await rightPanelPageObject.toggle();
|
||||
await rightPanelPageObject.measurementsPanel.select();
|
||||
const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||
```
|
||||
|
||||
The exact row/action methods vary by panel — check the source file for the one you need.
|
||||
|
||||
### Layout identifiers are camelCase JS properties
|
||||
|
||||
`mainToolbarPageObject.layoutSelection.<layout>.click()` — access layouts by camelCase property name (e.g. `threeDFourUp`, `axialPrimary`), not with bracket-escaped DICOM-ish strings like `['3DFourUp']`. This is a convention enforced by how the class exposes its tools.
|
||||
|
||||
### Sub-tools auto-open their dropdown
|
||||
|
||||
Tools nested inside a toolbar dropdown (measurement tools, more tools, layouts) each expose a `.click()` that opens the parent menu for you. You almost never need to open the menu first. `await mainToolbarPageObject.measurementTools.length.click()` does both the expand and the select.
|
||||
|
||||
### When the control you need isn't covered yet — create or augment
|
||||
|
||||
The page objects here cover what the suite currently exercises. When your test needs a
|
||||
control that isn't exposed, **extend the page object system rather than dropping a raw
|
||||
`page.getByTestId(...)` into the spec.** A raw selector in a spec is the clearest tell
|
||||
that the author didn't read the existing tests — it duplicates a locator that should
|
||||
live in one place and bypasses the fixture wiring.
|
||||
|
||||
| What you need | Where it goes | Precedent to copy |
|
||||
|---|---|---|
|
||||
| A toolbar button/tool (Zoom, Pan) | a getter on `MainToolbarPageObject` | `crosshairs`, `measurementTools` |
|
||||
| A menu / prompt / context menu / small dialog | `DOMOverlayPageObject` | `viewport.measurementTracking`, `dialog.input` |
|
||||
| A large dialog with its own fields (User Preferences) | a **new** page object class reached via `DOMOverlayPageObject` | `DicomTagBrowserPageObject` (`DOMOverlayPageObject.dialog.dicomTagBrowser`) |
|
||||
| Individual fields/rows in that dialog | methods/sub-objects on the dialog's page object | `DicomTagBrowserPageObject.seriesSelect` |
|
||||
| Side-panel controls | `LeftPanelPageObject` / `RightPanelPageObject` | existing sub-panels |
|
||||
|
||||
Two more expectations:
|
||||
|
||||
- **Missing `data-cy`?** Add it to the source component and target it; don't fall back
|
||||
to `getByRole`/text. `testIdAttribute` is `data-cy`, so `getByTestId('Zoom')` resolves
|
||||
`[data-cy="Zoom"]`. Mention any attribute you added so it ships in the same PR.
|
||||
- **Mirror the existing shape.** A new getter should look like its neighbors (return a
|
||||
locator, or a small object with `.click()` etc.) so the new surface is
|
||||
indistinguishable from what was already there.
|
||||
|
||||
Example — a "set the Zoom hotkey" test should make `mainToolbarPageObject.zoom`, an
|
||||
options-menu accessor on `DOMOverlayPageObject`, and a `userPreferences` dialog page
|
||||
object (with a per-hotkey field accessor) exist — then the spec reads as steps, not
|
||||
selectors.
|
||||
|
||||
---
|
||||
|
||||
## Page object map — what each class is *for*
|
||||
|
||||
This table exists to help you pick the right file to open, not to enumerate methods. Source of truth for any specific method remains the `.ts` file.
|
||||
|
||||
| Class | File | Covers |
|
||||
|-------|------|--------|
|
||||
| ViewportPageObject | `tests/pages/ViewportPageObject.ts` | Cornerstone viewports — clicks, drags, overlays, annotations, crosshairs |
|
||||
| MainToolbarPageObject | `tests/pages/MainToolbarPageObject.ts` | Top toolbar — measurement tools, more tools, layouts, crosshairs, pan |
|
||||
| LeftPanelPageObject | `tests/pages/LeftPanelPageObject.ts` | Study browser — thumbnails, load by modality or description |
|
||||
| RightPanelPageObject | `tests/pages/RightPanelPageObject.ts` | Side panels — measurements, contour seg, labelmap seg, TMTV, microscopy |
|
||||
| DOMOverlayPageObject | `tests/pages/DOMOverlayPageObject.ts` | DOM overlays — dialogs, hydration/tracking prompts, context menus, tag-browser accessor |
|
||||
| NotFoundStudyPageObject | `tests/pages/NotFoundStudyPageObject.ts` | Study-not-found error page |
|
||||
| DicomTagBrowserPageObject | `tests/pages/DicomTagBrowserPageObject.ts` | Tag-browser dialog (non-fixture; reach via `DOMOverlayPageObject.dialog`) |
|
||||
| DataOverlayPageObject | `tests/pages/DataOverlayPageObject.ts` | Data-overlay menu (non-fixture; reach via `viewport.overlayMenu`) |
|
||||
|
||||
If the directory adds or renames a file, that diff is your first clue and this table is your second — trust the directory.
|
||||
|
||||
For live usage, the seed spec in [patterns-by-feature.md](patterns-by-feature.md) shows how a class is actually called; the source file tells you everything else that's on it.
|
||||
148
.agents/skills/ohif-test-agent/references/patterns-by-feature.md
Normal file
148
.agents/skills/ohif-test-agent/references/patterns-by-feature.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Seed specs by feature area
|
||||
|
||||
> When writing a new OHIF test, find the closest feature area below and read the listed spec in full before writing. Playwright's own guidance says the seed test "serves as an example of all the generated tests" — that applies here.
|
||||
>
|
||||
> **If a spec listed below has moved or been renamed**, grep `tests/` for a remaining example (e.g. `grep -rn "loadSeriesByModality('RTSTRUCT')" tests/`). The pattern matters more than the exact filename — specs get renamed, the feature area persists.
|
||||
>
|
||||
> **No close match below?** This list only covers areas with a seed worth copying; don't add a stub for every untested area. See [Feature area not listed above?](#feature-area-not-listed-above-no-existing-seed).
|
||||
|
||||
## Canonical study UIDs
|
||||
|
||||
| UID | Mode(s) | What it has |
|
||||
|-----|---------|-------------|
|
||||
| `1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5` | `viewer` | CT — default for measurement/annotation tests |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785` | `viewer` | CT volume for 3D/MPR/crosshairs |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.256467663913010332776401703474716742458` | `viewer` or `segmentation` | CT + SEG for labelmap tests |
|
||||
| `1.2.840.113619.2.290.3.3767434740.226.1600859119.501` | `viewer` / `segmentation` / `tmtv` | CT + RTSTRUCT + PET, used for contour and TMTV |
|
||||
| `1.3.6.1.4.1.14519.5.2.1.7695.4007.324475281161490036195179843543` | `viewer` | SR structured report |
|
||||
|
||||
Do not invent UIDs — they must exist on the e2e data server.
|
||||
|
||||
---
|
||||
|
||||
## 1. Simple measurement tools (length, angle, bidirectional, rectangle, ellipse, circle)
|
||||
|
||||
**Seed:** `tests/Length.spec.ts`, `tests/Angle.spec.ts`
|
||||
|
||||
Pattern: select tool via `mainToolbarPageObject.measurementTools.<tool>.click()`, place N points via `activeViewport.normalizedClickAt([...])`, confirm the tracking prompt, screenshot via `screenShotPaths.<name>.<name>DisplayedCorrectly`.
|
||||
|
||||
## 2. Freehand / spline / livewire ROIs
|
||||
|
||||
**Seed:** `tests/FreehandROI.spec.ts`, `tests/Livewire.spec.ts`, `tests/Spline.spec.ts`
|
||||
|
||||
Pattern: `normalizedDragAt({ start, end, config: { steps: 20, delay: 30 } })` for smooth strokes; `subscribeToMeasurementAdded` to assert the event fires; `activeViewport.nthAnnotation(0)` to reference what was drawn.
|
||||
|
||||
## 3. Annotations (arrow, probe)
|
||||
|
||||
**Seed:** `tests/ArrowAnnotate.spec.ts`, `tests/Probe.spec.ts`
|
||||
|
||||
Arrow annotate opens `DOMOverlayPageObject.dialog.input` for the label. Use `fillAndSave(label)`.
|
||||
|
||||
## 4. Measurement panel interactions
|
||||
|
||||
**Seed:** `tests/MeasurementPanel.spec.ts`
|
||||
|
||||
Panel access: `rightPanelPageObject.toggle()` → `.measurementsPanel.select()` → `.panel.nthMeasurement(i)` → `.actions.rename|delete|toggleLock|duplicate|...`. Also demonstrates `addLengthMeasurement(page)` and panel-row `click()` for jump-to.
|
||||
|
||||
## 5. Context menu (right-click on annotation)
|
||||
|
||||
**Seed:** `tests/ContextMenu.spec.ts`
|
||||
|
||||
Two ways to open: `activeViewport.normalizedClickAt([{...}], 'right')` on an empty area, or `activeViewport.nthAnnotation(0).contextMenu.open()` on a drawn annotation. Then `DOMOverlayPageObject.viewport.annotationContextMenu.addLabel|delete.click()`.
|
||||
|
||||
## 6. Labelmap segmentation (SEG) hydration
|
||||
|
||||
**Seed:** `tests/SEGHydration.spec.ts`, `tests/SEGHydrationThenMPR.spec.ts`
|
||||
|
||||
Flow: `leftPanelPageObject.loadSeriesByModality('SEG')` → `waitForTimeout(3000)` → `DOMOverlayPageObject.viewport.segmentationHydration.yes.click()`. Often pokes Cornerstone state directly via `page.evaluate(() => window.cornerstone...)` for zoom/render.
|
||||
|
||||
## 7. Contour segmentation (RTSTRUCT) + interactions
|
||||
|
||||
**Seeds:**
|
||||
- Hydration: `tests/RTHydration.spec.ts`
|
||||
- Rename: `tests/ContourSegmentRename.spec.ts`
|
||||
- Navigation between segments: `tests/ContourSegNavigation.spec.ts`
|
||||
- Duplicate: `tests/ContourSegmentDuplicate.spec.ts`
|
||||
- Visibility: `tests/ContourSegmentToggleVisibility.spec.ts`
|
||||
- Locking: `tests/ContourSegLocking.spec.ts`
|
||||
|
||||
Pattern: load RTSTRUCT series → hydrate → use `rightPanelPageObject.contourSegmentationPanel.panel.nthSegment(i)` / `.segmentByText('Small Sphere')` and their `.actions.rename|delete`, or the segment's `.click()` to jump.
|
||||
|
||||
## 8. Labelmap segmentation editing (brush / eraser / threshold)
|
||||
|
||||
**Seed:** `tests/LabelMapSegLocking.spec.ts`, `tests/SEGDrawingToolsResizing.spec.ts`
|
||||
|
||||
Pattern: `rightPanelPageObject.labelMapSegmentationPanel.tools.brush.setRadius(n)` / `.click()`, then `activeViewport.normalizedDragAt(...)` to paint.
|
||||
|
||||
## 9. TMTV / PET
|
||||
|
||||
**Seeds:** `tests/TMTVCSVReport.spec.ts`, `tests/TMTVSUV.spec.ts`, `tests/TMTVAlignment.spec.ts`, `tests/TMTVRendering.spec.ts`
|
||||
|
||||
Visit `mode: 'tmtv'` with a longer delay (`10000`). `rightPanelPageObject.tmtvPanel` for the side panel. Exporting a report uses `page.waitForEvent('download')` + `downloadAsString(download)`.
|
||||
|
||||
## 10. MPR and 3D layouts
|
||||
|
||||
**Seeds:** `tests/MPR.spec.ts`, `tests/3DOnly.spec.ts`, `tests/3DFourUp.spec.ts`, `tests/3DMain.spec.ts`, `tests/AxialPrimary.spec.ts`
|
||||
|
||||
Pattern: `mainToolbarPageObject.layoutSelection.<layoutName>.click()`. For 3D, wrap stabilization with `attemptAction(() => reduce3DViewportSize(page), 10, 100)` to settle the render before asserting.
|
||||
|
||||
## 11. Crosshairs
|
||||
|
||||
**Seed:** `tests/Crosshairs.spec.ts`
|
||||
|
||||
Pattern: `initializeMousePositionTracker(page)` in `beforeEach`, `mainToolbarPageObject.crosshairs.click()`, then `viewportPageObject.crosshairs.axial.rotate()` / `.increase()`.
|
||||
|
||||
## 12. Overlays — data overlay menu, window/level, orientation
|
||||
|
||||
**Seeds:** `tests/DataOverlayMenu.spec.ts`, `tests/WindowLevelOverlayText.spec.ts`, `tests/MultipleSegmentationDataOverlays.spec.ts`
|
||||
|
||||
Pattern: `viewportPageObject.getById('default').overlayMenu.dataOverlay.toggle()`, then `.addSegmentation(name)` / `.changeSegmentation(from, to)` / `.remove(name)`. For keyboard navigation, remember `press({ page, key, nTimes })` imports from `./utils/keyboardUtils`.
|
||||
|
||||
## 13. DICOM Tag Browser
|
||||
|
||||
**Seed:** `tests/DicomTagBrowser.spec.ts`
|
||||
|
||||
Open with `mainToolbarPageObject.moreTools.tagBrowser.click()`, then interact via `DOMOverlayPageObject.dialog.dicomTagBrowser.waitVisible()` / `.seriesSelect.selectOption(i)` / `.seriesSelect.getOptionText(i)`.
|
||||
|
||||
## 14. Study validation / not-found / worklist
|
||||
|
||||
**Seeds:** `tests/StudyValidation.spec.ts`, `tests/Worklist.spec.ts`
|
||||
|
||||
These are the rare specs that use `page.goto(...)` directly instead of `visitStudy()` — because they test error states or non-study pages. `notFoundStudyPageObject` gives you `errorMessage`, `returnMessage`, `studyListLink`.
|
||||
|
||||
## 15. SR (Structured Report) hydration
|
||||
|
||||
**Seeds:** `tests/SRHydration.spec.ts`, `tests/SRHydrationThenReload.spec.ts`
|
||||
|
||||
Same shape as SEG hydration but load via `loadSeriesByModality('SR')`.
|
||||
|
||||
## Feature area not listed above? (no existing seed)
|
||||
|
||||
Don't add a stub section here for an untested area. When your target isn't listed:
|
||||
|
||||
1. **Look for an indirect seed.** Grep `tests/` for the controls or flow you need
|
||||
(`grep -rn "options-menu" tests/`, a related panel/dialog). An adjacent area's seed
|
||||
usually still shows how the harness is driven.
|
||||
2. **If genuinely uncovered, you're *creating* page objects.** Read
|
||||
[page-objects.md](page-objects.md) → "When the control you need isn't covered yet", then:
|
||||
- One page object per new control. Dialogs follow `DicomTagBrowserPageObject` (reached
|
||||
through `DOMOverlayPageObject`); toolbar buttons get a getter on `MainToolbarPageObject`.
|
||||
- Add a `data-cy` to any control that lacks one.
|
||||
- **Verify the real effect, not a proxy** — drag and screenshot that the image moved via
|
||||
`locator: viewportPageObject.grid`, not just that a button gained `data-active`.
|
||||
|
||||
**Example — user preferences / hotkeys (no seed today):** open the options menu and User
|
||||
Preferences dialog (options-menu accessor on `DOMOverlayPageObject` + a `userPreferences`
|
||||
dialog page object reached through it), set the hotkey, save, then trigger it and confirm the
|
||||
viewport effect (activate Zoom, drag, screenshot the zoom). Add the Zoom button as a getter on
|
||||
`MainToolbarPageObject`.
|
||||
|
||||
---
|
||||
|
||||
## Advanced patterns worth knowing
|
||||
|
||||
- **`subscribeToMeasurementAdded`** (`tests/FreehandROI.spec.ts`) — for async "was a measurement actually added?" assertions. Always `try { ... } finally { await measurementAdded.unsubscribe() }`.
|
||||
- **`attemptAction`** (`tests/3DOnly.spec.ts`) — retry flaky setup (3D render, heavy layout change) without silencing real failures.
|
||||
- **`addOHIFConfiguration`** (`tests/RTHydrationDisableConfirmation.spec.ts`) — pre-load config overrides before `visitStudy`.
|
||||
- **`page.evaluate(() => window.services...)`** — used in several SEG/SR specs to set customizations or poke viewport state. Treat as an escape hatch, not a default.
|
||||
- **`expect.toPass({ timeout })`** — wrap flaky assertions (common for jump-to-measurement tests where rendering settles asynchronously).
|
||||
100
.agents/skills/ohif-test-agent/references/utilities.md
Normal file
100
.agents/skills/ohif-test-agent/references/utilities.md
Normal file
@ -0,0 +1,100 @@
|
||||
# OHIF Test Utility guide
|
||||
|
||||
> This file documents the **stable import rules and conventions** around `tests/utils/`. For the current list of exported helpers and their exact signatures, **read `tests/utils/index.ts` and the files it re-exports** — the barrel is always current; a static table here is not. Utilities get added, renamed, and refactored; the rules below change much more slowly.
|
||||
|
||||
## How to discover what's available
|
||||
|
||||
1. Open `tests/utils/index.ts`. Every symbol exported from the barrel is importable as `import { foo } from './utils'`.
|
||||
2. If what you need isn't in the barrel, look in the rest of `tests/utils/` — there are a handful of specialized files (`keyboardUtils.ts`, `assertions.ts`, `download.ts`, …). These need the **deeper import path**; see the rule below.
|
||||
3. For the actual signature, read the utility's `.ts` file. It's one short function per file in most cases.
|
||||
4. To see a utility in context, grep `tests/` (`grep -rn visitStudy tests/`) — or open the seed spec for the relevant feature area ([patterns-by-feature.md](patterns-by-feature.md)). Existing specs are the most reliable signature reference because they co-evolve with the API.
|
||||
|
||||
Do not guess parameter shapes from memory, and do not treat this file as an API catalog — it intentionally isn't one.
|
||||
|
||||
---
|
||||
|
||||
## Stable rules
|
||||
|
||||
### Barrel vs. deep imports
|
||||
|
||||
Two import styles exist. They are not interchangeable.
|
||||
|
||||
```ts
|
||||
// Barrel — anything re-exported from tests/utils/index.ts
|
||||
import { test, expect, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
|
||||
|
||||
// Deep — for files NOT re-exported by the barrel
|
||||
import { press } from './utils/keyboardUtils';
|
||||
import { assertNumberOfModalityLoadBadges } from './utils/assertions';
|
||||
import { downloadAsString } from './utils/download';
|
||||
```
|
||||
|
||||
If a symbol isn't in the barrel, `import { x } from './utils'` **compiles**, resolves `x` to `undefined`, and blows up at the first call site. Confirm by opening `tests/utils/index.ts` for your working revision. At the time of this writing, `press`, `downloadAsString`, and the `assert*` helpers live outside the barrel — but maintainers can move things in or out, so treat `index.ts` as the ground truth rather than this note.
|
||||
|
||||
### Never import `test` / `expect` from `@playwright/test`
|
||||
|
||||
```ts
|
||||
// ✅ Correct — gets the fixture-extended runner
|
||||
import { test, expect } from './utils';
|
||||
|
||||
// ❌ Wrong — compiles, but every page-object fixture is silently undefined
|
||||
import { test, expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
If your test function's destructured arguments (like `viewportPageObject`) are `undefined`, this import is almost always why.
|
||||
|
||||
### `visitStudy` — 2000 ms is a convention, not a default
|
||||
|
||||
```ts
|
||||
await visitStudy(page, studyInstanceUID, mode, 2000);
|
||||
```
|
||||
|
||||
The function's own default delay is `0`. Nearly every spec passes `2000` to let the first render settle. The one consistent exception is `mode: 'tmtv'`, where the suite uniformly uses `10000` because PET fusion and SUV calculation add real wall-clock cost before the UI is interactive.
|
||||
|
||||
Notably, 3D layouts in `viewer` mode (3DOnly, 3DFourUp, 3DMain, 3DPrimary) and MPR also use `2000` — they're not "heavy" in the `visitStudy` sense. Their stabilization problem is solved at the interaction layer with `attemptAction(() => reduce3DViewportSize(page), 10, 100)`, not by a longer visit delay.
|
||||
|
||||
So: pick `10000` when the mode is `tmtv`, `2000` otherwise, and only ramp up if a specific test flakes on first-render assertions. The delay is a good first lever for "not visible" flakes, but it's not a universal upgrade.
|
||||
|
||||
### `checkForScreenshot` — use the object form, never screenshot the full app
|
||||
|
||||
**This is the direction going forward** The suite is being migrated off *full-app* screenshots — not off screenshots altogether. Screenshots are the correct and required tool whenever what you're verifying is canvas-only raster output with no DOM signal; don't avoid them there. Avoid them only where a faithful DOM/SVG signal exists (e.g. a vector overlay's color via `getSvgAttribute`) or where you'd be capturing the whole app. See SKILL.md → "Screenshot vs. DOM assertion — how to choose". Any new spec must follow the rules below, and any modification to an older spec should bring it in line when reasonable.
|
||||
|
||||
- **Object form** (required for all new specs): `checkForScreenshot({ page, screenshotPath, normalizedClip?, ... })`
|
||||
- **Positional form**: legacy. It still appears in older specs because they haven't been migrated yet. **Do not treat existing positional-form usage as a pattern to copy** — those specs are the thing being moved away from. Do not introduce the positional form in new code.
|
||||
|
||||
**Hard rules for new screenshots:**
|
||||
|
||||
1. Use the object form.
|
||||
2. Scope by passing a `locator` — `viewportPageObject.grid` for the whole grid, or a specific viewport pane locator. **Never screenshot the full app.** `normalizedClip` is computed *relative to the locator* (and defaults to the full page when no locator is given), so `{ x: 0, y: 0, width: 1, height: 1 }` alone does not scope anything — reserve `normalizedClip` for clipping to a sub-region of a locator. If you find yourself reaching for `fullPage: true`, stop and pass a locator instead.
|
||||
|
||||
Do not tune `maxDiffPixelRatio` or `threshold` to make a screenshot pass — those are intentionally rarely touched and not the right knob for flakes. If a baseline mismatches, regenerate it (`--update-snapshots`) after a human review of the diff, or fix the underlying instability. Check the current signature in `tests/utils/checkForScreenshot.ts` if something looks off.
|
||||
|
||||
### `screenShotPaths` — use keys, not raw strings
|
||||
|
||||
```ts
|
||||
await checkForScreenshot({
|
||||
page,
|
||||
locator: viewportPageObject.grid,
|
||||
screenshotPath: screenShotPaths.length.lengthDisplayedCorrectly,
|
||||
});
|
||||
```
|
||||
|
||||
The full tree of categories lives in `tests/utils/screenShotPaths.ts`. When you need a new baseline, **add the key there and reference it by name** rather than typing a raw path. A typo in a key becomes a compile error instead of a silent mismatch, and other tests become discoverable through the object.
|
||||
|
||||
### Object-param convention
|
||||
|
||||
Many OHIF test utilities take a single **object argument** rather than positional arguments — notably `press({ page, key, nTimes? })`, the `simulate*` helpers, and the `assert*` helpers. If a call looks like it should work but throws "cannot read properties of undefined," check whether you're passing positional args to something that expects `{ page, ... }`.
|
||||
|
||||
Read the one-line signature at the top of the utility's source file before calling it — it's faster than guessing, and it's always right.
|
||||
|
||||
---
|
||||
|
||||
## Utility shapes worth flagging
|
||||
|
||||
Most utilities are obvious once you read the source; these earn a mention:
|
||||
|
||||
- **`waitForViewportRenderCycle(page, options?)`** — preferred replacement for `page.waitForTimeout(...)` after any viewport-mutating action (hydration confirm, layout change, segmentation add, series load, etc.). Start it **before** the action, await it after — it captures the `needsRender → rendered` transition the action triggers. Use `waitForViewportsRendered(page)` (the second-half-only variant) when the render is already in flight before you can attach a watcher. Source: `tests/utils/waitForViewportsRendered.ts`. Seed: `tests/SEGHydrationFromMPR.spec.ts`. The "Wait for renders, don't sleep" section in [SKILL.md](../SKILL.md) covers the idiom in full.
|
||||
- **`subscribeToMeasurementAdded(page)`** — returns `{ waitFired(timeout?), unsubscribe() }`. Use in freehand/livewire/spline specs to assert the event fired. Always wrap in `try { ... } finally { await sub.unsubscribe() }` so a failing assertion doesn't leak the listener across tests.
|
||||
- **`attemptAction(action, attempts?, delay?)`** — retries a flaky async action without masking real failures. Mainly used to stabilize 3D scenes (`attemptAction(() => reduce3DViewportSize(page), 10, 100)`).
|
||||
|
||||
For everything else, the pattern is: find a spec that uses it (see [patterns-by-feature.md](patterns-by-feature.md)), copy the shape, adapt.
|
||||
@ -1,52 +1,47 @@
|
||||
version: 2.1
|
||||
|
||||
# ci: re-trigger pipeline
|
||||
|
||||
orbs:
|
||||
codecov: codecov/codecov@1.0.5
|
||||
cypress: cypress-io/cypress@3.4.2
|
||||
|
||||
defaults: &defaults
|
||||
docker:
|
||||
- image: cimg/node:20.18.1
|
||||
- image: cimg/node:24.15.0
|
||||
environment:
|
||||
TERM: xterm
|
||||
QUICK_BUILD: true
|
||||
working_directory: ~/repo
|
||||
|
||||
commands:
|
||||
install_bun:
|
||||
install_pnpm:
|
||||
steps:
|
||||
- restore_cache:
|
||||
keys:
|
||||
- bun-cache-v2-{{ arch }}-latest
|
||||
- run:
|
||||
name: Install Bun
|
||||
name: Install pnpm
|
||||
command: |
|
||||
if [ ! -d "$HOME/.bun" ]; then
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
fi
|
||||
echo 'export BUN_INSTALL="$HOME/.bun"' >> $BASH_ENV
|
||||
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> $BASH_ENV
|
||||
# The cimg/node global modules dir (/usr/local/lib/node_modules) is
|
||||
# root-owned, so a plain `npm install -g` fails with EACCES. Install
|
||||
# with sudo so the global pnpm binary lands in the shared prefix.
|
||||
sudo npm install -g pnpm@11.5.2
|
||||
echo 'export PATH="$(pnpm store path)/../.bin:$PATH"' >> $BASH_ENV
|
||||
source $BASH_ENV
|
||||
- save_cache:
|
||||
key: bun-cache-v2-{{ arch }}-latest
|
||||
paths:
|
||||
- ~/.bun
|
||||
|
||||
jobs:
|
||||
UNIT_TESTS:
|
||||
<<: *defaults
|
||||
resource_class: large
|
||||
steps:
|
||||
- install_bun
|
||||
- install_pnpm
|
||||
- run: node --version
|
||||
- checkout
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: bun install --no-save
|
||||
command: pnpm install --frozen-lockfile
|
||||
# RUN TESTS
|
||||
- run:
|
||||
name: 'JavaScript Test Suite'
|
||||
command: bun run test:unit:ci
|
||||
command: pnpm run test:unit:ci
|
||||
# platform/app
|
||||
- run:
|
||||
name: 'VIEWER: Combine report output'
|
||||
@ -80,17 +75,17 @@ jobs:
|
||||
steps:
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout
|
||||
- install_bun
|
||||
- install_pnpm
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: bun install --no-save
|
||||
command: pnpm install --frozen-lockfile
|
||||
# Build & Test
|
||||
- run:
|
||||
name: 'Perform the versioning before build'
|
||||
command: bun ./version.mjs
|
||||
command: node ./version.mjs
|
||||
- run:
|
||||
name: 'Build the OHIF Viewer'
|
||||
command: bun run build
|
||||
command: pnpm run build
|
||||
no_output_timeout: 45m
|
||||
- run:
|
||||
name: 'Upload SourceMaps, Send Deploy Notification'
|
||||
@ -116,14 +111,55 @@ jobs:
|
||||
<<: *defaults
|
||||
resource_class: large
|
||||
steps:
|
||||
- install_bun
|
||||
- install_pnpm
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
# SECURITY AUDIT - only when pnpm-lock.yaml has changed
|
||||
- run:
|
||||
name: 'Security Audit - High Risk Vulnerabilities'
|
||||
command: |
|
||||
git fetch origin master 2>/dev/null || true
|
||||
BASE_REF=$(git merge-base HEAD origin/master 2>/dev/null)
|
||||
if [[ -z "$BASE_REF" ]]; then
|
||||
echo "Could not determine base ref (e.g. shallow clone or no origin/master), skipping security audit."
|
||||
exit 0
|
||||
fi
|
||||
CHANGED_FILES=$(git diff --name-only origin/master...HEAD 2>/dev/null || echo "")
|
||||
if ! echo "$CHANGED_FILES" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "pnpm-lock.yaml unchanged - skipping security audit."
|
||||
exit 0
|
||||
fi
|
||||
echo "pnpm-lock.yaml changed - running pnpm audit for security vulnerabilities..."
|
||||
echo "Checking for HIGH-RISK vulnerabilities..."
|
||||
|
||||
if pnpm audit --audit-level high; then
|
||||
echo "No high-risk vulnerabilities found"
|
||||
echo "Security audit passed!"
|
||||
else
|
||||
echo ""
|
||||
echo "HIGH-RISK VULNERABILITIES DETECTED!"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "To fix these issues:"
|
||||
echo " 1. Run: pnpm audit"
|
||||
echo " 2. Review the vulnerability details"
|
||||
echo " 3. Update affected packages to secure versions"
|
||||
echo " 4. Test your changes"
|
||||
echo " 5. Re-run: pnpm audit --audit-level high"
|
||||
echo ""
|
||||
echo "Full audit report:"
|
||||
|
||||
pnpm audit || true
|
||||
|
||||
echo ""
|
||||
echo "This build cannot proceed until high-risk vulnerabilities are resolved."
|
||||
exit 1
|
||||
fi
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: bun install --frozen-lockfile
|
||||
command: pnpm install
|
||||
- run:
|
||||
name: Avoid hosts unknown for github
|
||||
command: |
|
||||
@ -134,28 +170,34 @@ jobs:
|
||||
git config --global user.name "ohif-bot"
|
||||
- run:
|
||||
name: Authenticate with NPM registry
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
|
||||
# Write the auth token to npm's per-user config (~/.npmrc), not the
|
||||
# repo root. publish-package.mjs chdir's into each package dir to run
|
||||
# `npm publish`, and npm only reads the project .npmrc from that dir
|
||||
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
|
||||
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
|
||||
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
- run:
|
||||
name: build half of the packages (to avoid out of memory in circleci)
|
||||
command: |
|
||||
bun run build:package-all
|
||||
pnpm run build:package-all
|
||||
- run:
|
||||
name: build the other half of the packages
|
||||
command: |
|
||||
bun run build:package-all-1
|
||||
pnpm run build:package-all-1
|
||||
|
||||
NPM_PUBLISH:
|
||||
<<: *defaults
|
||||
resource_class: large
|
||||
steps:
|
||||
- install_bun
|
||||
- install_pnpm
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: bun install --no-save
|
||||
command: pnpm install --frozen-lockfile
|
||||
- run:
|
||||
name: Avoid hosts unknown for github
|
||||
command: |
|
||||
@ -166,15 +208,21 @@ jobs:
|
||||
git config --global user.name "ohif-bot"
|
||||
- run:
|
||||
name: Authenticate with NPM registry
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
|
||||
# Write the auth token to npm's per-user config (~/.npmrc), not the
|
||||
# repo root. publish-package.mjs chdir's into each package dir to run
|
||||
# `npm publish`, and npm only reads the project .npmrc from that dir
|
||||
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
|
||||
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
|
||||
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
- run:
|
||||
name: build half of the packages (to avoid out of memory in circleci)
|
||||
command: |
|
||||
bun run build:package-all
|
||||
pnpm run build:package-all
|
||||
- run:
|
||||
name: build the other half of the packages
|
||||
command: |
|
||||
bun run build:package-all-1
|
||||
pnpm run build:package-all-1
|
||||
- run:
|
||||
name: increase min time out
|
||||
command: |
|
||||
@ -186,14 +234,20 @@ jobs:
|
||||
- run:
|
||||
name: publish package versions
|
||||
command: |
|
||||
bun ./publish-version.mjs
|
||||
node ./publish-version.mjs
|
||||
- run:
|
||||
name: Again set the NPM registry (was deleted in the version script)
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
|
||||
name: Re-assert the NPM auth token before publishing
|
||||
# Write the auth token to npm's per-user config (~/.npmrc), not the
|
||||
# repo root. publish-package.mjs chdir's into each package dir to run
|
||||
# `npm publish`, and npm only reads the project .npmrc from that dir
|
||||
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
|
||||
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
|
||||
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
|
||||
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
- run:
|
||||
name: publish package dist
|
||||
command: |
|
||||
bun ./publish-package.mjs
|
||||
node ./publish-package.mjs
|
||||
- persist_to_workspace:
|
||||
root: ~/repo
|
||||
paths:
|
||||
@ -215,8 +269,11 @@ jobs:
|
||||
if [[ ! -e version.txt ]]; then
|
||||
exit 0
|
||||
else
|
||||
# Remove npm config
|
||||
rm -f ./.npmrc
|
||||
# Restore the committed .npmrc (pnpm workspace config such as
|
||||
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
|
||||
# auth token, but the Dockerfile COPYs .npmrc for the in-image
|
||||
# pnpm install, so it needs the config file present and token-free.
|
||||
git checkout -- .npmrc
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat version.txt)
|
||||
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
|
||||
@ -249,8 +306,11 @@ jobs:
|
||||
if [[ ! -e version.txt ]]; then
|
||||
exit 0
|
||||
else
|
||||
# Remove npm config
|
||||
rm -f ./.npmrc
|
||||
# Restore the committed .npmrc (pnpm workspace config such as
|
||||
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
|
||||
# auth token, but the Dockerfile COPYs .npmrc for the in-image
|
||||
# pnpm install, so it needs the config file present and token-free.
|
||||
git checkout -- .npmrc
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat version.txt)
|
||||
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
|
||||
@ -287,7 +347,11 @@ jobs:
|
||||
exit 0
|
||||
else
|
||||
echo "Building and pushing Docker image from the master branch (beta releases)"
|
||||
rm -f ./.npmrc
|
||||
# Restore the committed .npmrc (pnpm workspace config such as
|
||||
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
|
||||
# auth token, but the Dockerfile COPYs .npmrc for the in-image
|
||||
# pnpm install, so it needs the config file present and token-free.
|
||||
git checkout -- .npmrc
|
||||
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat version.txt)
|
||||
@ -321,7 +385,11 @@ jobs:
|
||||
exit 0
|
||||
else
|
||||
echo "Building and pushing ARM64 Docker image from the master branch (beta releases)"
|
||||
rm -f ./.npmrc
|
||||
# Restore the committed .npmrc (pnpm workspace config such as
|
||||
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
|
||||
# auth token, but the Dockerfile COPYs .npmrc for the in-image
|
||||
# pnpm install, so it needs the config file present and token-free.
|
||||
git checkout -- .npmrc
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat version.txt)
|
||||
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
|
||||
@ -339,11 +407,21 @@ jobs:
|
||||
resource_class: large
|
||||
parallelism: 8
|
||||
steps:
|
||||
- install_pnpm
|
||||
- run:
|
||||
name: Install System Dependencies
|
||||
command: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6
|
||||
# CircleCI's base image registers third-party apt sources (git-lfs via
|
||||
# packagecloud, git-core PPA via launchpad) that periodically hang
|
||||
# `apt-get update`. The Cypress libs below all come from the Ubuntu
|
||||
# archive, so drop those sources and bound the update with timeouts +
|
||||
# retries so a slow mirror can't stall the job indefinitely.
|
||||
sudo rm -f /etc/apt/sources.list.d/*git* || true
|
||||
sudo apt-get update \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=20 \
|
||||
-o Acquire::https::Timeout=20
|
||||
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6
|
||||
- run:
|
||||
name: Start Xvfb
|
||||
command: Xvfb :99 -screen 0 1920x1080x24 &
|
||||
@ -352,11 +430,15 @@ jobs:
|
||||
name: Export Display Variable
|
||||
command: export DISPLAY=:99
|
||||
- cypress/install:
|
||||
install-command: yarn install --no-save
|
||||
install-command: pnpm install
|
||||
- cypress/run-tests:
|
||||
# CI runs headless under Xvfb with no GPU, so Electron uses software
|
||||
# WebGL. Newer Chromium deprecated that implicit fallback (canvas
|
||||
# rendering degrades and races with element clicks). ELECTRON_EXTRA_LAUNCH_ARGS
|
||||
# is the reliable way to pass the opt-in flag to Cypress's Electron browser.
|
||||
cypress-command: |
|
||||
npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run --record --parallel
|
||||
start-command: yarn run test:data && yarn run test:e2e:serve
|
||||
npx wait-on@latest http://localhost:3000 && cd platform/app && ELECTRON_EXTRA_LAUNCH_ARGS="--enable-unsafe-swiftshader" npx cypress run --record --parallel
|
||||
start-command: pnpm run test:data && pnpm run test:e2e:serve
|
||||
|
||||
DOCKER_MULTIARCH_MANIFEST:
|
||||
<<: *defaults
|
||||
@ -455,8 +537,13 @@ workflows:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
# - HOLD_FOR_APPROVAL:
|
||||
# type: approval
|
||||
# requires:
|
||||
# - BUILD
|
||||
- NPM_PUBLISH:
|
||||
requires:
|
||||
# - HOLD_FOR_APPROVAL
|
||||
- BUILD
|
||||
- DOCKER_BETA_PUBLISH:
|
||||
requires:
|
||||
|
||||
10
.github/.dependabot.yaml
vendored
10
.github/.dependabot.yaml
vendored
@ -1,15 +1,9 @@
|
||||
version: 2
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
- package-ecosystem: 'bun'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
labels: ['dependencies']
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
- package-ecosystem: 'npm'
|
||||
# Disable all pull requests for npm/pnpm version updates.
|
||||
open-pull-requests-limit: 0
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
43
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
43
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -51,28 +51,29 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
- type: textarea
|
||||
id: system_info
|
||||
attributes:
|
||||
label: 'OS'
|
||||
description: 'Your operating system.'
|
||||
placeholder: 'e.g., Windows 10, macOS 10.15.4'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: node-version
|
||||
attributes:
|
||||
label: 'Node version'
|
||||
description: 'Your Node.js version.'
|
||||
placeholder: 'e.g., 20.18.1'
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: 'Browser'
|
||||
description: 'Your browser.'
|
||||
placeholder: 'e.g., Chrome 83.0.4103.116, Firefox 77.0.1, Safari 13.1.1'
|
||||
label: 'System Information'
|
||||
description: 'Please run the following command in your terminal and paste the output:'
|
||||
placeholder: |
|
||||
Run: npx envinfo --system --binaries --browsers
|
||||
|
||||
Then paste the output here. It should look something like:
|
||||
|
||||
System:
|
||||
OS: Windows 10 10.0.19042
|
||||
CPU: (8) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
|
||||
Memory: 15.89 GB / 31.74 GB
|
||||
Shell: 1.0.0 - C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe
|
||||
Binaries:
|
||||
Node: 20.18.1 - C:\Program Files\nodejs\node.EXE
|
||||
Yarn: 1.22.22 - C:\Users\user\AppData\Roaming\npm\yarn.CMD
|
||||
npm: 10.8.2 - C:\Program Files\nodejs\npm.CMD
|
||||
Browsers:
|
||||
Chrome: 83.0.4103.116
|
||||
Edge: Spartan (44.19041.1266.0), Chromium (83.0.478.58)
|
||||
Firefox: 77.0.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
73
.github/workflows/build-docs.yml
vendored
73
.github/workflows/build-docs.yml
vendored
@ -21,14 +21,29 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
node-version: 20 # Or your desired Node version
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.15.0
|
||||
cache: pnpm
|
||||
|
||||
- name: Install root dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Configure git for private repos
|
||||
run: git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf ssh://git@github.com/
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
# Internal @ohif/* deps are workspace:* in the committed manifests and the
|
||||
# lockfile records them as links, so pnpm-lock.yaml stays in sync across
|
||||
# version bumps and a frozen install passes. Frozen is the right choice
|
||||
# for a deploy job: it fails fast if a PR ever changed a real dependency
|
||||
# without committing the lockfile update, instead of silently reconciling.
|
||||
# (Exact versions appear only in published tarballs, where pnpm publish
|
||||
# rewrites workspace:* at pack time -- not publish-version.mjs.)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Removed Playwright tests and coverage generation steps
|
||||
|
||||
@ -42,47 +57,44 @@ jobs:
|
||||
# Note: This relies on the merge commit being directly pushed to main
|
||||
PR_DATA=$(gh pr list --state merged --search "$MERGE_COMMIT_SHA" --json number,headRefOid --jq '.[0]')
|
||||
if [ -z "$PR_DATA" ]; then
|
||||
echo "Could not find merged PR for commit $MERGE_COMMIT_SHA."
|
||||
# Decide how to handle - fail, or maybe generate coverage now?
|
||||
# For now, let's fail.
|
||||
exit 1
|
||||
echo "::warning::Could not find merged PR for commit $MERGE_COMMIT_SHA. Skipping coverage embedding."
|
||||
echo "coverage_found=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
|
||||
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number')
|
||||
echo "Found PR Number: $PR_NUMBER"
|
||||
echo "Found PR Head SHA: $PR_HEAD_SHA"
|
||||
|
||||
# Find the latest workflow run ID for the playwright workflow on the PR head commit
|
||||
# Uses the workflow file name 'playwright.yml'
|
||||
# Remove the --status success flag to find any run
|
||||
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --json databaseId --jq '.[0].databaseId')
|
||||
# Find the latest *successful* playwright.yml run for the PR head commit so we
|
||||
# never embed coverage from a failed/incomplete run.
|
||||
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --status success --json databaseId --jq '.[0].databaseId')
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "Could not find any 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA)."
|
||||
# Decide how to handle - maybe try finding the artifact from the merge commit run if that exists?
|
||||
# For now, let's fail.
|
||||
exit 1
|
||||
echo "::warning::Could not find a successful 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA). Skipping coverage embedding."
|
||||
echo "coverage_found=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "Found Run ID: $RUN_ID"
|
||||
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
|
||||
echo "coverage_found=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download coverage artifact from PR run
|
||||
if: steps.find_pr_run.outputs.coverage_found == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p ./coverage-artifact
|
||||
gh run download ${{ steps.find_pr_run.outputs.run_id }} -n coverage-report-pr --dir ./coverage-artifact
|
||||
# Check if download was successful (e.g., check if files exist)
|
||||
if [ ! -f ./coverage-artifact/base.css ]; then
|
||||
echo "Failed to download or find expected files in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}."
|
||||
# Verify the artifact contains an HTML coverage report rather than checking a single asset
|
||||
if [ -z "$(ls -A ./coverage-artifact)" ] || ! ls ./coverage-artifact/*.html >/dev/null 2>&1; then
|
||||
echo "Failed to download or find an HTML coverage report in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Artifact downloaded successfully."
|
||||
|
||||
- name: Install docs dependencies
|
||||
run: cd platform/docs && bun install
|
||||
|
||||
- name: Copy coverage to docs static directory
|
||||
if: steps.find_pr_run.outputs.coverage_found == 'true'
|
||||
run: |
|
||||
# Copy files from the downloaded artifact directory
|
||||
mkdir -p platform/docs/static/coverage
|
||||
@ -97,12 +109,17 @@ jobs:
|
||||
cp ./coverage-artifact/sorter.js platform/docs/static/
|
||||
|
||||
- name: Build docs
|
||||
run: cd platform/docs && bun run build
|
||||
run: pnpm --filter ohif-docs run build
|
||||
|
||||
- name: Deploy to Netlify
|
||||
run: |
|
||||
cd platform/docs
|
||||
npx netlify-cli deploy --dir=./build --prod
|
||||
# The docs are already built by the "Build docs" step above, so pass
|
||||
# --no-build: `netlify deploy` runs the build command by default, which
|
||||
# would otherwise pick up the repo-root netlify.toml (configured for the
|
||||
# main viewer app) and build the wrong project. --dir is resolved
|
||||
# relative to the repo root (the netlify.toml base), not the cwd, so it
|
||||
# must be the full path to the prebuilt docs output. --filter selects
|
||||
# the ohif-docs package so Netlify CLI does not abort on monorepo detection.
|
||||
run: npx netlify-cli deploy --filter ohif-docs --dir=platform/docs/build --prod --no-build
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
8
.github/workflows/codeql.yml
vendored
8
.github/workflows/codeql.yml
vendored
@ -55,7 +55,9 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
@ -65,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@ -93,6 +95,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
206
.github/workflows/playwright.yml
vendored
206
.github/workflows/playwright.yml
vendored
@ -1,7 +1,20 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [master, release/*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cs3d_ref:
|
||||
description: >-
|
||||
CS3D branch (e.g. main, origin:feat/foo) or version (e.g. 4.18.2, 4.19+, 4.x). Only used
|
||||
when ohif-integration label is present or via workflow_dispatch.
|
||||
required: false
|
||||
default: '4.19+'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@ -9,46 +22,211 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
playwright-tests:
|
||||
timeout-minutes: 60
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 120
|
||||
environment: fork-pr-approval
|
||||
runs-on: [self-hosted, nashua]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [20]
|
||||
node-version: [24.15.0]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
# No `cache: pnpm`: this is a self-hosted runner with a persistent
|
||||
# pnpm store on local disk. The Actions cache doesn't preserve the
|
||||
# pnpm self-install's links faithfully, dropping pnpm/dist/worker.js
|
||||
# and breaking `pnpm install` with MODULE_NOT_FOUND.
|
||||
# Install pnpm via Corepack instead of pnpm/action-setup. The action's
|
||||
# self-installer is a JS action run under the runner's bundled
|
||||
# externals/node{version} and derives the `npm` path from that runtime — which
|
||||
# on this self-hosted runner is corrupted (Cannot find module
|
||||
# '../lib/cli.js'), so it dies regardless of `standalone:true`. Corepack
|
||||
# ships inside the Node that setup-node just installed, reads the pinned
|
||||
# `packageManager` (pnpm@11.5.2) from package.json, and fetches pnpm via
|
||||
# Node's own https — it never invokes the npm CLI.
|
||||
- name: Enable Corepack (pnpm)
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
|
||||
# ── CS3D integration: detect label and ref type ──────────────────────
|
||||
- name: Check for CS3D integration label
|
||||
id: cs3d-check
|
||||
run: bash .scripts/ci/cs3d-check-integration.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Detect CS3D ref type
|
||||
id: cs3d-ref
|
||||
if: steps.cs3d-check.outputs.enabled == 'true'
|
||||
run: |
|
||||
REF="${CS3D_REF}"
|
||||
if [[ "$REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then
|
||||
echo "type=version" >> "$GITHUB_OUTPUT"
|
||||
RESOLVED=$(node .scripts/cs3d-resolve-version.mjs "$REF")
|
||||
echo "version=$RESOLVED" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::CS3D version: $REF -> $RESOLVED"
|
||||
else
|
||||
echo "type=branch" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::CS3D branch: $REF"
|
||||
fi
|
||||
env:
|
||||
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
|
||||
|
||||
# ── CS3D branch path: clone and build before OHIF install ───────────
|
||||
- name: Clone CS3D
|
||||
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
|
||||
run: |
|
||||
REF="${CS3D_REF}"
|
||||
if [[ "$REF" == *:* ]]; then
|
||||
REPO="https://github.com/${REF%%:*}/cornerstone3D.git"
|
||||
BRANCH="${REF#*:}"
|
||||
else
|
||||
REPO="https://github.com/cornerstonejs/cornerstone3D.git"
|
||||
BRANCH="$REF"
|
||||
fi
|
||||
echo "::notice::Cloning CS3D from $REPO branch $BRANCH"
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO" libs/@cornerstonejs
|
||||
env:
|
||||
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
|
||||
|
||||
- name: Install & Build CS3D
|
||||
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
|
||||
working-directory: libs/@cornerstonejs
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:esm
|
||||
|
||||
# ── Common: install OHIF dependencies ───────────────────────────────
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ── CS3D branch path: link packages after OHIF install ──────────────
|
||||
- name: Link CS3D packages
|
||||
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
|
||||
working-directory: libs/@cornerstonejs
|
||||
run: node scripts/link-ohif-cornerstone-node-modules.mjs "$GITHUB_WORKSPACE"
|
||||
|
||||
# ── CS3D version path: update versions after OHIF install ───────────
|
||||
- name: Set CS3D version
|
||||
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'version'
|
||||
run: |
|
||||
node .scripts/cs3d-set-version.mjs "${CS3D_VERSION}"
|
||||
pnpm install --no-frozen-lockfile
|
||||
env:
|
||||
CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }}
|
||||
|
||||
# ── Common: run tests ───────────────────────────────────────────────
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install
|
||||
# Only chromium is used (see playwright.config.ts and tests/globalSetup.ts);
|
||||
# firefox/webkit projects are commented out. Scoping the install to chromium
|
||||
# avoids downloading + dep-validating browsers we never launch (the WebKit
|
||||
# validation is what surfaces the "missing libwoff1/libflite1/..." error on
|
||||
# hosts without those libs).
|
||||
run: npx playwright install chromium
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=10192"
|
||||
bun run test:e2e:coverage
|
||||
bash .scripts/ci/with-nashua-lock.sh pnpm run test:e2e:coverage
|
||||
|
||||
# ── Common: collect test results and coverage ───────────────────────
|
||||
- name: Create directory of test results
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
mkdir -p packaged-test-results
|
||||
cp -r ./tests/test-results packaged-test-results/ || true
|
||||
cp ./tests/playwright-report.json packaged-test-results/ || true
|
||||
cp -r ./tests/playwright-report packaged-test-results/ || true
|
||||
- name: Upload directory of test results artifact
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-results
|
||||
path: packaged-test-results/
|
||||
retention-days: 5
|
||||
- name: create the coverage report
|
||||
run: |
|
||||
bun nyc report --reporter=lcov --reporter=text
|
||||
pnpm exec nyc report --reporter=lcov --reporter=text
|
||||
- name: Upload the coverage report to GitHub Actions Artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: coverage-report-pr
|
||||
path: coverage
|
||||
retention-days: 3
|
||||
|
||||
# ── CS3D: build and deploy preview to Netlify ───────────────────────
|
||||
- name: Log build context (OHIF/CS3D branch and version for build diagnosis)
|
||||
if: steps.cs3d-check.outputs.enabled == 'true'
|
||||
run: |
|
||||
if [[ "$CS3D_REF_TYPE" == "branch" ]]; then
|
||||
echo "::notice::Build type: ohif-downstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: branch ${{ steps.cs3d-check.outputs.cs3d_ref }}"
|
||||
else
|
||||
echo "::notice::Build type: ohif-upstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: version ${{ steps.cs3d-ref.outputs.version }}"
|
||||
fi
|
||||
node .scripts/log-build-context.mjs
|
||||
env:
|
||||
BUILD_TYPE:
|
||||
${{ steps.cs3d-ref.outputs.type == 'branch' && 'ohif-downstream' || 'ohif-upstream' }}
|
||||
CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }}
|
||||
|
||||
- name: Build OHIF viewer (CS3D preview)
|
||||
if: steps.cs3d-check.outputs.enabled == 'true'
|
||||
run: pnpm run build:ci
|
||||
|
||||
- name: Deploy CS3D preview to Netlify
|
||||
if: steps.cs3d-check.outputs.enabled == 'true'
|
||||
run: |
|
||||
RESULT=$(npx netlify-cli deploy --dir=platform/app/dist --alias="cs3d-pr-${PR_NUM}" --json --filter=@ohif/app) || {
|
||||
echo "::error::Netlify deploy command failed"
|
||||
exit 1
|
||||
}
|
||||
URL=$(echo "$RESULT" | jq -r '.deploy_url')
|
||||
if [[ -z "$URL" || "$URL" == "null" ]]; then
|
||||
echo "::error::Netlify deploy did not return a valid URL"
|
||||
echo "$RESULT"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::CS3D preview deployed: $URL"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
PR_NUM: ${{ github.event.pull_request.number || 'manual' }}
|
||||
|
||||
# ── CS3D: log results ───────────────────────────────────────────────
|
||||
- name: Log CS3D build used
|
||||
if: steps.cs3d-check.outputs.enabled == 'true'
|
||||
run: |
|
||||
if [[ "$CS3D_REF_TYPE" == "branch" ]]; then
|
||||
echo "::notice::CS3D integration PASSED with branch ${CS3D_REF} (linked from libs/@cornerstonejs)"
|
||||
else
|
||||
echo "::notice::CS3D integration PASSED with @cornerstonejs/*@${CS3D_VERSION}"
|
||||
fi
|
||||
env:
|
||||
CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }}
|
||||
CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }}
|
||||
CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }}
|
||||
|
||||
# ── Separate job: block merge when using a CS3D branch ─────────────
|
||||
cs3d-branch-merge-guard:
|
||||
name: 'CS3D Branch Merge Guard'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for CS3D branch usage
|
||||
run: bash .scripts/ci/cs3d-branch-merge-guard.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
# Packages
|
||||
node_modules
|
||||
.cursor/
|
||||
.claude/
|
||||
.nyc_output/
|
||||
# Output
|
||||
build
|
||||
@ -44,6 +45,7 @@ videos/
|
||||
|
||||
# autogenerated files
|
||||
platform/app/src/pluginImports.js
|
||||
CLAUDE.md
|
||||
/Viewers.iml
|
||||
platform/app/.recipes/Nginx-Dcm4Chee/logs/*
|
||||
platform/app/.recipes/OpenResty-Orthanc/logs/*
|
||||
@ -59,4 +61,14 @@ tests/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
**/.claude/settings.local.json
|
||||
# cornerstone3D local linking
|
||||
libs/
|
||||
|
||||
# Backup files
|
||||
*~
|
||||
|
||||
# cornerstone3D local linking
|
||||
libs/
|
||||
link-cs3d.js
|
||||
unlink-cs3d.js
|
||||
auth.json
|
||||
|
||||
@ -6,27 +6,21 @@ cd "$(dirname "$0")"
|
||||
cd .. # Up to project root
|
||||
|
||||
# Helpful to verify which versions we're using
|
||||
echo 'My yarn version is... '
|
||||
echo 'My pnpm version is... '
|
||||
|
||||
yarn -v
|
||||
pnpm -v
|
||||
node -v
|
||||
|
||||
# Build && Move PWA Output
|
||||
yarn run build:ci
|
||||
pnpm run build:ci
|
||||
mkdir -p ./.netlify/www/pwa
|
||||
mv platform/app/dist/* .netlify/www/pwa -v
|
||||
echo 'Web application built and copied'
|
||||
|
||||
# Build && Move Docusaurus Output (for the docs themselves)
|
||||
cd platform/docs
|
||||
yarn install
|
||||
yarn run build
|
||||
cd ../..
|
||||
pnpm --filter ohif-docs run build
|
||||
mkdir -p ./.netlify/www/docs
|
||||
mv platform/docs/build/* .netlify/www/docs -v
|
||||
echo 'Docs built (docusaurus) and copied'
|
||||
|
||||
# Cache all of the node_module dependencies in
|
||||
# extensions, modules, and platform packages
|
||||
yarn run lerna:cache
|
||||
echo 'Nothing left to see here. Go home, folks.'
|
||||
|
||||
@ -2,14 +2,13 @@
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
"node": ">=24",
|
||||
"pnpm": ">=11"
|
||||
},
|
||||
"scripts": {
|
||||
"deploy": "netlify deploy --prod --dir ./../platform/app/dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"netlify-cli": "^2.21.0"
|
||||
"netlify-cli": "2.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
20.9.0
|
||||
24.15.0
|
||||
|
||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@ -0,0 +1,5 @@
|
||||
node-linker=hoisted
|
||||
strict-peer-dependencies=false
|
||||
link-workspace-packages=true
|
||||
prefer-workspace-packages=true
|
||||
manage-package-manager-versions=false
|
||||
42
.scripts/ci/cs3d-branch-merge-guard.sh
Normal file
42
.scripts/ci/cs3d-branch-merge-guard.sh
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# CS3D branch merge guard: blocks merge when tests ran against a CS3D branch (not a version).
|
||||
# Exits 0 when merge is allowed or guard is skipped; exits 1 when merge must be blocked.
|
||||
#
|
||||
# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER
|
||||
# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+)
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "::notice::workflow_dispatch — no merge to block, skipping guard."
|
||||
exit 0
|
||||
elif [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name')
|
||||
if echo "$LABELS" | grep -q "ohif-integration"; then
|
||||
ENABLED=true
|
||||
CS3D_REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \
|
||||
| sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1)
|
||||
if [[ -z "$CS3D_REF" ]]; then
|
||||
CS3D_REF="4.19+"
|
||||
fi
|
||||
else
|
||||
ENABLED=false
|
||||
fi
|
||||
else
|
||||
ENABLED=false
|
||||
fi
|
||||
|
||||
if [[ "$ENABLED" != "true" ]]; then
|
||||
echo "::notice::No ohif-integration label — skipping merge guard."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the ref is a branch (not a version)
|
||||
if [[ "$CS3D_REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then
|
||||
echo "::notice::CS3D ref '$CS3D_REF' is a version — merge allowed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Tests ran against CS3D branch '${CS3D_REF}' — this build cannot be merged."
|
||||
echo "::error::Re-run with a published CS3D version (e.g. 4.19+) before merging."
|
||||
exit 1
|
||||
29
.scripts/ci/cs3d-check-integration.sh
Normal file
29
.scripts/ci/cs3d-check-integration.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# CS3D integration check: detects ohif-integration label and parses CS3D_REF.
|
||||
# Writes to GITHUB_OUTPUT: enabled (true|false), cs3d_ref (when enabled).
|
||||
#
|
||||
# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER, GITHUB_OUTPUT
|
||||
# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+)
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
echo "cs3d_ref=${CS3D_REF_INPUT:-4.19+}" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name')
|
||||
if echo "$LABELS" | grep -q "ohif-integration"; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \
|
||||
| sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1)
|
||||
if [[ -z "$REF" ]]; then
|
||||
REF="4.19+"
|
||||
fi
|
||||
echo "cs3d_ref=${REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::CS3D ref from PR body: ${REF}"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
54
.scripts/ci/with-nashua-lock.sh
Normal file
54
.scripts/ci/with-nashua-lock.sh
Normal file
@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run a command while holding the nashua Playwright mutex.
|
||||
#
|
||||
# Only one Playwright run may execute at a time on the shared nashua self-hosted
|
||||
# runner — across THIS repo (OHIF) AND the cornerstone3D repo. GitHub's
|
||||
# `concurrency:` is scoped per-repository and cannot coordinate across the two
|
||||
# orgs, so the mutex lives on the box's filesystem.
|
||||
#
|
||||
# This is OHIF's own copy (the script can't be shared across separate repos). It
|
||||
# MUST use the SAME lock path as cornerstone3D's copy (NASHUA_LOCK_FILE default
|
||||
# below) — if the paths differ, the mutex silently does nothing.
|
||||
#
|
||||
# flock holds the lock via fd 9 and it releases automatically when the wrapped
|
||||
# command exits — including on job cancel / timeout / SIGKILL — so there are no
|
||||
# stale locks to clean up.
|
||||
#
|
||||
# Usage: .scripts/ci/with-nashua-lock.sh <command> [args...]
|
||||
|
||||
# Strict mode: -e abort on any unhandled command failure, -u treat use of an
|
||||
# unset variable as an error, -o pipefail make a pipeline fail if ANY stage
|
||||
# fails (not just the last). Catches mistakes early instead of pressing on.
|
||||
set -euo pipefail
|
||||
|
||||
LOCK="${NASHUA_LOCK_FILE:-/var/tmp/nashua-playwright.lock}" # MUST match cornerstone3D's copy
|
||||
LOCK_WAIT="${NASHUA_LOCK_WAIT:-5400}" # max seconds to wait
|
||||
|
||||
# Guard: a command to wrap is required. With no arguments there is nothing to
|
||||
# run, so print usage and exit instead of silently grabbing and releasing the
|
||||
# lock for no reason (which would mask a mis-wired workflow step).
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "usage: $0 <command> [args...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Open the lock file on fd 9 (read-write, create if missing, never truncate).
|
||||
exec 9<>"$LOCK" || { echo "::error::cannot open lock file $LOCK"; exit 1; }
|
||||
|
||||
# Try instantly; if busy, report who holds it, then block up to LOCK_WAIT.
|
||||
if ! flock -n 9; then
|
||||
echo "nashua Playwright runner busy — held by: $(cat "${LOCK}.info" 2>/dev/null || echo unknown). Waiting up to ${LOCK_WAIT}s…"
|
||||
if ! flock -w "$LOCK_WAIT" 9; then
|
||||
echo "::error::Timed out after ${LOCK_WAIT}s waiting for the nashua Playwright lock"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Record the current holder for other jobs' "held by" message (best-effort).
|
||||
echo "${GITHUB_REPOSITORY:-local}#${GITHUB_RUN_ID:-0} @ $(date -u +%FT%TZ 2>/dev/null || true)" > "${LOCK}.info" 2>/dev/null || true
|
||||
echo "✅ Acquired nashua Playwright lock ($LOCK); running: $*"
|
||||
|
||||
# Replace the shell with the command. fd 9 is inherited (not close-on-exec), so
|
||||
# the lock is held for the whole command and released when it exits.
|
||||
exec "$@"
|
||||
55
.scripts/cs3d-check.mjs
Normal file
55
.scripts/cs3d-check.mjs
Normal file
@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const workflowPath = path.resolve(
|
||||
process.cwd(),
|
||||
'libs',
|
||||
'@cornerstonejs',
|
||||
'.github',
|
||||
'workflows',
|
||||
'ohif-downstream.yml'
|
||||
);
|
||||
|
||||
if (!fs.existsSync(workflowPath)) {
|
||||
console.error(`[cs3d:check] Workflow file not found: ${workflowPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workflowText = fs.readFileSync(workflowPath, 'utf8');
|
||||
const ohifRefMatch = workflowText.match(/^\s*OHIF_REF:\s*["']?([^"'\r\n]+)["']?\s*$/m);
|
||||
|
||||
if (!ohifRefMatch) {
|
||||
console.error('[cs3d:check] Could not find OHIF_REF in ohif-downstream workflow.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expectedBranch = ohifRefMatch[1].trim();
|
||||
let currentBranch = '';
|
||||
|
||||
try {
|
||||
currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
console.error('[cs3d:check] Failed to determine current git branch.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (currentBranch !== expectedBranch) {
|
||||
console.error(
|
||||
`[cs3d:check] Branch mismatch: current='${currentBranch}', expected='${expectedBranch}' from ${path.relative(
|
||||
process.cwd(),
|
||||
workflowPath
|
||||
)}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[cs3d:check] OK: current branch '${currentBranch}' matches OHIF_REF in ${path.relative(
|
||||
process.cwd(),
|
||||
workflowPath
|
||||
)}`
|
||||
);
|
||||
63
.scripts/cs3d-resolve-version.mjs
Normal file
63
.scripts/cs3d-resolve-version.mjs
Normal file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Resolves a version pattern to a concrete npm version of @cornerstonejs/core.
|
||||
*
|
||||
* Patterns:
|
||||
* 4.18.2 -> 4.18.2 (exact, returned as-is)
|
||||
* 4.18.2-beta.3 -> 4.18.2-beta.3 (exact prerelease)
|
||||
* 4.x -> latest 4.* from npm
|
||||
* 4.17.x -> latest 4.17.* from npm
|
||||
* 4.19+ -> latest >=4.19.0 <5.0.0-0 from npm (4.19 and later, same major)
|
||||
*
|
||||
* Prints the resolved version to stdout.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const pattern = process.argv[2];
|
||||
if (!pattern) {
|
||||
console.error('Usage: cs3d-resolve-version.mjs <pattern>');
|
||||
console.error(' e.g. 4.x, 4.17.x, 4.19+, 4.18.2, 4.18.2-beta.3');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Exact version (no wildcard or range) — pass through unchanged
|
||||
if (!pattern.includes('x') && !pattern.endsWith('+')) {
|
||||
console.log(pattern);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Convert "M.m+" to npm semver range ">=M.m.0 <(M+1).0.0-0"
|
||||
let npmRange = pattern;
|
||||
const plusMatch = pattern.match(/^(\d+)\.(\d+)\+$/);
|
||||
if (plusMatch) {
|
||||
const major = Number(plusMatch[1]);
|
||||
const minor = Number(plusMatch[2]);
|
||||
npmRange = `>=${major}.${minor}.0 <${major + 1}.0.0-0`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Let npm resolve the range: @package@<range> → concrete version
|
||||
const raw = execSync(`npm view @cornerstonejs/core@"${npmRange}" version --json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const resolved = JSON.parse(raw);
|
||||
if (!resolved) {
|
||||
console.error(`npm returned no version when resolving @cornerstonejs/core@${npmRange}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// npm may return an array of versions for ranges; pick the latest (last) one
|
||||
const version = Array.isArray(resolved) ? resolved[resolved.length - 1] : resolved;
|
||||
console.log(version);
|
||||
} catch (err) {
|
||||
const message =
|
||||
(err && (err.stderr?.toString() || err.message || String(err))) ||
|
||||
`Unknown error resolving @cornerstonejs/core@${pattern}`;
|
||||
console.error(`Failed to resolve @cornerstonejs/core version for pattern "${pattern}":`);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
131
.scripts/cs3d-set-version.mjs
Normal file
131
.scripts/cs3d-set-version.mjs
Normal file
@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Updates all @cornerstonejs/* package versions across the OHIF workspace.
|
||||
*
|
||||
* Usage: node .scripts/cs3d-set-version.mjs <version>
|
||||
*
|
||||
* Only updates the 8 main CS3D packages (not codec packages):
|
||||
* adapters, ai, core, dicom-image-loader, labelmap-interpolation,
|
||||
* nifti-volume-loader, polymorphic-segmentation, tools
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { resolve, dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
|
||||
const version = process.argv[2];
|
||||
if (!version) {
|
||||
console.error('Usage: cs3d-set-version.mjs <version>');
|
||||
console.error(' e.g. 4.18.2, 4.19.0-beta.1');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The 8 CS3D packages that are built from source (not codecs)
|
||||
const CS3D_PACKAGES = [
|
||||
'@cornerstonejs/adapters',
|
||||
'@cornerstonejs/ai',
|
||||
'@cornerstonejs/core',
|
||||
'@cornerstonejs/dicom-image-loader',
|
||||
'@cornerstonejs/labelmap-interpolation',
|
||||
'@cornerstonejs/nifti-volume-loader',
|
||||
'@cornerstonejs/polymorphic-segmentation',
|
||||
'@cornerstonejs/tools',
|
||||
];
|
||||
|
||||
// Read root package.json to get workspace globs
|
||||
const rootPkgPath = resolve(rootDir, 'package.json');
|
||||
const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8'));
|
||||
const workspaceGlobs = rootPkg.workspaces?.packages || rootPkg.workspaces || [];
|
||||
|
||||
// Collect all package.json paths from workspace globs
|
||||
function findWorkspacePackageJsons() {
|
||||
const paths = [rootPkgPath]; // include root
|
||||
|
||||
for (const pattern of workspaceGlobs) {
|
||||
const parts = pattern.split('/');
|
||||
let searchDir = rootDir;
|
||||
let hasWildcard = false;
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '*') {
|
||||
hasWildcard = true;
|
||||
break;
|
||||
}
|
||||
searchDir = join(searchDir, part);
|
||||
}
|
||||
|
||||
if (hasWildcard) {
|
||||
try {
|
||||
const entries = readdirSync(searchDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const pkgJson = join(searchDir, entry.name, 'package.json');
|
||||
if (existsSync(pkgJson)) {
|
||||
paths.push(pkgJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// directory doesn't exist, skip
|
||||
}
|
||||
} else {
|
||||
const pkgJson = join(rootDir, pattern, 'package.json');
|
||||
if (existsSync(pkgJson)) {
|
||||
paths.push(pkgJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Update a dependencies object, returning count of changes
|
||||
function updateDeps(deps, targetVersion) {
|
||||
let count = 0;
|
||||
if (!deps) return count;
|
||||
for (const pkg of CS3D_PACKAGES) {
|
||||
if (pkg in deps && deps[pkg] !== targetVersion) {
|
||||
deps[pkg] = targetVersion;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const pkgPaths = findWorkspacePackageJsons();
|
||||
let totalChanges = 0;
|
||||
|
||||
for (const pkgPath of pkgPaths) {
|
||||
const content = readFileSync(pkgPath, 'utf8');
|
||||
const pkg = JSON.parse(content);
|
||||
let changes = 0;
|
||||
|
||||
changes += updateDeps(pkg.dependencies, version);
|
||||
changes += updateDeps(pkg.devDependencies, version);
|
||||
changes += updateDeps(pkg.peerDependencies, version);
|
||||
changes += updateDeps(pkg.resolutions, version);
|
||||
|
||||
if (changes > 0) {
|
||||
// Preserve original formatting (detect indent — restrict to spaces/tabs so
|
||||
// we don't accidentally capture a CRLF newline as part of the indent string)
|
||||
const indent = content.match(/^([ \t]+)/m)?.[1] || ' ';
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
|
||||
const rel = pkgPath.replace(rootDir + '/', '').replace(rootDir + '\\', '');
|
||||
console.log(` Updated ${rel} (${changes} packages)`);
|
||||
totalChanges += changes;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone: ${totalChanges} version(s) updated to ${version} across ${pkgPaths.length} package files.`
|
||||
);
|
||||
console.log(
|
||||
'This step changes package.json; the following install must not use a frozen Bun lockfile ' +
|
||||
'(OHIF+CS3D combined “version” CI does: `bun install --config=./bunfig.update-lockfile.toml`). ' +
|
||||
'Other installs stay frozen. Locally after this script, use that bun command and/or ' +
|
||||
'`bun run install:update-lockfile` when you intend to commit lockfile updates.\n'
|
||||
);
|
||||
@ -6,9 +6,9 @@ PROJECT=$1
|
||||
if [ -z "$PROJECT" ]
|
||||
then
|
||||
# Default
|
||||
npx lerna run dev:viewer
|
||||
pnpm --filter @ohif/app run dev:viewer
|
||||
else
|
||||
eval "npx lerna run dev:$PROJECT"
|
||||
pnpm --filter @ohif/app run "dev:$PROJECT"
|
||||
fi
|
||||
|
||||
read -p 'Press [Enter] key to continue...'
|
||||
|
||||
108
.scripts/log-build-context.mjs
Normal file
108
.scripts/log-build-context.mjs
Normal file
@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Logs build context (OHIF branch/version, CS3D source) for diagnosing build issues on GitHub.
|
||||
* Run from repo root. Used by version.mjs and can be invoked from CI workflows.
|
||||
*/
|
||||
import { execa } from 'execa';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[build-context] ${msg}`);
|
||||
}
|
||||
|
||||
async function detectCs3dSource() {
|
||||
const libsCs3d = path.join(REPO_ROOT, 'libs/@cornerstonejs');
|
||||
const coreInNodeModules = path.join(REPO_ROOT, 'node_modules/@cornerstonejs/core');
|
||||
|
||||
try {
|
||||
const libsExists = await fs.access(libsCs3d).then(() => true).catch(() => false);
|
||||
if (!libsExists) {
|
||||
return { source: 'npm', detail: 'published @cornerstonejs packages (no libs/@cornerstonejs)' };
|
||||
}
|
||||
|
||||
const coreStat = await fs.lstat(coreInNodeModules).catch(() => null);
|
||||
const isSymlink = coreStat?.isSymbolicLink();
|
||||
if (isSymlink) {
|
||||
const target = await fs.readlink(coreInNodeModules);
|
||||
return { source: 'integrated', detail: `linked from libs/@cornerstonejs (→ ${target})` };
|
||||
}
|
||||
|
||||
return { source: 'npm', detail: 'published @cornerstonejs packages (libs exists but not linked)' };
|
||||
} catch {
|
||||
return { source: 'unknown', detail: 'could not detect' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCs3dVersion() {
|
||||
try {
|
||||
const corePkg = path.join(REPO_ROOT, 'node_modules/@cornerstonejs/core/package.json');
|
||||
const pkg = JSON.parse(await fs.readFile(corePkg, 'utf-8'));
|
||||
return pkg.version || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
async function getCs3dBranchFromLibs() {
|
||||
try {
|
||||
const libsCs3d = path.join(REPO_ROOT, 'libs/@cornerstonejs');
|
||||
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: libsCs3d,
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const inCI = process.env.GITHUB_ACTIONS === 'true';
|
||||
const buildType = inCI ? (process.env.BUILD_TYPE || 'ohif-upstream') : 'local';
|
||||
|
||||
log('═══════════════════════════════════════════════════════════════');
|
||||
log('Build context (for diagnosing GitHub build issues)');
|
||||
log('═══════════════════════════════════════════════════════════════');
|
||||
|
||||
if (inCI) {
|
||||
log(`Build type: ${process.env.BUILD_TYPE || 'ohif-upstream'}`);
|
||||
log(`GitHub repo: ${process.env.GITHUB_REPOSITORY || 'unknown'}`);
|
||||
log(`GitHub ref: ${process.env.GITHUB_REF || 'unknown'}`);
|
||||
log(`GitHub SHA: ${process.env.GITHUB_SHA || 'unknown'}`);
|
||||
log(`Workflow: ${process.env.GITHUB_WORKFLOW || 'unknown'}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: branch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
const { stdout: sha } = await execa('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
log(`OHIF branch: ${branch} (${sha})`);
|
||||
} catch {
|
||||
log('OHIF branch: (not a git repo or error)');
|
||||
}
|
||||
|
||||
const cs3d = await detectCs3dSource();
|
||||
log(`CS3D source: ${cs3d.source} — ${cs3d.detail}`);
|
||||
|
||||
if (cs3d.source === 'integrated') {
|
||||
const branch = await getCs3dBranchFromLibs();
|
||||
if (branch) log(`CS3D branch (libs/@cornerstonejs): ${branch}`);
|
||||
} else {
|
||||
const ver = await getCs3dVersion();
|
||||
log(`CS3D version (@cornerstonejs/core): ${ver}`);
|
||||
}
|
||||
|
||||
log('═══════════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('[build-context] Error:', err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
51
.webpack/resolveConfig.js
Normal file
51
.webpack/resolveConfig.js
Normal file
@ -0,0 +1,51 @@
|
||||
const path = require('path');
|
||||
|
||||
// Shared module-resolution rules (alias + module search paths) used by BOTH
|
||||
// build pipelines so they cannot drift apart:
|
||||
// - the webpack/rspack build: webpack.base.js -> webpack.pwa.js, plus every
|
||||
// per-package webpack.prod.js / webpack.dev.js that merges webpack.base.js
|
||||
// - the rsbuild build: rsbuild.config.ts (dev:fast)
|
||||
//
|
||||
// Plugin aliases (writePluginImportsFile.getPluginResolveAliases) are NOT
|
||||
// included here: they depend on pluginConfig.json and are merged in separately
|
||||
// by each config.
|
||||
//
|
||||
// All paths are anchored to this file's location (the repo-root `.webpack/`
|
||||
// directory), so the values are identical regardless of which package's build
|
||||
// is doing the resolving.
|
||||
|
||||
const alias = {
|
||||
// Some extensions import app-level utilities (e.g. history,
|
||||
// preserveQueryParameters) from '@ohif/app'. pnpm's isolated layout does not
|
||||
// expose the top-level app package to those extensions, and adding it as a
|
||||
// workspace dependency would create an app<->default cycle, so we resolve the
|
||||
// bare specifier to the app source here ($ = exact match).
|
||||
'@ohif/app$': path.resolve(__dirname, '../platform/app/src/index.js'),
|
||||
'@': path.resolve(__dirname, '../platform/app/src'),
|
||||
'@components': path.resolve(__dirname, '../platform/app/src/components'),
|
||||
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
|
||||
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
|
||||
'@state': path.resolve(__dirname, '../platform/app/src/state'),
|
||||
};
|
||||
|
||||
// Directories to search when resolving modules. The leading bare 'node_modules'
|
||||
// preserves the default importer-relative walk-up, which pnpm's isolated layout
|
||||
// requires so that transitive deps (e.g. react-remove-scroll -> tslib 2.x)
|
||||
// resolve to the sibling copy inside .pnpm/<pkg>/node_modules rather than a
|
||||
// hoisted older version.
|
||||
const moduleSearchPaths = [
|
||||
'node_modules',
|
||||
path.resolve(__dirname, '../node_modules'),
|
||||
path.resolve(__dirname, '../../../node_modules'),
|
||||
path.resolve(__dirname, '../platform/app/node_modules'),
|
||||
path.resolve(__dirname, '../platform/ui/node_modules'),
|
||||
];
|
||||
|
||||
// Build the resolve.modules array for a build. `srcDir` is the building
|
||||
// package's source root; it is appended last (matching the previous inline
|
||||
// behavior in webpack.base.js). Pass nothing to get just the shared paths.
|
||||
function getModules(srcDir) {
|
||||
return srcDir ? [...moduleSearchPaths, srcDir] : [...moduleSearchPaths];
|
||||
}
|
||||
|
||||
module.exports = { alias, moduleSearchPaths, getModules };
|
||||
@ -2,14 +2,14 @@ const autoprefixer = require('autoprefixer');
|
||||
const path = require('path');
|
||||
const tailwindcss = require('tailwindcss');
|
||||
const tailwindConfigPath = path.resolve('../../platform/app/tailwind.config.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const rspack = require('@rspack/core');
|
||||
const devMode = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const cssToJavaScript = {
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
//'style-loader',
|
||||
devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
|
||||
devMode ? 'style-loader' : rspack.CssExtractRspackPlugin.loader,
|
||||
{ loader: 'css-loader', options: { importLoaders: 1 } },
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
|
||||
@ -4,6 +4,8 @@ function transpileJavaScript(mode) {
|
||||
const exclude =
|
||||
mode === 'production'
|
||||
? excludeNodeModulesExcept([
|
||||
// Workspace packages (needed for pnpm shamefully-hoist where they resolve through node_modules)
|
||||
'@ohif',
|
||||
// 'dicomweb-client',
|
||||
// https://github.com/react-dnd/react-dnd/blob/master/babel.config.js
|
||||
'react-dnd',
|
||||
|
||||
@ -4,11 +4,10 @@ const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
|
||||
// ~~ PLUGINS
|
||||
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
const TerserJSPlugin = require('terser-webpack-plugin');
|
||||
|
||||
// ~~ PackageJSON
|
||||
// const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core
|
||||
@ -18,9 +17,15 @@ const TerserJSPlugin = require('terser-webpack-plugin');
|
||||
const loadWebWorkersRule = require('./rules/loadWebWorkers.js');
|
||||
const transpileJavaScriptRule = require('./rules/transpileJavaScript.js');
|
||||
const cssToJavaScript = require('./rules/cssToJavaScript.js');
|
||||
// Module-resolution rules shared with the rsbuild build (see rsbuild.config.ts).
|
||||
const resolveConfig = require('./resolveConfig');
|
||||
// Only uncomment for old v2 stylus
|
||||
// const stylusToJavaScript = require('./rules/stylusToJavaScript.js');
|
||||
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
let ReactRefreshWebpackPlugin;
|
||||
try {
|
||||
const mod = require('@rspack/plugin-react-refresh');
|
||||
ReactRefreshWebpackPlugin = mod.ReactRefreshRspackPlugin || mod.default || mod;
|
||||
} catch { ReactRefreshWebpackPlugin = null; }
|
||||
|
||||
// ~~ ENV VARS
|
||||
const NODE_ENV = process.env.NODE_ENV;
|
||||
@ -66,6 +71,12 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
|
||||
const config = {
|
||||
mode: isProdBuild ? 'production' : 'development',
|
||||
devtool: isProdBuild ? 'source-map' : 'cheap-module-source-map',
|
||||
// `rspack serve` (@rspack/cli) auto-enables lazyCompilation for web-only
|
||||
// apps unless the config defines it explicitly. The on-demand proxy chunks
|
||||
// it produces fail to load in the headless cypress/electron e2e run
|
||||
// (ChunkLoadError on cornerstone vendor chunks), so disable it here to match
|
||||
// the rsbuild build (see rsbuild.config.ts `dev.lazyCompilation: false`).
|
||||
lazyCompilation: false,
|
||||
entry: ENTRY,
|
||||
optimization: {
|
||||
// splitChunks: {
|
||||
@ -92,9 +103,7 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
cache: {
|
||||
type: 'filesystem',
|
||||
},
|
||||
cache: isProdBuild ? false : { type: 'filesystem' },
|
||||
module: {
|
||||
noParse: [/(dicomicc)/],
|
||||
rules: [
|
||||
@ -168,9 +177,6 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
|
||||
},
|
||||
},
|
||||
cssToJavaScript,
|
||||
// Note: Only uncomment the following if you are using the old style of stylus in v2
|
||||
// Also you need to uncomment this platform/app/.webpack/rules/extractStyleChunks.js
|
||||
// stylusToJavaScript,
|
||||
{
|
||||
test: /\.wasm/,
|
||||
type: 'asset/resource',
|
||||
@ -194,29 +200,16 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
|
||||
},
|
||||
resolve: {
|
||||
mainFields: ['module', 'browser', 'main'],
|
||||
// alias and modules are shared with the rsbuild build via ./resolveConfig
|
||||
// so the two pipelines resolve identically.
|
||||
alias: {
|
||||
// Viewer project
|
||||
'@': path.resolve(__dirname, '../platform/app/src'),
|
||||
'@components': path.resolve(__dirname, '../platform/app/src/components'),
|
||||
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
|
||||
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
|
||||
'@state': path.resolve(__dirname, '../platform/app/src/state'),
|
||||
'dicom-microscopy-viewer':
|
||||
'dicom-microscopy-viewer/dist/dynamic-import/dicomMicroscopyViewer.min.js',
|
||||
...resolveConfig.alias,
|
||||
},
|
||||
// Which directories to search when resolving modules
|
||||
modules: [
|
||||
// Modules specific to this package
|
||||
path.resolve(__dirname, '../node_modules'),
|
||||
// Hoisted Yarn Workspace Modules
|
||||
path.resolve(__dirname, '../../../node_modules'),
|
||||
path.resolve(__dirname, '../platform/app/node_modules'),
|
||||
path.resolve(__dirname, '../platform/ui/node_modules'),
|
||||
SRC_DIR,
|
||||
],
|
||||
modules: resolveConfig.getModules(SRC_DIR),
|
||||
// Attempt to resolve these extensions in order.
|
||||
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '*'],
|
||||
// symlinked resources are resolved to their real path, not their symlinked location
|
||||
// Workspace packages use relative imports between sibling packages.
|
||||
// Resolve symlinks to keep those imports anchored at the real package paths.
|
||||
symlinks: true,
|
||||
fallback: {
|
||||
fs: false,
|
||||
@ -225,24 +218,34 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
|
||||
buffer: require.resolve('buffer'),
|
||||
},
|
||||
},
|
||||
node: {
|
||||
// Leave __filename / __dirname references alone. The previous 'mock'
|
||||
// value triggers an rspack warning whenever bundled deps reference
|
||||
// __dirname (e.g. Emscripten-compiled cornerstone codecs). Those refs
|
||||
// sit inside `if (ENVIRONMENT_IS_NODE)` branches that never execute in
|
||||
// the browser, so leaving them un-substituted is harmless at runtime.
|
||||
__filename: false,
|
||||
__dirname: false,
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin(defineValues),
|
||||
new webpack.ProvidePlugin({
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
...(isProdBuild ? [] : [new ReactRefreshWebpackPlugin({ overlay: false })]),
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^(fs|path)$/,
|
||||
contextRegExp: /@cornerstonejs[\\/]codec-/,
|
||||
}),
|
||||
...(isProdBuild || IS_COVERAGE || !ReactRefreshWebpackPlugin
|
||||
? []
|
||||
: [new ReactRefreshWebpackPlugin({ overlay: false })]),
|
||||
// Uncomment to generate bundle analyzer
|
||||
// new BundleAnalyzerPlugin(),
|
||||
],
|
||||
};
|
||||
|
||||
if (isProdBuild) {
|
||||
config.optimization.minimizer = [
|
||||
new TerserJSPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {},
|
||||
}),
|
||||
];
|
||||
config.optimization.minimizer = [new webpack.SwcJsMinimizerRspackPlugin()];
|
||||
}
|
||||
|
||||
if (isQuickBuild) {
|
||||
|
||||
205
AGENTS.md
Normal file
205
AGENTS.md
Normal file
@ -0,0 +1,205 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI coding agents (Claude, Codex, and other LLM tools) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is **OHIF** v3 (Open Health Imaging Foundation) - a medical imaging viewer. It's an extensible web imaging platform.
|
||||
## Development Commands
|
||||
|
||||
### Main Development
|
||||
```bash
|
||||
# Start development server for all packages
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Build all packages for production
|
||||
yarn build
|
||||
|
||||
# Build specific packages
|
||||
cd platform/app && yarn build # Main viewer app
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
- **`platform/`** - Core OHIF infrastructure
|
||||
- `app/` - Main viewer application (`@ohif/viewer`)
|
||||
- `core/` - Core services and utilities
|
||||
- `ui-next/` - Modern UI component library
|
||||
- **`extensions/`** - Modular functionality plugins
|
||||
- **`modes/`** - Application workflow configurations
|
||||
|
||||
### Key Extension Architecture
|
||||
|
||||
**Extension System**: Each extension exports modules (viewports, tools, panels, commands) that the app dynamically loads. Extensions are self-contained with their own webpack builds.
|
||||
|
||||
|
||||
**Core Extensions:**
|
||||
- `cornerstone/` - Medical image rendering engine
|
||||
- `cornerstone-dicom-pmp/` - DICOM PMP support
|
||||
- `cornerstone-dicom-seg/` - DICOM Segmentation support
|
||||
- `cornerstone-dicom-sr/` - DICOM SR support
|
||||
- `dicom-pdf/` - DICOM PDF support
|
||||
- `dicom-video/` - DICOM Video support
|
||||
- `measurement-tracking/` - Measurement tracking support
|
||||
- `default/` - Standard OHIF functionality
|
||||
|
||||
### Service-Oriented Design (PUB-SUB)
|
||||
|
||||
The app uses a Services Manager pattern with these core services:
|
||||
- **Display Set Service**: Manages image series organization
|
||||
- **Measurement Service**: Handles annotations and measurements
|
||||
- **Hanging Protocol Service**: Controls image layout and display rules
|
||||
- **UI Service**: Manages panels, modals, and notifications
|
||||
- **Segmentation Service**: AI/ML powered image segmentation, loading segmentations, etc.
|
||||
- **Viewport Grid Service**: Manages viewport layout and display rules
|
||||
- **Viewport Display Set History Service**: Manages viewport display set history
|
||||
- **Viewport Dialog Service**: Manages viewport dialogs
|
||||
- **Notification Service**: Manages notifications
|
||||
- **Modal Service**: Manages modals
|
||||
- **Dialog Service**: Manages dialogs, more general not just viewport dialogs
|
||||
- **Customization Service**: Manages customization of the app
|
||||
- **Toolbar Service**: Manages the toolbar, viewport action corners, tool states
|
||||
- **User Authentication Service**: Manages user authentication, but used only for injecting tokens in dicomweb requests in our context
|
||||
- **Panel Service**: Manages side panels
|
||||
- **Cornerstone Viewport Service**: Manages the cornerstone viewport, rendering engines, presentation states, more tightly coupled to cornerstone than the other services
|
||||
- **Tool Group Service**: Manages tool groups, creating and managing tool groups, etc.
|
||||
- **Sync Group Service**: Manages sync groups, syncing zooming, panning, scrolling, etc.
|
||||
- **Cornerstone Cache Service**: Manages the cornerstone cache, caching images, etc.
|
||||
|
||||
Most of the services utilize a pub sub architecture and extend the pub sub service interace at `pubSubServiceInterface.ts`
|
||||
|
||||
### Commands Manager
|
||||
|
||||
The Commands Manager tracks named commands (or functions) that are scoped to
|
||||
a context. When we attempt to run a command with a given name, we look for it
|
||||
in our active contexts, in the order specified.
|
||||
If found, we run the command, passing in any application
|
||||
or call specific data specified in the command's definition.
|
||||
|
||||
You can call `commandsManager.runCommand` to run a command.
|
||||
|
||||
### Extension Manager
|
||||
|
||||
Aggregates and exposes extension modules throughout the OHIF application, manages data sources, and provides a centralized registry for accessing extension functionality.
|
||||
|
||||
### Build System
|
||||
|
||||
**Yarn Workspaces**: Optimized monorepo builds with dependency caching
|
||||
**Webpack 5**: Module federation for dynamic extension loading
|
||||
**Plugin Import System**: Extensions auto-register via `writePluginImportsFile.js`
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **React 18 + TypeScript**: UI framework
|
||||
- **Cornerstone.js**: Medical image rendering
|
||||
- **DICOM**: Medical imaging standard support
|
||||
- **ONNX Runtime**: AI model inference (SAM segmentation models)
|
||||
- **Zustand**: State management
|
||||
- **TailwindCSS**: Styling system
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Adding New Tools
|
||||
1. Create tool class in `extensions/cornerstone/src/tools/`
|
||||
2. Register in tool module's `toolNames.ts`
|
||||
3. Add to toolbar via `getToolbarModule.tsx`
|
||||
4. Add measurement mapping if needed in `measurementServiceMappings/`
|
||||
|
||||
### Creating Extensions
|
||||
Extensions must export:
|
||||
- `id.js` - Unique extension identifier
|
||||
- `index.tsx` - Extension registration
|
||||
- Module functions (`getToolbarModule`, `getViewportModule`, etc.)
|
||||
|
||||
### Viewport Customization
|
||||
Custom viewports extend base Cornerstone viewport:
|
||||
- Override render methods for custom overlays
|
||||
- Implement measurement tracking
|
||||
- Add viewport-specific tools and interactions
|
||||
|
||||
### Service Integration
|
||||
Register services in extension's `servicesManager.registerService` and access via:
|
||||
```javascript
|
||||
const { MeasurementService } = servicesManager.services;
|
||||
```
|
||||
|
||||
### Creating stores
|
||||
To create a store, you can make one in your extension's `stores/` directory, and you can follow the example of an existing store such as `useLutPresentationStore.ts` or `useSynchronizersStore.ts`.
|
||||
|
||||
### Creating hooks
|
||||
To create a hook, you can make one in your extension's `hooks/` directory, and you can follow the example of an existing hook such as `usePatientInfo.tsx`.
|
||||
|
||||
### Creating providers
|
||||
To create a provider, you can make one in your extension's `providers/` or `contexts/` directory, and you can follow the example of an existing provider such as `ViewportGridProvider.tsx`.
|
||||
|
||||
### Adding new icons
|
||||
To add a new icon, you can add it to the `icons/` directory, then register the icon using `import { addIcon } from '@ohif/extension-default/src/utils'`
|
||||
|
||||
### Creating synchronizers
|
||||
You can create custom synchronizers and place them in the `synchronizers/` directory, you can follow the example of `frameViewSynchronizer.ts`
|
||||
|
||||
### Utilites
|
||||
Any new utilites should be placed in the `utils/` directory, and you can follow the example of `formatPN.ts`
|
||||
|
||||
### Commands
|
||||
Commands are created in the commandsModule of the extension, for example the cornerstone extension has `commandsModule.tsx`, sometimes its also named `getCommandsModule.tsx.`
|
||||
|
||||
### Overriding OHIF Components
|
||||
|
||||
To override an OHIF component, you can create a new component in your extension's `components/` directory, then import it instead of the original ui-next component.
|
||||
|
||||
### Mode layout
|
||||
|
||||
The layoutTemplate is a function that returns a layout object, you can follow the example of `longitudinal/src/index.ts`. This would be helpful when you need to override a component as you can know where to look for the original component.
|
||||
|
||||
### Pub Sub
|
||||
Always prioritrize pub sub, by calling a services subscribe over useEffects as it's more reliable, for example
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
cornerstoneViewportService.subscribe(EVENTS.VIEWPORT_DATA_CHANGED, handleViewportDataChanged),
|
||||
syncGroupService.subscribe(EVENTS.VIEWPORT_REMOVED, onHotKeyRemoval),
|
||||
syncGroupService.subscribe(EVENTS.VIEWPORT_ADDED, onHotKeyAddition),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(({ unsubscribe }) => unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Never modify core architecture
|
||||
Do not modify the core and always find a way to implement the solution via the extensions and modes, only modify core as a last resort if all other fail or there's an architectural constraint.
|
||||
|
||||
## Skills
|
||||
|
||||
The `ohif-test-agent` skill (Playwright E2E test guidance) lives at `.agents/skills/ohif-test-agent/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Plugin Configuration
|
||||
Extensions are auto-discovered via `pluginConfig.json` and dynamically imported during build.
|
||||
|
||||
## Medical Imaging Specifics
|
||||
|
||||
### DICOM Support
|
||||
- Multi-format: CT, MRI, X-Ray, Mammography, Ultrasound
|
||||
- SOP Class handlers for specialized DICOM types (RT, SEG, SR)
|
||||
- DICOMweb protocol for web-based image retrieval
|
||||
|
||||
### Hanging Protocols
|
||||
Define how images are arranged and displayed:
|
||||
- Located in `hps/` directories
|
||||
- JSON configuration with viewport rules
|
||||
- Support for priors comparison and multi-monitor layouts
|
||||
|
||||
### Measurement Tools
|
||||
- Cornerstone Tools integration for annotations
|
||||
- Bidirectional measurements, polylines, annotations
|
||||
- Export capabilities (DICOM SR, CSV reports)
|
||||
- AI-assisted measurements via ONNX models
|
||||
2496
CHANGELOG.md
2496
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
55
Dockerfile
55
Dockerfile
@ -20,48 +20,33 @@
|
||||
#
|
||||
|
||||
|
||||
# syntax=docker/dockerfile:1.7-labs
|
||||
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
|
||||
#
|
||||
# It's a good example of how to build our static application and package it
|
||||
# with a web server capable of hosting it as static content.
|
||||
#
|
||||
# docker build
|
||||
# --------------
|
||||
# If you would like to use this dockerfile to build and tag an image, make sure
|
||||
# you set the context to the project's root directory:
|
||||
# https://docs.docker.com/engine/reference/commandline/build/
|
||||
#
|
||||
#
|
||||
# SUMMARY
|
||||
# --------------
|
||||
# This dockerfile is used as an input for a second stage to make things run faster.
|
||||
#
|
||||
|
||||
|
||||
# Stage 1: Build the application
|
||||
# docker build -t ohif/viewer:latest .
|
||||
# Copy Files
|
||||
FROM node:20.18.1-slim as builder
|
||||
FROM node:24.15.0-slim as builder
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential python3
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pnpm@11
|
||||
|
||||
RUN mkdir /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
RUN npm install -g bun
|
||||
# RUN npm install -g lerna@7.4.2
|
||||
ENV PATH=/usr/src/app/node_modules/.bin:$PATH
|
||||
|
||||
# Do an initial install and then a final install
|
||||
COPY package.json yarn.lock preinstall.js lerna.json ./
|
||||
COPY --parents ./addOns/package.json ./addOns/*/*/package.json ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./
|
||||
# Run the install before copying the rest of the files
|
||||
|
||||
RUN bun pm cache rm
|
||||
RUN bun install
|
||||
# Copy package manifests for install caching. preinstall.js is included because
|
||||
# the root package.json's "preinstall" lifecycle script (node preinstall.js)
|
||||
# runs during `pnpm install` below -- before the full source is copied -- so the
|
||||
# script file must already be present or install fails with MODULE_NOT_FOUND.
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc preinstall.js ./
|
||||
COPY --parents ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./
|
||||
# Run the install before copying the rest of the files.
|
||||
# Keep --no-frozen-lockfile here (unlike CI): .dockerignore excludes
|
||||
# platform/docs, so the lockfile's docs importer has no manifest in the build
|
||||
# context and a frozen install would fail. pnpm reconciles (drops docs) instead.
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
# Copy the local directory
|
||||
COPY --link --exclude=yarn.lock --exclude=package.json --exclude=Dockerfile . .
|
||||
COPY --link --exclude=pnpm-lock.yaml --exclude=package.json --exclude=Dockerfile . .
|
||||
|
||||
# Build here
|
||||
# After install it should hopefully be stable until the local directory changes
|
||||
@ -71,14 +56,14 @@ ARG APP_CONFIG=config/default.js
|
||||
ARG PUBLIC_URL=/
|
||||
ENV PUBLIC_URL=${PUBLIC_URL}
|
||||
|
||||
RUN bun run show:config
|
||||
RUN bun run build
|
||||
RUN pnpm run show:config
|
||||
RUN pnpm run build
|
||||
|
||||
# Precompress files
|
||||
RUN chmod u+x .docker/compressDist.sh
|
||||
RUN ./.docker/compressDist.sh
|
||||
|
||||
# Stage 3: Bundle the built application into a Docker container
|
||||
# Stage 2: Bundle the built application into a Docker container
|
||||
# which runs Nginx using Alpine Linux
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine as final
|
||||
#RUN apk add --no-cache bash
|
||||
@ -98,7 +83,7 @@ COPY --from=builder /usr/src/app/platform/app/dist/dicom-microscopy-viewer /usr/
|
||||
# In entrypoint.sh, app-config.js might be overwritten, so chmod it to be writeable.
|
||||
# The nginx user cannot chmod it, so change to root.
|
||||
USER root
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && chmod -R 777 /usr/share/nginx/html
|
||||
USER nginx
|
||||
ENTRYPOINT ["/usr/src/entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
102
README.md
102
README.md
@ -49,11 +49,12 @@ provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF
|
||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-4d.webp?raw=true" alt="4D" width="350"/> | 4D | [Demo](https://viewer.ohif.org/dynamic-volume?StudyInstanceUIDs=2.25.232704420736447710317909004159492840763) |
|
||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-video.webp?raw=true" alt="VIDEO" width="350"/> | Video | [Demo](https://viewer.ohif.org/viewer?StudyInstanceUIDs=2.25.96975534054447904995905761963464388233) |
|
||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/microscopy.webp?raw=true" alt="microscopy" width="350"/> | Slide Microscopy | [Demo](https://viewer.ohif.org/microscopy?StudyInstanceUIDs=2.25.141277760791347900862109212450152067508) |
|
||||
| <img src="https://github.com/OHIF/Viewers/blob/master/platform/docs/docs/assets/img/demo-ecg.webp?raw=true" alt="ECG" width="350"/> | ECG Waveform | [Demo](https://viewer-dev.ohif.org/viewer?StudyInstanceUIDs=2.25.209974489360710696739324151261716440238) |
|
||||
|
||||
## About
|
||||
|
||||
The OHIF Viewer can retrieve
|
||||
and load images from most sources and formats; render sets in 2D, 3D, and
|
||||
and load images from most sources and formats, render sets in 2D, 3D, and
|
||||
reconstructed representations; allows for the manipulation, annotation, and
|
||||
serialization of observations; supports internationalization, OpenID Connect,
|
||||
offline use, hotkeys, and many more features.
|
||||
@ -76,7 +77,7 @@ contributions of individuals, research groups, and commercial organizations.
|
||||
After more than 8-years of integrating with many companies and organizations,
|
||||
The OHIF Viewer has been rebuilt from the ground up to better address the
|
||||
varying workflow and configuration needs of its many users. All of the Viewer's
|
||||
core features are built using it's own extension system. The same extensibility
|
||||
core features are built using its own extension system. The same extensibility
|
||||
that allows us to offer:
|
||||
|
||||
- 2D and 3D medical image viewing
|
||||
@ -104,7 +105,6 @@ For commercial support, academic collaborations, and answers to common
|
||||
questions; please use [Get Support](https://ohif.org/get-support/) to contact
|
||||
us.
|
||||
|
||||
|
||||
## Developing
|
||||
|
||||
### Branches
|
||||
@ -147,7 +147,14 @@ Here is a schematic representation of our development workflow:
|
||||
3. Navigate to the cloned project's directory
|
||||
4. Add this repo as a `remote` named `upstream`
|
||||
- `git remote add upstream https://github.com/OHIF/Viewers.git`
|
||||
5. `yarn install` to restore dependencies and link projects
|
||||
5. `yarn install --frozen-lockfile` to restore dependencies and link projects
|
||||
|
||||
:::danger
|
||||
In general run `yarn install` with the `--frozen-lockfile` flag to help avoid
|
||||
supply chain attacks by enforcing reproducible dependencies. That is, if the
|
||||
`yarn.lock` file is clean and does NOT reference compromised packages, then
|
||||
no compromised packages should land on your machine by using this flag.
|
||||
:::
|
||||
|
||||
#### To Develop
|
||||
|
||||
@ -158,9 +165,67 @@ _From this repository's root directory:_
|
||||
yarn config set workspaces-experimental true
|
||||
|
||||
# Restore dependencies
|
||||
yarn install
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
### Cornerstone3D Integration Testing
|
||||
|
||||
OHIF's Playwright end-to-end tests can run against a **CS3D branch** or a
|
||||
**published CS3D version**, allowing changes that span both repositories to be
|
||||
validated together before merging.
|
||||
|
||||
#### Setting up an integration build
|
||||
|
||||
1. Add the **`ohif-integration`** label to your OHIF pull request.
|
||||
2. In the PR body, add a line specifying the CS3D ref:
|
||||
```
|
||||
CS3D_REF: feat/my-feature
|
||||
```
|
||||
- **Version ref** (e.g. `4.19+`, `4.18.2`) — the workflow resolves it to an
|
||||
exact published version and swaps the CS3D dependency via npm.
|
||||
- **Branch ref** (e.g. `main`, `cornerstonejs:feat/foo`) — the workflow
|
||||
clones the branch, builds CS3D from source with `bun run build:esm`, and
|
||||
symlinks the built packages into OHIF's `node_modules`.
|
||||
- For forks, use the `<owner>:<branch>` format
|
||||
(e.g. `myGithubUser:feat/foo`).
|
||||
- If no `CS3D_REF` is specified, the default is `4.19+`.
|
||||
3. The workflow can also be triggered manually via **workflow_dispatch** with a
|
||||
`cs3d_ref` input.
|
||||
|
||||
#### What happens in CI
|
||||
|
||||
The [Playwright workflow](.github/workflows/playwright.yml) runs two jobs:
|
||||
|
||||
| Job | Purpose |
|
||||
|-----|---------|
|
||||
| **Playwright Tests** | Builds OHIF (with CS3D linked or version-swapped), runs the full Playwright suite, uploads test results and coverage, and deploys a Netlify preview when `ohif-integration` is active. |
|
||||
| **CS3D Branch Merge Guard** | A lightweight check that **fails** when the `ohif-integration` label is present and `CS3D_REF` points to a branch (not a version). This prevents merging while still letting the Playwright tests show green so you can see whether the code actually works. |
|
||||
|
||||
#### Testing changes that span both repos
|
||||
|
||||
If a feature requires changes in both Cornerstone3D and OHIF:
|
||||
|
||||
1. Create your feature branch in CS3D and push it.
|
||||
2. Create a matching branch in OHIF.
|
||||
3. Add the `ohif-integration` label to the OHIF pull request.
|
||||
4. In the PR body, add: `CS3D_REF: <your-cs3d-branch>`.
|
||||
5. Playwright tests will build CS3D from source, link it, and run the full
|
||||
suite. The merge guard will block merge until you switch to a published
|
||||
version — but you can see the test results and the preview deploy while
|
||||
iterating.
|
||||
6. Once the CS3D side is merged and published, update the PR body to reference
|
||||
the published version (e.g. `CS3D_REF: 4.19+`). The tests will run against
|
||||
the registry version and the merge guard will pass.
|
||||
|
||||
#### Preview deploys
|
||||
|
||||
When `ohif-integration` is active, the Playwright workflow also builds the OHIF
|
||||
viewer and deploys it to Netlify as a preview. This gives you a live URL to
|
||||
manually test the combined CS3D + OHIF changes without running anything locally.
|
||||
|
||||
For details on linking CS3D locally for development, see the
|
||||
[Cornerstone3D README](libs/@cornerstonejs/README.md#local-development-linking--unlinking).
|
||||
|
||||
## Commands
|
||||
|
||||
These commands are available from the root directory. Each project directory
|
||||
@ -179,6 +244,32 @@ also supports a number of commands that can be found in their respective
|
||||
\* - For more information on different builds, check out our [Deploy
|
||||
Docs][deployment-docs]
|
||||
|
||||
### Which config each command uses
|
||||
|
||||
The dev server and the production build select a different default
|
||||
[configuration file][config-file] when `APP_CONFIG` is not set explicitly:
|
||||
|
||||
| Command | Default config | Data sources | `?customization=` |
|
||||
| ------------------------------- | -------------------- | ------------ | ----------------- |
|
||||
| `dev`, `dev:fast`, `start` | `config/dev.js` | Full set | Enabled |
|
||||
| `build` | `config/default.js` | One demo source | Disabled |
|
||||
|
||||
- **`config/dev.js`** is the full-featured local-development config: every data
|
||||
source is enabled, the `?customization=` URL feature is turned on (via
|
||||
`customizationUrlPrefixes`), and it is kept at parity with the public demo
|
||||
(`config/netlify.js`) so customizations behave locally the same way they do on
|
||||
the demo.
|
||||
- **`config/netlify.js`** is the public demo / Netlify deploy config
|
||||
(`build:viewer:ci`), with the same full data-source set and `?customization=`
|
||||
enabled.
|
||||
- **`config/default.js`** is a locked-down baseline and is now **only** the
|
||||
default for a plain production build (`build` with no `APP_CONFIG`): a single
|
||||
read-only demo data source and `?customization=` off.
|
||||
|
||||
Any explicit `APP_CONFIG` overrides the default, e.g.
|
||||
`APP_CONFIG=config/default.js pnpm run dev` or
|
||||
`APP_CONFIG=config/netlify.js pnpm run build`.
|
||||
|
||||
## Project
|
||||
|
||||
The OHIF Medical Image Viewing Platform is maintained as a
|
||||
@ -306,6 +397,7 @@ MIT © [OHIF](https://github.com/OHIF)
|
||||
[ohif-architecture]: https://docs.ohif.org/architecture/index.html
|
||||
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
|
||||
[deployment-docs]: https://docs.ohif.org/deployment/
|
||||
[config-file]: https://docs.ohif.org/configuration/configurationFiles
|
||||
[react-url]: https://reactjs.org/
|
||||
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
|
||||
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
# External Dependencies
|
||||
|
||||
This module contains optional dependencies and external dependencies for including in OHIF, such as the DICOM Microscopy Viewer component.
|
||||
2630
addOns/externals/devDependencies/CHANGELOG.md
vendored
2630
addOns/externals/devDependencies/CHANGELOG.md
vendored
File diff suppressed because it is too large
Load Diff
95
addOns/externals/devDependencies/package.json
vendored
95
addOns/externals/devDependencies/package.json
vendored
@ -1,95 +0,0 @@
|
||||
{
|
||||
"name": "@externals/devDependencies",
|
||||
"description": "External dev dependencies - put dev build dependencies here",
|
||||
"version": "3.11.0-beta.99",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"yarn": ">=1.19.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@kitware/vtk.js": "32.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.2.1",
|
||||
"moment": "^2.30.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.28.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@rsbuild/core": "^1.3.16",
|
||||
"@rsbuild/plugin-node-polyfill": "^1.3.0",
|
||||
"@rsbuild/plugin-react": "^1.3.1",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/helpers": "^0.5.15",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
"@typescript-eslint/parser": "^6.3.0",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"babel-loader": "^8.2.4",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^9.0.1",
|
||||
"cross-env": "^5.2.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"dotenv": "^8.1.0",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-prettier": "7.2.0",
|
||||
"eslint-config-react-app": "^6.0.0",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-flowtype": "^7.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "5.5.1",
|
||||
"eslint-plugin-promise": "^5.2.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.11",
|
||||
"eslint-webpack-plugin": "^2.5.3",
|
||||
"execa": "^8.0.1",
|
||||
"extract-css-chunks-webpack-plugin": "^4.5.4",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"husky": "^3.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-canvas-mock": "^2.1.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"jest-junit": "^6.4.0",
|
||||
"lerna": "^7.2.0",
|
||||
"lint-staged": "^9.0.2",
|
||||
"mini-css-extract-plugin": "^2.1.0",
|
||||
"optimize-css-assets-webpack-plugin": "^6.0.1",
|
||||
"postcss": "^8.3.5",
|
||||
"postcss-import": "^14.0.2",
|
||||
"postcss-loader": "^6.1.1",
|
||||
"postcss-preset-env": "^7.4.3",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.9",
|
||||
"react-refresh": "^0.14.2",
|
||||
"semver": "^7.5.1",
|
||||
"serve": "^14.2.4",
|
||||
"shader-loader": "^1.3.1",
|
||||
"shx": "^0.3.3",
|
||||
"source-map-loader": "^4.0.1",
|
||||
"style-loader": "^1.0.0",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"typescript": "5.5.4",
|
||||
"unused-webpack-plugin": "2.4.0",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"webpack-dev-server": "5.2.2",
|
||||
"webpack-hot-middleware": "^2.25.0",
|
||||
"webpack-merge": "^5.7.3",
|
||||
"workbox-webpack-plugin": "^6.1.5",
|
||||
"worker-loader": "^3.0.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "Included as direct dependency"
|
||||
}
|
||||
}
|
||||
2603
addOns/externals/dicom-microscopy-viewer/CHANGELOG.md
vendored
2603
addOns/externals/dicom-microscopy-viewer/CHANGELOG.md
vendored
File diff suppressed because it is too large
Load Diff
@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@externals/dicom-microscopy-viewer",
|
||||
"description": "External reference to dicom-microscopy-viewer",
|
||||
"version": "3.11.0-beta.99",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dicom-microscopy-viewer": "^0.46.1"
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
{
|
||||
"name": "ohif-monorepo-root",
|
||||
"private": true,
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"../platform/i18n",
|
||||
"../platform/core",
|
||||
"../platform/ui",
|
||||
"../platform/ui-next",
|
||||
"../platform/app",
|
||||
"../extensions/*",
|
||||
"../modes/*",
|
||||
"../addOns/externals/*"
|
||||
],
|
||||
"nohoist": [
|
||||
"**/html-minifier-terser"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "cd .. && node preinstall.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-object-rest-spread": "^7.28.0",
|
||||
"@babel/plugin-transform-private-methods": "^7.27.1",
|
||||
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-arrow-functions": "^7.16.7",
|
||||
"@babel/plugin-transform-regenerator": "^7.16.7",
|
||||
"@babel/plugin-transform-runtime": "7.28.0",
|
||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||
"@babel/preset-env": "7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/@babel/runtime": "^7.20.13",
|
||||
"commander": "8.3.0",
|
||||
"dcmjs": "^0.42.0",
|
||||
"dicomweb-client": ">=0.10.4",
|
||||
"nth-check": "^2.1.1",
|
||||
"trim-newlines": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"trim": "^1.0.0",
|
||||
"package-json": "^8.1.0",
|
||||
"typescript": "5.5.4",
|
||||
"sharp": "^0.32.6"
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,9 @@ module.exports = {
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
plugins: [
|
||||
'babel-plugin-istanbul',
|
||||
// jest's babel coverage provider injects babel-plugin-istanbul when
|
||||
// --collectCoverage is set; adding it here too makes babel 7 (pulled in
|
||||
// by jest 30) throw "Duplicate plugin/preset detected".
|
||||
'@babel/plugin-transform-object-rest-spread',
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-transform-regenerator',
|
||||
@ -35,6 +37,7 @@ module.exports = {
|
||||
'@babel/plugin-transform-typescript',
|
||||
'@babel/plugin-transform-class-static-block',
|
||||
'@babel/plugin-transform-for-of',
|
||||
['babel-plugin-transform-import-meta', { module: 'ES6' }],
|
||||
],
|
||||
},
|
||||
production: {
|
||||
|
||||
@ -1 +1 @@
|
||||
bd3ff5f57c2fee35e92be4571048cc057eeaf29d
|
||||
6dd150d401ad73d60632a23378b7dfd4b5142690
|
||||
118
diff.txt
118
diff.txt
@ -1,118 +0,0 @@
|
||||
diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
|
||||
index 6c1e4f8f6..56dac15b5 100644
|
||||
--- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
|
||||
+++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
|
||||
@@ -156,65 +156,65 @@ function ViewportOrientationMenu({
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
|
||||
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
|
||||
onClick={() => handleOrientationChange('axial')}
|
||||
>
|
||||
- <span className="mr-0 flex w-4 items-center justify-center">
|
||||
+ <div className="mr-1 flex w-6 items-center justify-start">
|
||||
{currentOrientation === 'axial' ? (
|
||||
- <Icons.Checked className="text-primary h-5 w-5" />
|
||||
+ <Icons.Checked className="text-primary h-6 w-6" />
|
||||
) : null}
|
||||
- </span>
|
||||
- <span className="flex-1 text-left">Axial</span>
|
||||
+ </div>
|
||||
+ <div className="flex-1 text-left">Axial</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
|
||||
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
|
||||
onClick={() => handleOrientationChange('sagittal')}
|
||||
>
|
||||
- <span className="mr-0 flex w-4 items-center justify-center">
|
||||
+ <div className="mr-1 flex w-6 items-center justify-start">
|
||||
{currentOrientation === 'sagittal' ? (
|
||||
- <Icons.Checked className="text-primary h-5 w-5" />
|
||||
+ <Icons.Checked className="text-primary h-6 w-6" />
|
||||
) : null}
|
||||
- </span>
|
||||
- <span className="flex-1 text-left">Sagittal</span>
|
||||
+ </div>
|
||||
+ <div className="flex-1 text-left">Sagittal</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
|
||||
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
|
||||
onClick={() => handleOrientationChange('coronal')}
|
||||
>
|
||||
- <span className="mr-0 flex w-4 items-center justify-center">
|
||||
+ <div className="mr-1 flex w-6 items-center justify-start">
|
||||
{currentOrientation === 'coronal' ? (
|
||||
- <Icons.Checked className="text-primary h-5 w-5" />
|
||||
+ <Icons.Checked className="text-primary h-6 w-6" />
|
||||
) : null}
|
||||
- </span>
|
||||
- <span className="flex-1 text-left">Coronal</span>
|
||||
+ </div>
|
||||
+ <div className="flex-1 text-left">Coronal</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
|
||||
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
|
||||
onClick={() => handleOrientationChange('acquisition')}
|
||||
>
|
||||
- <span className="mr-0 flex w-4 items-center justify-center">
|
||||
+ <div className="mr-1 flex w-6 items-center justify-start">
|
||||
{currentOrientation === 'acquisition' ? (
|
||||
- <Icons.Checked className="text-primary h-5 w-5" />
|
||||
+ <Icons.Checked className="text-primary h-6 w-6" />
|
||||
) : null}
|
||||
- </span>
|
||||
- <span className="flex-1 text-left">Acquisition</span>
|
||||
+ </div>
|
||||
+ <div className="flex-1 text-left">Acquisition</div>
|
||||
</Button>
|
||||
{/* Divider */}
|
||||
- <div className="my-2 border-t border-white/20" />
|
||||
+ <div className="mx-1 my-2 border-t border-white/20" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
|
||||
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
|
||||
onClick={() => handleOrientationChange('reformat')}
|
||||
>
|
||||
- <span className="mr-0 flex w-4 items-center justify-center">
|
||||
+ <div className="mr-1 flex w-6 items-center justify-start">
|
||||
{currentOrientation === 'reformat' ? (
|
||||
- <Icons.Checked className="text-primary h-5 w-5" />
|
||||
+ <Icons.Checked className="text-primary h-6 w-6" />
|
||||
) : null}
|
||||
- </span>
|
||||
- <span className="flex-1 text-left">Reformat</span>
|
||||
+ </div>
|
||||
+ <div className="flex-1 text-left">Reformat</div>
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
diff --git a/platform/ui-next/src/components/Icons/Sources/Checked.tsx b/platform/ui-next/src/components/Icons/Sources/Checked.tsx
|
||||
index 83c8fc57e..c88b75e0d 100644
|
||||
--- a/platform/ui-next/src/components/Icons/Sources/Checked.tsx
|
||||
+++ b/platform/ui-next/src/components/Icons/Sources/Checked.tsx
|
||||
@@ -4,14 +4,14 @@ import type { IconProps } from '../types';
|
||||
export const Checked = (props: IconProps) => (
|
||||
<svg
|
||||
{...props}
|
||||
- width="24"
|
||||
+ width="20"
|
||||
height="24"
|
||||
- viewBox="0 0 24 24"
|
||||
+ viewBox="0 0 20 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
- d="M16 9L11.3333 15L8 12.4737"
|
||||
+ d="M14 9L9.3333 15L6 12.4737"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
@ -1,8 +1,8 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
@ -35,9 +35,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone-dicom-pmap',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone-dicom-pmap',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,54 +1,54 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-pmap",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM Parametric Map read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"public/**",
|
||||
"README.md"
|
||||
],
|
||||
"repository": "OHIF/Viewers",
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:dicom-pmap": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"dev:dicom-pmap": "pnpm run dev",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "pnpm run build",
|
||||
"start": "pnpm run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/extension-cornerstone": "3.11.0-beta.99",
|
||||
"@ohif/extension-default": "3.11.0-beta.99",
|
||||
"@ohif/i18n": "3.11.0-beta.99",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-router": "^6.8.1",
|
||||
"react-router-dom": "^6.8.1"
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/i18n": "workspace:*",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "12.3.1",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^3.30.3",
|
||||
"@cornerstonejs/core": "^3.30.3",
|
||||
"@kitware/vtk.js": "32.12.0",
|
||||
"react-color": "^2.19.3"
|
||||
}
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@kitware/vtk.js": "35.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
@ -32,9 +32,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: false,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone-dicom-rt',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone-dicom-rt',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-rt",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM RT read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-extension-cornerstone-dicom-rt.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"public/**",
|
||||
@ -14,38 +18,34 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": "OHIF/Viewers",
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:dicom-seg": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"dev:dicom-seg": "pnpm run dev",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "pnpm run build",
|
||||
"start": "pnpm run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/extension-cornerstone": "3.11.0-beta.99",
|
||||
"@ohif/extension-default": "3.11.0-beta.99",
|
||||
"@ohif/i18n": "3.11.0-beta.99",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^10.11.0",
|
||||
"react-router": "^6.23.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/i18n": "workspace:*",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "12.3.1",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"react-color": "^2.19.3"
|
||||
}
|
||||
"@babel/runtime": "7.29.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { utils, Types as OhifTypes } from '@ohif/core';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { segmentation as cstSegmentation } from '@cornerstonejs/tools';
|
||||
|
||||
import { SOPClassHandlerId } from './id';
|
||||
import loadRTStruct from './loadRTStruct';
|
||||
|
||||
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.481.3'];
|
||||
const { sopClassDictionary } = utils;
|
||||
|
||||
const sopClassUids = [sopClassDictionary.RTStructureSetStorage];
|
||||
|
||||
const loadPromises = {};
|
||||
|
||||
@ -13,19 +16,25 @@ function _getDisplaySetsFromSeries(
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
extensionManager
|
||||
) {
|
||||
const instance = instances[0];
|
||||
utils.sortStudyInstances(instances);
|
||||
// Choose the LAST instance in the list as the most recently created one.
|
||||
const instance = instances[instances.length - 1];
|
||||
|
||||
const {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
SeriesDescription,
|
||||
SeriesDescription = '',
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
SeriesTime,
|
||||
StructureSetDate,
|
||||
StructureSetTime,
|
||||
SOPClassUID,
|
||||
wadoRoot,
|
||||
wadoUri,
|
||||
wadoUriRoot,
|
||||
imageId: predecessorImageId,
|
||||
} = instance;
|
||||
|
||||
const displaySet = {
|
||||
@ -35,12 +44,19 @@ function _getDisplaySetsFromSeries(
|
||||
displaySetInstanceUID: utils.guid(),
|
||||
SeriesDescription,
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
/**
|
||||
* The "SeriesDate" for a display set is really the display set date, which
|
||||
* should be the date of the instance being used, which will be the structure
|
||||
* set date in this case.
|
||||
*/
|
||||
SeriesDate: StructureSetDate || SeriesDate,
|
||||
SeriesTime: StructureSetTime || SeriesTime,
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
SOPClassHandlerId,
|
||||
SOPClassUID,
|
||||
FrameOfReferenceUID: null,
|
||||
referencedImages: null,
|
||||
referencedSeriesInstanceUID: null,
|
||||
referencedDisplaySetInstanceUID: null,
|
||||
@ -50,6 +66,8 @@ function _getDisplaySetsFromSeries(
|
||||
structureSet: null,
|
||||
sopClassUids,
|
||||
instance,
|
||||
instances,
|
||||
predecessorImageId,
|
||||
wadoRoot,
|
||||
wadoUriRoot,
|
||||
wadoUri,
|
||||
@ -58,7 +76,10 @@ function _getDisplaySetsFromSeries(
|
||||
};
|
||||
|
||||
let referencedSeriesSequence = instance.ReferencedSeriesSequence;
|
||||
if (instance.ReferencedFrameOfReferenceSequence && !instance.ReferencedSeriesSequence) {
|
||||
if (
|
||||
instance.ReferencedFrameOfReferenceSequence?.RTReferencedStudySequence &&
|
||||
!instance.ReferencedSeriesSequence
|
||||
) {
|
||||
instance.ReferencedSeriesSequence = _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
|
||||
instance.ReferencedFrameOfReferenceSequence
|
||||
);
|
||||
@ -66,7 +87,8 @@ function _getDisplaySetsFromSeries(
|
||||
}
|
||||
|
||||
if (!referencedSeriesSequence) {
|
||||
throw new Error('ReferencedSeriesSequence is missing for the RTSTRUCT');
|
||||
console.error('ReferencedSeriesSequence is missing for the RTSTRUCT');
|
||||
return;
|
||||
}
|
||||
|
||||
const referencedSeries = referencedSeriesSequence[0];
|
||||
@ -74,10 +96,17 @@ function _getDisplaySetsFromSeries(
|
||||
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
|
||||
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
|
||||
|
||||
displaySet.FrameOfReferenceUID =
|
||||
instance.ReferencedFrameOfReferenceSequence?.[0]?.FrameOfReferenceUID;
|
||||
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
|
||||
displaySet.referencedSeriesInstanceUID
|
||||
);
|
||||
const referencedDisplaySets =
|
||||
displaySetService.getDisplaySetsForReferences(referencedSeriesSequence);
|
||||
if (referencedDisplaySets?.length > 1) {
|
||||
console.warn(
|
||||
'Reference applies to more than 1 display set for Contours, applying only to first display set'
|
||||
);
|
||||
}
|
||||
|
||||
if (!referencedDisplaySets || referencedDisplaySets.length === 0) {
|
||||
// Instead of throwing error, subscribe to display sets added
|
||||
@ -88,14 +117,16 @@ function _getDisplaySetsFromSeries(
|
||||
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
|
||||
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
|
||||
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
|
||||
displaySet.FrameOfReferenceUID = addedDisplaySet.FrameOfReferenceUID;
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const referencedDisplaySet = referencedDisplaySets[0];
|
||||
const [referencedDisplaySet] = referencedDisplaySets;
|
||||
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
|
||||
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
|
||||
displaySet.FrameOfReferenceUID = referencedDisplaySet.FrameOfReferenceUID;
|
||||
}
|
||||
|
||||
displaySet.load = ({ headers, createSegmentation = true }) =>
|
||||
@ -113,10 +144,11 @@ function _load(
|
||||
) {
|
||||
const { SOPInstanceUID } = rtDisplaySet;
|
||||
const { segmentationService } = servicesManager.services;
|
||||
|
||||
if (
|
||||
(rtDisplaySet.loading || rtDisplaySet.isLoaded) &&
|
||||
loadPromises[SOPInstanceUID] &&
|
||||
_segmentationExistsInCache(rtDisplaySet, segmentationService)
|
||||
_segmentationExists(rtDisplaySet)
|
||||
) {
|
||||
return loadPromises[SOPInstanceUID];
|
||||
}
|
||||
@ -125,27 +157,22 @@ function _load(
|
||||
|
||||
// We don't want to fire multiple loads, so we'll wait for the first to finish
|
||||
// and also return the same promise to any other callers.
|
||||
loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => {
|
||||
if (!rtDisplaySet.structureSet) {
|
||||
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
|
||||
loadPromises[SOPInstanceUID] = new Promise<void>(async (resolve, reject) => {
|
||||
try {
|
||||
if (!rtDisplaySet.structureSet) {
|
||||
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
|
||||
rtDisplaySet.structureSet = structureSet;
|
||||
}
|
||||
|
||||
rtDisplaySet.structureSet = structureSet;
|
||||
}
|
||||
if (createSegmentation) {
|
||||
await segmentationService.createSegmentationForRTDisplaySet(rtDisplaySet);
|
||||
}
|
||||
|
||||
if (createSegmentation) {
|
||||
segmentationService
|
||||
.createSegmentationForRTDisplaySet(rtDisplaySet)
|
||||
.then(() => {
|
||||
rtDisplaySet.loading = false;
|
||||
resolve();
|
||||
})
|
||||
.catch(error => {
|
||||
rtDisplaySet.loading = false;
|
||||
reject(error);
|
||||
});
|
||||
} else {
|
||||
rtDisplaySet.loading = false;
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
rtDisplaySet.loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
@ -187,9 +214,8 @@ function _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
|
||||
return ReferencedSeriesSequence;
|
||||
}
|
||||
|
||||
function _segmentationExistsInCache() {
|
||||
// Todo: fix this
|
||||
return false;
|
||||
function _segmentationExists(segDisplaySet) {
|
||||
return !!cstSegmentation.state.getSegmentation(segDisplaySet.displaySetInstanceUID);
|
||||
}
|
||||
|
||||
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
|
||||
|
||||
@ -2,9 +2,34 @@ import dcmjs from 'dcmjs';
|
||||
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
|
||||
const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB;
|
||||
|
||||
async function checkAndLoadContourData(instance, datasource) {
|
||||
/**
|
||||
* Checks and loads contour data for RT Structure Set, handling both inline and bulk data URIs.
|
||||
* Processes ROIContourSequence to extract contour data and resolve any bulk data references.
|
||||
*
|
||||
* @async
|
||||
* @function checkAndLoadContourData
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} params.instance - Initial RT Structure instance
|
||||
* @param {Object} params.dataSource - Data source for retrieving bulk data
|
||||
* @param {Object} params.extensionManager - OHIF extension manager
|
||||
* @param {Object} params.rtStructDisplaySet - RT Structure display set
|
||||
* @param {Object} params.headers - HTTP headers for requests
|
||||
* @returns {Promise<Object>} Promise that resolves to the processed RT Structure instance with loaded contour data
|
||||
* @throws {Promise<string>} Rejects with error message if instance is invalid or data retrieval fails
|
||||
*/
|
||||
async function checkAndLoadContourData({
|
||||
instance: initialInstance,
|
||||
dataSource,
|
||||
extensionManager,
|
||||
rtStructDisplaySet,
|
||||
headers,
|
||||
}) {
|
||||
let instance = initialInstance;
|
||||
if (!instance || !instance.ROIContourSequence) {
|
||||
return Promise.reject('Invalid instance object or ROIContourSequence');
|
||||
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
|
||||
if (!instance || !instance.ROIContourSequence) {
|
||||
return Promise.reject('Invalid instance object or ROIContourSequence');
|
||||
}
|
||||
}
|
||||
|
||||
const promisesMap = new Map();
|
||||
@ -30,11 +55,11 @@ async function checkAndLoadContourData(instance, datasource) {
|
||||
} else if (contourData && contourData.BulkDataURI) {
|
||||
const bulkDataURI = contourData.BulkDataURI;
|
||||
|
||||
if (!datasource || !datasource.retrieve || !datasource.retrieve.bulkDataURI) {
|
||||
return Promise.reject('Invalid datasource object or retrieve function');
|
||||
if (!dataSource || !dataSource.retrieve || !dataSource.retrieve.bulkDataURI) {
|
||||
return Promise.reject('Invalid dataSource object or retrieve function');
|
||||
}
|
||||
|
||||
const bulkDataPromise = datasource.retrieve.bulkDataURI({
|
||||
const bulkDataPromise = dataSource.retrieve.bulkDataURI({
|
||||
BulkDataURI: bulkDataURI,
|
||||
StudyInstanceUID: instance.StudyInstanceUID,
|
||||
SeriesInstanceUID: instance.SeriesInstanceUID,
|
||||
@ -44,6 +69,40 @@ async function checkAndLoadContourData(instance, datasource) {
|
||||
promisesMap.has(referencedROINumber)
|
||||
? promisesMap.get(referencedROINumber).push(bulkDataPromise)
|
||||
: promisesMap.set(referencedROINumber, [bulkDataPromise]);
|
||||
} else if (contourData && contourData.InlineBinary) {
|
||||
// Contour data is still in binary format, conversion needed
|
||||
const base64String = contourData.InlineBinary;
|
||||
const decodedText = atob(base64String);
|
||||
|
||||
const rawValues = decodedText.split('\\');
|
||||
|
||||
const result = [];
|
||||
|
||||
// Ensure strictly that we have a full set of 3 coordinates
|
||||
if (rawValues.length % 3 !== 0) {
|
||||
return Promise.reject('ContourData raw values not divisible by 3');
|
||||
}
|
||||
|
||||
for (let i = 0; i < rawValues.length; i += 3) {
|
||||
if (i + 2 < rawValues.length) {
|
||||
const x = parseFloat(rawValues[i]);
|
||||
const y = parseFloat(rawValues[i + 1]);
|
||||
const z = parseFloat(rawValues[i + 2]);
|
||||
|
||||
// Only push if all three are valid numbers (filters out trailing empty splits)
|
||||
if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {
|
||||
result.push(x);
|
||||
result.push(y);
|
||||
result.push(z);
|
||||
} else {
|
||||
return Promise.reject('Error parsing contourData from InlineBinary format');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
promisesMap.has(referencedROINumber)
|
||||
? promisesMap.get(referencedROINumber).push(result)
|
||||
: promisesMap.set(referencedROINumber, [result]);
|
||||
} else {
|
||||
return Promise.reject(`Invalid ContourData: ${contourData}`);
|
||||
}
|
||||
@ -64,9 +123,12 @@ async function checkAndLoadContourData(instance, datasource) {
|
||||
ROIContour.ContourSequence.forEach((Contour, index) => {
|
||||
const promise = resolvedPromises[index];
|
||||
if (promise.status === 'fulfilled') {
|
||||
if (Array.isArray(promise.value) && promise.value.every(Number.isFinite)) {
|
||||
if (
|
||||
Array.isArray(promise.value) &&
|
||||
promise.value.every(it => Number.isFinite(Number(it)))
|
||||
) {
|
||||
// If promise.value is already an array of numbers, use it directly
|
||||
Contour.ContourData = promise.value;
|
||||
Contour.ContourData = promise.value.map(Number);
|
||||
} else {
|
||||
// If the resolved promise value is a byte array (Blob), it needs to be decoded
|
||||
const uint8Array = new Uint8Array(promise.value);
|
||||
@ -87,35 +149,74 @@ async function checkAndLoadContourData(instance, datasource) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
|
||||
/**
|
||||
* Retrieves and parses RT Structure Set instance from DICOM data.
|
||||
* Uses the cornerstone utility module to load DICOM data and converts it to a naturalized dataset.
|
||||
*
|
||||
* @async
|
||||
* @function getRTStructInstance
|
||||
* @param {Object} params - Parameters object
|
||||
* @param {Object} params.extensionManager - OHIF extension manager
|
||||
* @param {Object} params.rtStructDisplaySet - RT Structure display set
|
||||
* @param {Object} params.headers - HTTP headers for requests
|
||||
* @returns {Promise<Object>} Promise that resolves to the parsed RT Structure dataset
|
||||
*/
|
||||
const getRTStructInstance = async ({ extensionManager, rtStructDisplaySet, headers }) => {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
const { dicomLoaderService } = utilityModule.exports;
|
||||
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
|
||||
rtStructDisplaySet,
|
||||
null,
|
||||
headers
|
||||
);
|
||||
const dicomData = DicomMessage.readFile(segArrayBuffer);
|
||||
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
|
||||
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
|
||||
return rtStructDataset;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function to load and process RT Structure Set data.
|
||||
* Creates a structure set object with ROI contours, metadata, and visualization properties.
|
||||
* Handles both bulk data URI and inline contour data scenarios.
|
||||
*
|
||||
* @async
|
||||
* @function loadRTStruct
|
||||
* @param {Object} extensionManager - OHIF extension manager
|
||||
* @param {Object} rtStructDisplaySet - RT Structure display set to process
|
||||
* @param {Object} headers - HTTP headers for data requests
|
||||
* @returns {Promise<Object>} Promise that resolves to a structure set object containing:
|
||||
* - StructureSetLabel: Label of the structure set
|
||||
* - SeriesInstanceUID: Series instance UID
|
||||
* - ROIContours: Array of ROI contour data with points and metadata
|
||||
* - visible: Visibility state
|
||||
* - ReferencedSOPInstanceUIDsSet: Set of referenced SOP instance UIDs
|
||||
*/
|
||||
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
const { bulkDataURI } = dataSource.getConfig?.() || {};
|
||||
|
||||
const { dicomLoaderService } = utilityModule.exports;
|
||||
|
||||
// Set here is loading is asynchronous.
|
||||
// If this function throws its set back to false.
|
||||
rtStructDisplaySet.isLoaded = true;
|
||||
let instance = rtStructDisplaySet.instance;
|
||||
|
||||
if (!bulkDataURI || !bulkDataURI.enabled) {
|
||||
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
|
||||
rtStructDisplaySet,
|
||||
null,
|
||||
headers
|
||||
);
|
||||
|
||||
const dicomData = DicomMessage.readFile(segArrayBuffer);
|
||||
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
|
||||
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
|
||||
instance = rtStructDataset;
|
||||
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
|
||||
} else {
|
||||
await checkAndLoadContourData(instance, dataSource);
|
||||
instance = await checkAndLoadContourData({
|
||||
instance,
|
||||
dataSource,
|
||||
extensionManager,
|
||||
rtStructDisplaySet,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const { StructureSetROISequence, ROIContourSequence, RTROIObservationsSequence } = instance;
|
||||
@ -132,21 +233,16 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
|
||||
for (let i = 0; i < ROIContourSequence.length; i++) {
|
||||
const ROIContour = ROIContourSequence[i];
|
||||
const { ContourSequence } = ROIContour;
|
||||
|
||||
if (!ContourSequence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSupported = false;
|
||||
|
||||
const ContourSequenceArray = _toArray(ContourSequence);
|
||||
|
||||
const contourPoints = [];
|
||||
for (let c = 0; c < ContourSequenceArray.length; c++) {
|
||||
for (const ContourSequenceItem of ContourSequenceArray) {
|
||||
const { ContourData, NumberOfContourPoints, ContourGeometricType, ContourImageSequence } =
|
||||
ContourSequenceArray[c];
|
||||
|
||||
let isSupported = false;
|
||||
ContourSequenceItem;
|
||||
|
||||
const points = [];
|
||||
for (let p = 0; p < NumberOfContourPoints * 3; p += 3) {
|
||||
@ -157,22 +253,18 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
|
||||
});
|
||||
}
|
||||
|
||||
switch (ContourGeometricType) {
|
||||
case 'CLOSED_PLANAR':
|
||||
case 'OPEN_PLANAR':
|
||||
case 'POINT':
|
||||
isSupported = true;
|
||||
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
const supportedContourTypesMap = new Map([
|
||||
['CLOSED_PLANAR', false],
|
||||
['OPEN_NONPLANAR', false],
|
||||
['OPEN_PLANAR', false],
|
||||
['POINT', true],
|
||||
]);
|
||||
|
||||
contourPoints.push({
|
||||
numberOfPoints: NumberOfContourPoints,
|
||||
points,
|
||||
type: ContourGeometricType,
|
||||
isSupported,
|
||||
isSupported: supportedContourTypesMap.get(ContourGeometricType) ?? false,
|
||||
});
|
||||
|
||||
if (ContourImageSequence?.ReferencedSOPInstanceUID) {
|
||||
@ -187,20 +279,30 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
|
||||
StructureSetROISequence,
|
||||
RTROIObservationsSequence,
|
||||
ROIContour,
|
||||
contourPoints,
|
||||
isSupported
|
||||
contourPoints
|
||||
);
|
||||
}
|
||||
return structureSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets metadata for ROI contour data and adds it to the structure set.
|
||||
* Extracts ROI information from StructureSetROISequence and RTROIObservationsSequence,
|
||||
* then creates a complete ROI contour data object with visualization properties.
|
||||
*
|
||||
* @function _setROIContourMetadata
|
||||
* @param {Object} structureSet - The structure set object to add ROI contour to
|
||||
* @param {Array} StructureSetROISequence - Array of structure set ROI definitions
|
||||
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
|
||||
* @param {Object} ROIContour - ROI contour object containing contour data
|
||||
* @param {Array} contourPoints - Array of processed contour points
|
||||
*/
|
||||
function _setROIContourMetadata(
|
||||
structureSet,
|
||||
StructureSetROISequence,
|
||||
RTROIObservationsSequence,
|
||||
ROIContour,
|
||||
contourPoints,
|
||||
isSupported
|
||||
contourPoints
|
||||
) {
|
||||
const StructureSetROI = StructureSetROISequence.find(
|
||||
structureSetROI => structureSetROI.ROINumber === ROIContour.ReferencedROINumber
|
||||
@ -211,9 +313,9 @@ function _setROIContourMetadata(
|
||||
ROIName: StructureSetROI.ROIName,
|
||||
ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm,
|
||||
ROIDescription: StructureSetROI.ROIDescription,
|
||||
isSupported,
|
||||
contourPoints,
|
||||
visible: true,
|
||||
colorArray: [],
|
||||
};
|
||||
|
||||
_setROIContourDataColor(ROIContour, ROIContourData);
|
||||
@ -230,6 +332,14 @@ function _setROIContourMetadata(
|
||||
structureSet.ROIContours.push(ROIContourData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the display color for ROI contour data.
|
||||
* Uses ROIDisplayColor if available, otherwise converts RecommendedDisplayCIELabValue to RGB.
|
||||
*
|
||||
* @function _setROIContourDataColor
|
||||
* @param {Object} ROIContour - ROI contour object containing color information
|
||||
* @param {Object} ROIContourData - ROI contour data object to set color on
|
||||
*/
|
||||
function _setROIContourDataColor(ROIContour, ROIContourData) {
|
||||
let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour;
|
||||
|
||||
@ -243,6 +353,15 @@ function _setROIContourDataColor(ROIContour, ROIContourData) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets RT ROI observations metadata for ROI contour data.
|
||||
* Finds matching RTROIObservations by ROINumber and adds observation details to contour data.
|
||||
*
|
||||
* @function _setROIContourRTROIObservations
|
||||
* @param {Object} ROIContourData - ROI contour data object to add observations to
|
||||
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
|
||||
* @param {number} ROINumber - ROI number to match observations
|
||||
*/
|
||||
function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequence, ROINumber) {
|
||||
const RTROIObservations = RTROIObservationsSequence.find(
|
||||
RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber
|
||||
@ -262,6 +381,14 @@ function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequen
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a single object or array to an array.
|
||||
* Utility function to ensure consistent array handling for DICOM sequences.
|
||||
*
|
||||
* @function _toArray
|
||||
* @param {*} objOrArray - Object or array to convert
|
||||
* @returns {Array} Array containing the input (if already array) or wrapped in array
|
||||
*/
|
||||
function _toArray(objOrArray) {
|
||||
return Array.isArray(objOrArray) ? objOrArray : [objOrArray];
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
|
||||
const { unsubscribe } = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
|
||||
evt => {
|
||||
if (evt.rtDisplaySet.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
|
||||
if (evt.rtDisplaySet?.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
|
||||
setRtIsLoading(false);
|
||||
}
|
||||
|
||||
@ -168,7 +168,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
|
||||
|
||||
return () => {
|
||||
// remove the segmentation representations if seg displayset changed
|
||||
segmentationService.removeSegmentationRepresentations(viewportId);
|
||||
segmentationService.removeRepresentationsFromViewport(viewportId);
|
||||
referencedDisplaySetRef.current = null;
|
||||
toolGroupService.destroyToolGroup(toolGroupId);
|
||||
};
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
@ -35,9 +35,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone-dicom-seg',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone-dicom-seg',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,54 +1,54 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-seg",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM SEG read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-extension-cornerstone-dicom-seg.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"public/**",
|
||||
"README.md"
|
||||
],
|
||||
"repository": "OHIF/Viewers",
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:dicom-seg": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"dev:dicom-seg": "pnpm run dev",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "pnpm run build",
|
||||
"start": "pnpm run dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/extension-cornerstone": "3.11.0-beta.99",
|
||||
"@ohif/extension-default": "3.11.0-beta.99",
|
||||
"@ohif/i18n": "3.11.0-beta.99",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-router": "^6.23.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/i18n": "workspace:*",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "12.3.1",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^3.30.3",
|
||||
"@cornerstonejs/core": "^3.30.3",
|
||||
"@kitware/vtk.js": "32.12.0",
|
||||
"react-color": "^2.19.3"
|
||||
}
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@kitware/vtk.js": "35.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
import { classes, Types } from '@ohif/core';
|
||||
import { classes, Types, utils } from '@ohif/core';
|
||||
import { cache, metaData } from '@cornerstonejs/core';
|
||||
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
|
||||
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
|
||||
import { createReportDialogPrompt } from '@ohif/extension-default';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
|
||||
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
||||
|
||||
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
||||
|
||||
const { datasetToBlob } = dcmjs.data;
|
||||
import {
|
||||
getSegmentationSaveOptions,
|
||||
LABELMAP_SEG_SOP_CLASS_UID,
|
||||
BITMAP_SEG_SOP_CLASS_UID,
|
||||
} from './utils/segmentationConfig';
|
||||
|
||||
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
||||
const { viewports, activeViewportId } = viewportGridService.getState();
|
||||
@ -27,17 +29,16 @@ const {
|
||||
|
||||
const {
|
||||
Cornerstone3D: {
|
||||
RTSS: { generateRTSSFromSegmentations },
|
||||
RTSS: { generateRTSSFromRepresentation },
|
||||
},
|
||||
} = adaptersRT;
|
||||
|
||||
const { downloadDICOMData } = helpers;
|
||||
|
||||
const commandsModule = ({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
||||
const { segmentationService, displaySetService, viewportGridService, toolGroupService } =
|
||||
const { segmentationService, displaySetService, viewportGridService, customizationService } =
|
||||
servicesManager.services as AppTypes.Services;
|
||||
|
||||
const actions = {
|
||||
@ -91,48 +92,85 @@ const commandsModule = ({
|
||||
* @returns Returns the generated segmentation data.
|
||||
*/
|
||||
generateSegmentation: ({ segmentationId, options = {} }) => {
|
||||
// `dataSource` (a data source name) is consumed here to resolve the store
|
||||
// overrides; it must not be forwarded to the adapter's generateSegmentation.
|
||||
const { dataSource: dataSourceName, ...generateOptions } = options;
|
||||
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
|
||||
const predecessorImageId =
|
||||
generateOptions.predecessorImageId ?? segmentation.predecessorImageId;
|
||||
|
||||
const { imageIds } = segmentation.representationData.Labelmap;
|
||||
// A data source may override the app-wide `segmentation.store.*` defaults
|
||||
// via `configuration.segmentation.store` (different back ends support
|
||||
// different SEG encodings). Use the named target data source when storing,
|
||||
// otherwise the active one (e.g. download).
|
||||
const dataSourceDefinition = dataSourceName
|
||||
? extensionManager.getDataSourceDefinition(dataSourceName)
|
||||
: extensionManager.getActiveDataSourceDefinition();
|
||||
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
|
||||
|
||||
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
||||
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
|
||||
const labelmapData = segmentation.representationData.Labelmap;
|
||||
|
||||
const labelmaps2D = [];
|
||||
// Build a labelmap3D (one labelmaps2D entry per source slice) from a list of
|
||||
// derived labelmap image ids. When `referencedImageIds` is supplied (the
|
||||
// multi-layer/overlap path) each frame is indexed by its source slice so the
|
||||
// layers align to the same frames; otherwise frames are sequential (the legacy
|
||||
// single-layer behavior, kept byte-identical).
|
||||
const buildLabelmap3D = (segImageIds: string[], metadata, referencedImageIds?: string[]) => {
|
||||
const segImages = segImageIds.map(imageId => cache.getImage(imageId));
|
||||
const labelmaps2D = [];
|
||||
|
||||
let z = 0;
|
||||
// Map each source imageId to its frame index once (O(n)) so the per-slice lookup
|
||||
// below is O(1) — avoids the O(slices^2) indexOf scan on the multi-layer path.
|
||||
const referencedFrameIndexById = referencedImageIds
|
||||
? new Map(referencedImageIds.map((imageId, index) => [imageId, index]))
|
||||
: undefined;
|
||||
|
||||
for (const segImage of segImages) {
|
||||
const segmentsOnLabelmap = new Set();
|
||||
const pixelData = segImage.getPixelData();
|
||||
const { rows, columns } = segImage;
|
||||
let z = 0;
|
||||
|
||||
// Use a single pass through the pixel data
|
||||
for (let i = 0; i < pixelData.length; i++) {
|
||||
const segment = pixelData[i];
|
||||
if (segment !== 0) {
|
||||
segmentsOnLabelmap.add(segment);
|
||||
for (const segImage of segImages) {
|
||||
const segmentsOnLabelmap = new Set();
|
||||
const pixelData = segImage.getPixelData();
|
||||
const { rows, columns } = segImage;
|
||||
|
||||
// Use a single pass through the pixel data
|
||||
for (let i = 0; i < pixelData.length; i++) {
|
||||
const segment = pixelData[i];
|
||||
if (segment !== 0) {
|
||||
segmentsOnLabelmap.add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
const frameIndex = referencedFrameIndexById
|
||||
? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1
|
||||
: z++;
|
||||
|
||||
if (frameIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
labelmaps2D[frameIndex] = {
|
||||
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
||||
pixelData,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
labelmaps2D[z++] = {
|
||||
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
||||
pixelData,
|
||||
rows,
|
||||
columns,
|
||||
const allSegmentsOnLabelmap = labelmaps2D
|
||||
.filter(Boolean)
|
||||
.map(labelmap => labelmap.segmentsOnLabelmap);
|
||||
|
||||
return {
|
||||
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
||||
metadata,
|
||||
labelmaps2D,
|
||||
};
|
||||
}
|
||||
|
||||
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
|
||||
|
||||
const labelmap3D = {
|
||||
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
||||
metadata: [],
|
||||
labelmaps2D,
|
||||
};
|
||||
|
||||
// Segment metadata (shared across all layers).
|
||||
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
||||
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
|
||||
const metadata = [];
|
||||
|
||||
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
|
||||
// segmentation service already has a color for each segment
|
||||
@ -153,7 +191,7 @@ const commandsModule = ({
|
||||
color.slice(0, 3).map(value => value / 255)
|
||||
).map(value => Math.round(value));
|
||||
|
||||
const segmentMetadata = {
|
||||
metadata[segmentIndex] = {
|
||||
SegmentNumber: segmentIndex.toString(),
|
||||
SegmentLabel: label,
|
||||
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
|
||||
@ -170,14 +208,75 @@ const commandsModule = ({
|
||||
CodeMeaning: 'Tissue',
|
||||
},
|
||||
};
|
||||
labelmap3D.metadata[segmentIndex] = segmentMetadata;
|
||||
});
|
||||
|
||||
// Multi-layer (overlapping) SEGs register one labelmap layer per conflict-free
|
||||
// group. Export each layer as its own labelmap3D against the UNIQUE referenced
|
||||
// source series, so cornerstone writes overlapping segments as separate frames
|
||||
// that reference the same source slice (the DICOM SEG overlap encoding). The
|
||||
// cs3D adapter's fillSegmentation accepts an array of labelmap3D for exactly
|
||||
// this. Single-layer SEGs keep the original single-labelmap3D path unchanged.
|
||||
const layers = labelmapData.labelmaps ? Object.values(labelmapData.labelmaps) : undefined;
|
||||
|
||||
// The referenced source images must be fully loaded (in cache) before we can
|
||||
// build the SEG dataset against them; fail loudly rather than passing undefined
|
||||
// frames to the adapter.
|
||||
const resolveReferencedImage = (referencedImageId: string, sliceIndex: number) => {
|
||||
const referencedImage = cache.getImage(referencedImageId);
|
||||
if (!referencedImage) {
|
||||
throw new Error(
|
||||
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
|
||||
`(referencedImageId: ${referencedImageId}). Ensure the referenced series is fully loaded before storing.`
|
||||
);
|
||||
}
|
||||
return referencedImage;
|
||||
};
|
||||
|
||||
let referencedImages;
|
||||
let labelmaps3D;
|
||||
|
||||
if (layers && layers.length > 1) {
|
||||
const referencedImageIds =
|
||||
layers[0].referencedImageIds ?? labelmapData.referencedImageIds ?? [];
|
||||
referencedImages = referencedImageIds.map(resolveReferencedImage);
|
||||
labelmaps3D = layers.map(layer =>
|
||||
buildLabelmap3D(layer.imageIds ?? [], metadata, referencedImageIds)
|
||||
);
|
||||
} else {
|
||||
const { imageIds } = labelmapData;
|
||||
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
||||
referencedImages = segImages.map((image, sliceIndex) =>
|
||||
resolveReferencedImage(image.referencedImageId, sliceIndex)
|
||||
);
|
||||
labelmaps3D = buildLabelmap3D(imageIds, metadata);
|
||||
}
|
||||
|
||||
const saveOptions = {
|
||||
predecessorImageId,
|
||||
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
|
||||
...generateOptions,
|
||||
};
|
||||
|
||||
// A LABELMAP SEG frame stores a single label per voxel, so the labelmap
|
||||
// encoder cannot represent overlapping segments — it keeps only the last
|
||||
// layer written to each voxel. Overlapping segmentations arrive here as
|
||||
// multiple layers, so switch those to the binary SEG encoding, which
|
||||
// writes overlapping segments as separate frames referencing the same
|
||||
// source slice.
|
||||
const hasOverlappingLayers = Boolean(layers && layers.length > 1);
|
||||
if (hasOverlappingLayers && saveOptions.sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||
console.warn(
|
||||
'generateSegmentation: overlapping segments cannot be stored as a LABELMAP SEG; ' +
|
||||
'switching to the binary SEG encoding for this store.'
|
||||
);
|
||||
saveOptions.sopClassUID = BITMAP_SEG_SOP_CLASS_UID;
|
||||
}
|
||||
|
||||
const generatedSegmentation = generateSegmentation(
|
||||
referencedImages,
|
||||
labelmap3D,
|
||||
labelmaps3D,
|
||||
metaData,
|
||||
options
|
||||
saveOptions
|
||||
);
|
||||
|
||||
return generatedSegmentation;
|
||||
@ -197,8 +296,11 @@ const commandsModule = ({
|
||||
const generatedSegmentation = actions.generateSegmentation({
|
||||
segmentationId,
|
||||
});
|
||||
|
||||
downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`);
|
||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||
dataSource: 'download',
|
||||
defaultFileName: `${segmentationInOHIF.label}.dcm`,
|
||||
});
|
||||
storeFn(generatedSegmentation.dataset);
|
||||
},
|
||||
/**
|
||||
* Stores a segmentation based on the provided segmentationId into a specified data source.
|
||||
@ -212,72 +314,86 @@ const commandsModule = ({
|
||||
* @returns {Object|void} Returns the naturalized report if successfully stored,
|
||||
* otherwise throws an error.
|
||||
*/
|
||||
storeSegmentation: async ({ segmentationId, dataSource }) => {
|
||||
storeSegmentation: async ({ segmentationId, dataSource, modality = 'SEG' }) => {
|
||||
const segmentation = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
if (!segmentation) {
|
||||
throw new Error('No segmentation found');
|
||||
}
|
||||
|
||||
const { label } = segmentation;
|
||||
const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource()[0];
|
||||
const { label, predecessorImageId } = segmentation;
|
||||
|
||||
const {
|
||||
value: reportName,
|
||||
dataSourceName: selectedDataSource,
|
||||
dataSourceName,
|
||||
series,
|
||||
priorSeriesNumber,
|
||||
action,
|
||||
} = await createReportDialogPrompt({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
predecessorImageId,
|
||||
title: 'Store Segmentation',
|
||||
modality,
|
||||
enableDownload: true,
|
||||
});
|
||||
|
||||
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
|
||||
try {
|
||||
const selectedDataSourceConfig = selectedDataSource
|
||||
? extensionManager.getDataSources(selectedDataSource)[0]
|
||||
: defaultDataSource;
|
||||
if (action !== PROMPT_RESPONSES.CREATE_REPORT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedData = actions.generateSegmentation({
|
||||
segmentationId,
|
||||
options: {
|
||||
SeriesDescription: reportName || label || 'Research Derived Series',
|
||||
},
|
||||
});
|
||||
const defaultFileName =
|
||||
modality === 'RTSTRUCT' ? `rtss-${segmentationId}.dcm` : `${label || 'segmentation'}.dcm`;
|
||||
|
||||
if (!generatedData || !generatedData.dataset) {
|
||||
throw new Error('Error during segmentation generation');
|
||||
}
|
||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||
dataSource: dataSourceName,
|
||||
defaultFileName,
|
||||
});
|
||||
|
||||
const { dataset: naturalizedReport } = generatedData;
|
||||
if (!storeFn) {
|
||||
throw new Error(`No valid store for dataSource: ${dataSourceName}`);
|
||||
}
|
||||
|
||||
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
|
||||
if (naturalizedReport.StudyID === 'No Study ID') {
|
||||
naturalizedReport.StudyID = '';
|
||||
}
|
||||
try {
|
||||
const args = {
|
||||
segmentationId,
|
||||
options: {
|
||||
// Resolve store overrides against the data source we are storing into.
|
||||
dataSource: dataSourceName,
|
||||
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
||||
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
||||
predecessorImageId: series,
|
||||
},
|
||||
};
|
||||
const generatedDataAsync =
|
||||
(modality === 'SEG' && actions.generateSegmentation(args)) ||
|
||||
(modality === 'RTSTRUCT' && actions.generateContour(args));
|
||||
const generatedData = await generatedDataAsync;
|
||||
|
||||
await selectedDataSourceConfig.store.dicom(naturalizedReport);
|
||||
|
||||
// add the information for where we stored it to the instance as well
|
||||
naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot;
|
||||
|
||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
||||
|
||||
return naturalizedReport;
|
||||
} catch (error) {
|
||||
console.debug('Error storing segmentation:', error);
|
||||
throw error;
|
||||
if (!generatedData?.dataset) {
|
||||
throw new Error('Error during segmentation generation');
|
||||
}
|
||||
|
||||
const { dataset: naturalizedReport } = generatedData;
|
||||
|
||||
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
|
||||
if (naturalizedReport.StudyID === 'No Study ID') {
|
||||
naturalizedReport.StudyID = '';
|
||||
}
|
||||
|
||||
await storeFn(naturalizedReport, {});
|
||||
|
||||
return naturalizedReport;
|
||||
} catch (error) {
|
||||
console.debug('Error storing segmentation:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Converts segmentations into RTSS for download.
|
||||
* This sample function retrieves all segentations and passes to
|
||||
* cornerstone tool adapter to convert to DICOM RTSS format. It then
|
||||
* converts dataset to downloadable blob.
|
||||
*
|
||||
*/
|
||||
downloadRTSS: async ({ segmentationId }) => {
|
||||
|
||||
generateContour: async args => {
|
||||
const { segmentationId, options } = args;
|
||||
// `dataSource` is only used by the SEG store path; keep it out of the RTSS options.
|
||||
const { dataSource: _dataSource, ...contourOptions } = options ?? {};
|
||||
const segmentations = segmentationService.getSegmentation(segmentationId);
|
||||
|
||||
// inject colors to the segmentIndex
|
||||
@ -287,45 +403,52 @@ const commandsModule = ({
|
||||
segment.color = segmentationService.getSegmentColor(
|
||||
firstRepresentation.viewportId,
|
||||
segmentationId,
|
||||
segmentIndex
|
||||
Number(segmentIndex)
|
||||
);
|
||||
});
|
||||
const predecessorImageId =
|
||||
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
|
||||
const dataset = await generateRTSSFromRepresentation(segmentations, {
|
||||
predecessorImageId,
|
||||
...contourOptions,
|
||||
});
|
||||
return { dataset };
|
||||
},
|
||||
|
||||
const RTSS = await generateRTSSFromSegmentations(
|
||||
segmentations,
|
||||
classes.MetadataProvider,
|
||||
DicomMetadataStore
|
||||
);
|
||||
/**
|
||||
* Downloads an RTSS instance from a segmentation or contour
|
||||
* representation.
|
||||
*/
|
||||
downloadRTSS: async args => {
|
||||
const { dataset } = await actions.generateContour(args);
|
||||
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
|
||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||
dataSource: 'download',
|
||||
defaultFileName: `rtss-${seriesUID}-${instanceNumber}.dcm`,
|
||||
});
|
||||
await storeFn(dataset);
|
||||
},
|
||||
|
||||
try {
|
||||
const reportBlob = datasetToBlob(RTSS);
|
||||
|
||||
//Create a URL for the binary.
|
||||
const objectUrl = URL.createObjectURL(reportBlob);
|
||||
window.location.assign(objectUrl);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {
|
||||
const { uiState, setUIState } = useUIStateStore.getState();
|
||||
const isButtonActive = uiState['activeSegmentationUtility'] === buttonId;
|
||||
console.log('toggleActiveSegmentationUtility', isButtonActive, buttonId);
|
||||
// if the button is active, clear the active segmentation utility
|
||||
if (isButtonActive) {
|
||||
setUIState('activeSegmentationUtility', null);
|
||||
} else {
|
||||
setUIState('activeSegmentationUtility', buttonId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
loadSegmentationsForViewport: {
|
||||
commandFn: actions.loadSegmentationsForViewport,
|
||||
},
|
||||
|
||||
generateSegmentation: {
|
||||
commandFn: actions.generateSegmentation,
|
||||
},
|
||||
downloadSegmentation: {
|
||||
commandFn: actions.downloadSegmentation,
|
||||
},
|
||||
storeSegmentation: {
|
||||
commandFn: actions.storeSegmentation,
|
||||
},
|
||||
downloadRTSS: {
|
||||
commandFn: actions.downloadRTSS,
|
||||
},
|
||||
loadSegmentationsForViewport: actions.loadSegmentationsForViewport,
|
||||
generateSegmentation: actions.generateSegmentation,
|
||||
downloadSegmentation: actions.downloadSegmentation,
|
||||
storeSegmentation: actions.storeSegmentation,
|
||||
downloadRTSS: actions.downloadRTSS,
|
||||
toggleActiveSegmentationUtility: actions.toggleActiveSegmentationUtility,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@ -0,0 +1,252 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useRunCommand, useSystem } from '@ohif/core';
|
||||
import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone';
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@ohif/ui-next';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
import { contourSegmentation } from '@cornerstonejs/tools/utilities';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Segment } from '@cornerstonejs/tools/types';
|
||||
|
||||
const { LogicalOperation } = contourSegmentation;
|
||||
const options = [
|
||||
{
|
||||
value: 'merge',
|
||||
logicalOperation: LogicalOperation.Union,
|
||||
label: 'Merge',
|
||||
icon: 'actions-combine-merge',
|
||||
helperIcon: 'helper-combine-merge',
|
||||
},
|
||||
{
|
||||
value: 'intersect',
|
||||
logicalOperation: LogicalOperation.Intersect,
|
||||
label: 'Intersect',
|
||||
icon: 'actions-combine-intersect',
|
||||
helperIcon: 'helper-combine-intersect',
|
||||
},
|
||||
{
|
||||
value: 'subtract',
|
||||
logicalOperation: LogicalOperation.Subtract,
|
||||
label: 'Subtract',
|
||||
icon: 'actions-combine-subtract',
|
||||
helperIcon: 'helper-combine-subtract',
|
||||
},
|
||||
];
|
||||
|
||||
// Shared component for segment selection
|
||||
function SegmentSelector({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
segments,
|
||||
placeholder = 'Select a segment',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
segments: Segment[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation('SegmentationPanel');
|
||||
return (
|
||||
<div className="flex justify-between gap-6">
|
||||
<div>{label}</div>
|
||||
<Select
|
||||
key={`select-segment-${label}`}
|
||||
onValueChange={onValueChange}
|
||||
value={value}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="overflow-hidden"
|
||||
data-cy={`logical-contour-segment-${label.toLowerCase()}-trigger`}
|
||||
>
|
||||
<SelectValue placeholder={t(placeholder)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{segments.map(segment => (
|
||||
<SelectItem
|
||||
key={segment.segmentIndex}
|
||||
value={segment.segmentIndex.toString()}
|
||||
>
|
||||
{segment.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogicalContourOperationOptions() {
|
||||
const { servicesManager } = useSystem();
|
||||
const { segmentationService } = servicesManager.services;
|
||||
const { t } = useTranslation('SegmentationPanel');
|
||||
const { segmentationsWithRepresentations } = useActiveViewportSegmentationRepresentations();
|
||||
|
||||
const activeRepresentation = segmentationsWithRepresentations?.find(
|
||||
({ representation }) => representation?.active
|
||||
);
|
||||
|
||||
const segments = activeRepresentation
|
||||
? Object.values(activeRepresentation.segmentation.segments)
|
||||
: [];
|
||||
|
||||
// Calculate the next available segment index
|
||||
const nextSegmentIndex = activeRepresentation
|
||||
? segmentationService.getNextAvailableSegmentIndex(
|
||||
activeRepresentation.segmentation.segmentationId
|
||||
)
|
||||
: 1;
|
||||
|
||||
const activeSegment = segments.find(segment => segment.active);
|
||||
|
||||
const activeSegmentIndex = activeSegment?.segmentIndex || 0;
|
||||
|
||||
const [operation, setOperation] = useState(options[0]);
|
||||
const [segmentA, setSegmentA] = useState<string>(activeSegmentIndex?.toString() || '');
|
||||
const [segmentB, setSegmentB] = useState<string>('');
|
||||
const [createNewSegment, setCreateNewSegment] = useState<boolean>(false);
|
||||
const [newSegmentName, setNewSegmentName] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setSegmentA(activeSegmentIndex?.toString() || null);
|
||||
}, [activeSegmentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setNewSegmentName(`Segment ${nextSegmentIndex}`);
|
||||
}, [nextSegmentIndex]);
|
||||
|
||||
const runCommand = useRunCommand();
|
||||
|
||||
const applyLogicalContourOperation = useCallback(() => {
|
||||
let resultSegmentIndex = segmentA;
|
||||
if (createNewSegment) {
|
||||
resultSegmentIndex = nextSegmentIndex.toString();
|
||||
runCommand('addSegment', {
|
||||
segmentationId: activeRepresentation.segmentation.segmentationId,
|
||||
config: {
|
||||
label: newSegmentName,
|
||||
segmentIndex: nextSegmentIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
runCommand('applyLogicalContourOperation', {
|
||||
segmentAInfo: {
|
||||
segmentationId: activeRepresentation.segmentation.segmentationId,
|
||||
segmentIndex: parseInt(segmentA),
|
||||
},
|
||||
segmentBInfo: {
|
||||
segmentationId: activeRepresentation.segmentation.segmentationId,
|
||||
segmentIndex: parseInt(segmentB),
|
||||
},
|
||||
resultSegmentInfo: {
|
||||
segmentationId: activeRepresentation.segmentation.segmentationId,
|
||||
segmentIndex: parseInt(resultSegmentIndex),
|
||||
},
|
||||
logicalOperation: operation.logicalOperation,
|
||||
});
|
||||
}, [
|
||||
activeRepresentation?.segmentation?.segmentationId,
|
||||
createNewSegment,
|
||||
newSegmentName,
|
||||
nextSegmentIndex,
|
||||
operation.logicalOperation,
|
||||
runCommand,
|
||||
segmentA,
|
||||
segmentB,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex w-[245px] flex-col gap-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex w-auto flex-col items-center gap-2 text-base font-normal leading-none">
|
||||
<Tabs value={operation.value}>
|
||||
<TabsList className="inline-flex space-x-1">
|
||||
{options.map(option => {
|
||||
const { value, icon } = option;
|
||||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
key={`logical-contour-operation-${value}`}
|
||||
onClick={() => setOperation(option)}
|
||||
data-cy={`logical-contour-operation-${value}`}
|
||||
>
|
||||
<Icons.ByName name={icon}></Icons.ByName>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div>{t(operation.label)}</div>
|
||||
</div>
|
||||
<div className="bg-muted flex h-[62px] w-[88px] items-center justify-center rounded-lg">
|
||||
<Icons.ByName name={operation.helperIcon}></Icons.ByName>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentSelector
|
||||
label="A"
|
||||
value={segmentA}
|
||||
onValueChange={setSegmentA}
|
||||
segments={segments}
|
||||
/>
|
||||
<SegmentSelector
|
||||
label="B"
|
||||
value={segmentB}
|
||||
onValueChange={setSegmentB}
|
||||
segments={segments}
|
||||
/>
|
||||
<div className="flex justify-end pl-[34px]">
|
||||
<Button
|
||||
data-cy="apply-logical-contour-operation"
|
||||
className="border-primary/60 grow border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
applyLogicalContourOperation();
|
||||
}}
|
||||
>
|
||||
{t(operation.label)}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-input mt-2 h-[1px]" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Switch
|
||||
id="logical-contour-operations-create-new-segment-switch"
|
||||
data-cy="logical-contour-create-new-segment-switch"
|
||||
onCheckedChange={setCreateNewSegment}
|
||||
></Switch>
|
||||
<Label htmlFor="logical-contour-operations-create-new-segment-switch">
|
||||
{t('Create a new segment')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="pl-9">
|
||||
<Input
|
||||
className={cn(createNewSegment ? 'visible' : 'hidden')}
|
||||
disabled={!createNewSegment}
|
||||
id="logical-contour-operations-create-new-segment-input"
|
||||
type="text"
|
||||
placeholder={t('New segment name')}
|
||||
value={newSegmentName}
|
||||
onChange={e => setNewSegmentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogicalContourOperationOptions;
|
||||
@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Input, Label, Separator } from '@ohif/ui-next';
|
||||
import { useRunCommand } from '@ohif/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function SimplifyContourOptions() {
|
||||
const [areaThreshold, setAreaThreshold] = useState(10);
|
||||
|
||||
const runCommand = useRunCommand();
|
||||
const { t } = useTranslation('SegmentationPanel');
|
||||
|
||||
return (
|
||||
<div className="flex w-auto w-[252px] flex-col gap-[8px] text-base font-normal leading-none">
|
||||
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
|
||||
<div>{t('Fill contour holes')}</div>
|
||||
<Button
|
||||
className="border-primary/60 border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
runCommand('removeContourHoles');
|
||||
}}
|
||||
>
|
||||
{t('Fill Holes')}
|
||||
</Button>
|
||||
<Separator className="bg-input mt-[20px] h-[1px]" />
|
||||
</div>
|
||||
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
|
||||
<div>{t('Remove Small Contours')}</div>
|
||||
<div className="flex items-center gap-2 self-end">
|
||||
<Label
|
||||
htmlFor="simplify-contour-options"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t('Area Threshold')}
|
||||
</Label>
|
||||
<Input
|
||||
id="simplify-contour-options"
|
||||
className="w-20"
|
||||
type="number"
|
||||
value={areaThreshold}
|
||||
onChange={e => setAreaThreshold(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="border-primary/60 border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
runCommand('removeSmallContours', {
|
||||
areaThreshold,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Remove Small Contours')}
|
||||
</Button>
|
||||
<Separator className="bg-input mt-[20px] h-[1px]" />
|
||||
</div>
|
||||
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
|
||||
<div>{t('Create New Segment from Holes')}</div>
|
||||
<Button
|
||||
className="border-primary/60 border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
runCommand('convertContourHoles');
|
||||
}}
|
||||
>
|
||||
{t('Create New Segment')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimplifyContourOptions;
|
||||
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Button, Separator } from '@ohif/ui-next';
|
||||
import { useRunCommand } from '@ohif/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function SmoothContoursOptions() {
|
||||
const runCommand = useRunCommand();
|
||||
const { t } = useTranslation('SegmentationPanel');
|
||||
|
||||
return (
|
||||
<div className="flex w-auto w-[245px] flex-col gap-[8px] text-base font-normal leading-none">
|
||||
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
|
||||
<div>{t('Smooth all edges')}</div>
|
||||
<Button
|
||||
className="border-primary/60 border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
runCommand('smoothContours');
|
||||
}}
|
||||
>
|
||||
{t('Smooth Edges')}
|
||||
</Button>
|
||||
<Separator className="bg-input mt-[20px] h-[1px]" />
|
||||
</div>
|
||||
<div className="flex w-auto flex-col gap-[10px] text-base font-normal leading-none">
|
||||
<div>{t('Remove extra points')}</div>
|
||||
<Button
|
||||
className="border-primary/60 border"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
runCommand('decimateContours');
|
||||
}}
|
||||
>
|
||||
{t('Remove Points')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmoothContoursOptions;
|
||||
@ -0,0 +1,29 @@
|
||||
import {
|
||||
DEFAULT_SEG_STORE_MODE,
|
||||
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||
type SegmentationMode,
|
||||
} from '../utils/segmentationConfig';
|
||||
|
||||
export type SegmentLabelCustomization = {
|
||||
enabledByDefault?: boolean;
|
||||
labelColor?: number[];
|
||||
hoverTimeout?: number;
|
||||
background?: string;
|
||||
};
|
||||
|
||||
export type SegmentationStoreCustomization = {
|
||||
defaultMode?: SegmentationMode;
|
||||
transferSyntaxUID?: string;
|
||||
};
|
||||
|
||||
/** Extension-registered defaults: Label Map SEG + RLE Lossless. */
|
||||
const segmentationCustomization = {
|
||||
'segmentation.store.defaultMode': DEFAULT_SEG_STORE_MODE,
|
||||
'segmentation.store.transferSyntaxUID': DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||
'segmentation.segmentLabel': {
|
||||
enabledByDefault: false,
|
||||
hoverTimeout: 1,
|
||||
} satisfies SegmentLabelCustomization,
|
||||
};
|
||||
|
||||
export default segmentationCustomization;
|
||||
@ -0,0 +1,10 @@
|
||||
import segmentationCustomization from './customizations/segmentationCustomization';
|
||||
|
||||
export default function getCustomizationModule() {
|
||||
return [
|
||||
{
|
||||
name: 'default',
|
||||
value: segmentationCustomization,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -1,34 +1,261 @@
|
||||
import { utils, Types as OhifTypes } from '@ohif/core';
|
||||
import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { metaData, eventTarget } from '@cornerstonejs/core';
|
||||
import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
|
||||
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
|
||||
|
||||
import { SOPClassHandlerId } from './id';
|
||||
import { dicomlabToRGB } from './utils/dicomlabToRGB';
|
||||
import { getSegmentationParserType } from './utils/segmentationConfig';
|
||||
import {
|
||||
getFrameIndexFromImageId,
|
||||
isLocalSchemeImageId,
|
||||
stripFrameFromImageId,
|
||||
} from './utils/segLocalImageIds';
|
||||
|
||||
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
|
||||
const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||
|
||||
const loadPromises = {};
|
||||
|
||||
const SEG_LOAD_LOG_PREFIX = '[SEG load]';
|
||||
|
||||
// Max number of SEG frames fetched/decoded concurrently by the segmentation
|
||||
// loader. Hard-coded to 16 for now; intended to become configurable (and to
|
||||
// pair with the full-instance prefetch capability) in a follow-up.
|
||||
const SEG_FRAME_DECODE_CONCURRENCY = 16;
|
||||
|
||||
|
||||
function _normalizeImageId(imageId: string | string[] | undefined): string | undefined {
|
||||
if (imageId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(imageId) ? imageId[0] : imageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a WADO-RS frame imageId (…/frames/1) into one imageId per frame.
|
||||
* Multiframe SEG is stored as separate /frames/N resources on the server.
|
||||
*/
|
||||
function getFrameImageIds(segImageId: string, numberOfFrames: number): string[] {
|
||||
const frameMatch = segImageId.match(/(.*\/frames\/)(\d+)(.*)$/);
|
||||
if (!frameMatch || numberOfFrames <= 1) {
|
||||
return [segImageId];
|
||||
}
|
||||
|
||||
const prefix = frameMatch[1];
|
||||
const suffix = frameMatch[3] || '';
|
||||
const frameImageIds: string[] = [];
|
||||
|
||||
for (let frameNumber = 1; frameNumber <= numberOfFrames; frameNumber++) {
|
||||
frameImageIds.push(`${prefix}${frameNumber}${suffix}`);
|
||||
}
|
||||
|
||||
return frameImageIds;
|
||||
}
|
||||
|
||||
function _getSegNumberOfFrames(instance: Record<string, unknown>): number {
|
||||
const fromTag = Number(instance.NumberOfFrames);
|
||||
if (fromTag > 0) {
|
||||
return fromTag;
|
||||
}
|
||||
|
||||
const perFrame = instance.PerFrameFunctionalGroupsSequence;
|
||||
if (Array.isArray(perFrame) && perFrame.length > 0) {
|
||||
return perFrame.length;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function _ensureSegImageIdMetadataRegistered(
|
||||
imageId: string | undefined,
|
||||
instance: Record<string, unknown>
|
||||
): void {
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataProvider = classes.MetadataProvider;
|
||||
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadataProvider.addImageIdToUIDs(imageId, {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
frameNumber: getFrameIndexFromImageId(imageId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensures metadataProvider.get('instance', imageId) resolves for frame-qualified local SEG ids. */
|
||||
function _ensureSegInstanceMetadataAvailable(
|
||||
imageId: string | undefined,
|
||||
instance: Record<string, unknown>
|
||||
): void {
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_ensureSegImageIdMetadataRegistered(imageId, instance);
|
||||
|
||||
if (metaData.get('instance', imageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
const storedInstance =
|
||||
StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID
|
||||
? DicomMetadataStore.getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID)
|
||||
: undefined;
|
||||
|
||||
classes.MetadataProvider.addCustomMetadata(
|
||||
imageId,
|
||||
'instance',
|
||||
storedInstance || instance
|
||||
);
|
||||
}
|
||||
|
||||
function _getSegDataSource(extensionManager, instance: Record<string, unknown>) {
|
||||
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
let localUrl: string | undefined;
|
||||
|
||||
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
|
||||
const storedInstance = DicomMetadataStore.getInstance(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID
|
||||
);
|
||||
localUrl = storedInstance?.url as string | undefined;
|
||||
}
|
||||
|
||||
localUrl = localUrl || (instance.url as string | undefined);
|
||||
|
||||
if (localUrl && isLocalSchemeImageId(localUrl)) {
|
||||
const dicomLocal = extensionManager.getDataSources('dicomlocal');
|
||||
|
||||
if (dicomLocal?.[0]) {
|
||||
return dicomLocal[0];
|
||||
}
|
||||
}
|
||||
|
||||
return extensionManager.getActiveDataSource()[0];
|
||||
}
|
||||
|
||||
function _getSegImageIdFromInstance(
|
||||
instance: Record<string, unknown>,
|
||||
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||
): string | undefined {
|
||||
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||
const frame = numberOfFrames > 1 ? 1 : undefined;
|
||||
|
||||
return _normalizeImageId(
|
||||
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||
);
|
||||
}
|
||||
|
||||
function _resolveFrameImageIds(
|
||||
segImageIdStr: string,
|
||||
instance: Record<string, unknown>,
|
||||
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||
): string[] {
|
||||
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||
const fromFrameUrl = getFrameImageIds(segImageIdStr, numberOfFrames);
|
||||
|
||||
if (fromFrameUrl.length > 1) {
|
||||
return fromFrameUrl;
|
||||
}
|
||||
|
||||
if (numberOfFrames <= 1) {
|
||||
return [segImageIdStr];
|
||||
}
|
||||
|
||||
const frameImageIds: string[] = [];
|
||||
|
||||
for (let frame = 1; frame <= numberOfFrames; frame++) {
|
||||
const frameImageId = _normalizeImageId(
|
||||
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||
);
|
||||
|
||||
if (frameImageId) {
|
||||
frameImageIds.push(frameImageId);
|
||||
}
|
||||
}
|
||||
|
||||
return frameImageIds.length ? frameImageIds : [segImageIdStr];
|
||||
}
|
||||
|
||||
function _logSegImageIds({
|
||||
segDisplaySet,
|
||||
segImageIdStr,
|
||||
frameImageIds,
|
||||
referencedImageIds,
|
||||
}: {
|
||||
segDisplaySet: AppTypes.DisplaySet;
|
||||
segImageIdStr: string;
|
||||
frameImageIds: string[];
|
||||
referencedImageIds: string[];
|
||||
}) {
|
||||
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||
const numberOfFrames = Number(instance?.NumberOfFrames) || 1;
|
||||
|
||||
log.debug(SEG_LOAD_LOG_PREFIX, 'Loading SEG pixel data', {
|
||||
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||
SeriesInstanceUID: segDisplaySet.SeriesInstanceUID,
|
||||
SOPClassUID: segDisplaySet.SOPClassUID,
|
||||
NumberOfFrames: numberOfFrames,
|
||||
segmentCount: Object.keys(segDisplaySet.segments || {}).length,
|
||||
referencedDisplaySetInstanceUID: segDisplaySet.referencedDisplaySetInstanceUID,
|
||||
referencedImageIdCount: referencedImageIds.length,
|
||||
referencedImageIds,
|
||||
segImageIdForMetadata: segImageIdStr,
|
||||
frameImageIds,
|
||||
loadSegFramesIndividually: frameImageIds.length > 1,
|
||||
});
|
||||
}
|
||||
|
||||
function _getDisplaySetsFromSeries(
|
||||
instances,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
extensionManager
|
||||
) {
|
||||
const instance = instances[0];
|
||||
utils.sortStudyInstances(instances);
|
||||
|
||||
// Choose the LAST instance in the list as the most recently created one.
|
||||
const instance = instances[instances.length - 1];
|
||||
|
||||
const {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
SeriesDescription,
|
||||
SeriesDescription = '',
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
StructureSetDate,
|
||||
SOPClassUID,
|
||||
FrameOfReferenceUID,
|
||||
wadoRoot,
|
||||
wadoUri,
|
||||
wadoUriRoot,
|
||||
imageId: predecessorImageId,
|
||||
} = instance;
|
||||
|
||||
const displaySet = {
|
||||
@ -38,12 +265,13 @@ function _getDisplaySetsFromSeries(
|
||||
displaySetInstanceUID: utils.guid(),
|
||||
SeriesDescription,
|
||||
SeriesNumber,
|
||||
SeriesDate,
|
||||
SeriesDate: SeriesDate || StructureSetDate || '',
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
SOPClassHandlerId,
|
||||
SOPClassUID,
|
||||
FrameOfReferenceUID,
|
||||
referencedImages: null,
|
||||
referencedSeriesInstanceUID: null,
|
||||
referencedDisplaySetInstanceUID: null,
|
||||
@ -53,6 +281,7 @@ function _getDisplaySetsFromSeries(
|
||||
segments: {},
|
||||
sopClassUids,
|
||||
instance,
|
||||
predecessorImageId,
|
||||
instances: [instance],
|
||||
wadoRoot,
|
||||
wadoUriRoot,
|
||||
@ -73,10 +302,16 @@ function _getDisplaySetsFromSeries(
|
||||
displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence;
|
||||
displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID;
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const referencedDisplaySets = displaySetService.getDisplaySetsForSeries(
|
||||
displaySet.referencedSeriesInstanceUID
|
||||
const referencedDisplaySets = displaySetService.getDisplaySetsForReferences(
|
||||
instance.ReferencedSeriesSequence
|
||||
);
|
||||
|
||||
if (referencedDisplaySets?.length > 1) {
|
||||
console.warn(
|
||||
'Segmentation does not currently handle references to multiple series, defaulting to first series'
|
||||
);
|
||||
}
|
||||
|
||||
const referencedDisplaySet = referencedDisplaySets[0];
|
||||
|
||||
if (!referencedDisplaySet) {
|
||||
@ -92,6 +327,7 @@ function _getDisplaySetsFromSeries(
|
||||
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
|
||||
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
|
||||
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
|
||||
displaySet.FrameOfReferenceUID = addedDisplaySet.FrameOfReferenceUID;
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
@ -99,6 +335,7 @@ function _getDisplaySetsFromSeries(
|
||||
} else {
|
||||
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
|
||||
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
|
||||
displaySet.FrameOfReferenceUID = referencedDisplaySet.FrameOfReferenceUID;
|
||||
}
|
||||
|
||||
displaySet.load = async ({ headers }) =>
|
||||
@ -155,6 +392,11 @@ function _load(
|
||||
});
|
||||
});
|
||||
|
||||
// Expose the in-flight load promise so observers (e.g. the viewport service
|
||||
// waiting to attach the representation) can react to a load failure without
|
||||
// re-invoking load().
|
||||
segDisplaySet.loadingPromise = loadPromises[SOPInstanceUID];
|
||||
|
||||
return loadPromises[SOPInstanceUID];
|
||||
}
|
||||
|
||||
@ -162,16 +404,18 @@ async function _loadSegments({
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
segDisplaySet,
|
||||
headers,
|
||||
}: withAppTypes) {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) {
|
||||
const { segmentationService, uiNotificationService, customizationService } =
|
||||
servicesManager.services;
|
||||
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||
const dataSource = _getSegDataSource(extensionManager, instance);
|
||||
const segImageIdStr = _getSegImageIdFromInstance(instance, dataSource);
|
||||
|
||||
const { segmentationService, uiNotificationService } = servicesManager.services;
|
||||
|
||||
const { dicomLoaderService } = utilityModule.exports;
|
||||
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
|
||||
if (!segImageIdStr) {
|
||||
throw new Error(
|
||||
'Could not get imageId for SEG instance (no local wadouri url and getImageIdsForInstance returned nothing).'
|
||||
);
|
||||
}
|
||||
|
||||
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
||||
segDisplaySet.referencedDisplaySetInstanceUID
|
||||
@ -181,31 +425,114 @@ async function _loadSegments({
|
||||
throw new Error('referencedDisplaySet is missing for SEG');
|
||||
}
|
||||
|
||||
// Prefer cached stack imageIds (multiframe SEG fix #4890), then data source expansion.
|
||||
let { imageIds } = referencedDisplaySet;
|
||||
|
||||
if (!imageIds) {
|
||||
// try images
|
||||
const { images } = referencedDisplaySet;
|
||||
imageIds = images.map(image => image.imageId);
|
||||
if (!imageIds?.length) {
|
||||
imageIds = dataSource.getImageIdsForDisplaySet?.(referencedDisplaySet);
|
||||
}
|
||||
|
||||
// Todo: what should be defaults here
|
||||
if (!imageIds?.length) {
|
||||
imageIds = (referencedDisplaySet as { images?: { imageId: string }[] }).images?.map(
|
||||
(img: { imageId: string }) => img.imageId
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageIds?.length) {
|
||||
throw new Error('referencedDisplaySet has no imageIds');
|
||||
}
|
||||
|
||||
(segDisplaySet as AppTypes.DisplaySet & { referencedImageIds?: string[] }).referencedImageIds =
|
||||
imageIds;
|
||||
|
||||
if (!referencedDisplaySet.imageIds?.length) {
|
||||
referencedDisplaySet.imageIds = imageIds;
|
||||
}
|
||||
|
||||
const frameImageIds = _resolveFrameImageIds(
|
||||
segImageIdStr,
|
||||
segDisplaySet.instance as Record<string, unknown>,
|
||||
dataSource
|
||||
);
|
||||
|
||||
const segImageIdForMetadata = isLocalSchemeImageId(segImageIdStr)
|
||||
? stripFrameFromImageId(segImageIdStr)
|
||||
: segImageIdStr;
|
||||
|
||||
_logSegImageIds({
|
||||
segDisplaySet,
|
||||
segImageIdStr: segImageIdForMetadata,
|
||||
frameImageIds,
|
||||
referencedImageIds: imageIds,
|
||||
});
|
||||
|
||||
_ensureSegInstanceMetadataAvailable(segImageIdForMetadata, instance);
|
||||
frameImageIds.forEach(id => _ensureSegInstanceMetadataAvailable(id, instance));
|
||||
|
||||
const tolerance = 0.001;
|
||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
|
||||
const onProgress = evt => {
|
||||
const { percentComplete } = evt.detail;
|
||||
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
|
||||
percentComplete,
|
||||
});
|
||||
});
|
||||
};
|
||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||
|
||||
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
|
||||
imageIds,
|
||||
arrayBuffer,
|
||||
{ metadataProvider: metaData, tolerance }
|
||||
);
|
||||
// Fetch the whole SEG instance as a single Part 10 object and register its
|
||||
// per-frame compressed pixels into the Cornerstone3D frame registry, so the
|
||||
// per-frame loads below are served locally instead of one network request
|
||||
// per frame: SEG frames are so small and numerous that one bulk fetch beats
|
||||
// hundreds of tiny requests. Enabled by default; per-frame loading is the
|
||||
// exception (loadMultiframeAsPart10: false in the data source config, or the
|
||||
// cornerstone.segmentation.loadMultiframeAsPart10 customization). The
|
||||
// prefetch is awaited until it completes OR fails — deliberately no timeout:
|
||||
// a failed/unsupported instance fetch resolves quickly and falls back to
|
||||
// per-frame, while a slow large fetch is still the fastest way to all frames.
|
||||
const loadMultiframeAsPart10 =
|
||||
(dataSource?.getConfig?.()?.loadMultiframeAsPart10 as boolean | undefined) ??
|
||||
(customizationService?.getCustomization?.(
|
||||
'cornerstone.segmentation.loadMultiframeAsPart10'
|
||||
) as boolean | undefined) ??
|
||||
true;
|
||||
|
||||
let prefetch;
|
||||
if (loadMultiframeAsPart10) {
|
||||
prefetch = dataSource.retrieve?.prefetchInstanceFrames?.({
|
||||
instance,
|
||||
imageId: segImageIdForMetadata,
|
||||
});
|
||||
|
||||
if (prefetch?.done) {
|
||||
await prefetch.done;
|
||||
}
|
||||
}
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId(
|
||||
imageIds,
|
||||
segImageIdForMetadata,
|
||||
{
|
||||
metadataProvider: metaData,
|
||||
tolerance,
|
||||
parserType: getSegmentationParserType(
|
||||
segDisplaySet.SOPClassUID,
|
||||
customizationService
|
||||
),
|
||||
frameImageIds,
|
||||
concurrency: SEG_FRAME_DECODE_CONCURRENCY,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
eventTarget.removeEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||
prefetch?.cancel?.();
|
||||
}
|
||||
|
||||
let usedRecommendedDisplayCIELabValue = true;
|
||||
results.segMetadata.data.forEach((data, i) => {
|
||||
const resultsTyped = results as {
|
||||
segMetadata: { data: { rgba?: number[]; RecommendedDisplayCIELabValue?: number[] }[] };
|
||||
};
|
||||
resultsTyped.segMetadata.data.forEach((data, i) => {
|
||||
if (i > 0) {
|
||||
data.rgba = data.RecommendedDisplayCIELabValue;
|
||||
|
||||
@ -230,6 +557,17 @@ async function _loadSegments({
|
||||
}
|
||||
|
||||
Object.assign(segDisplaySet, results);
|
||||
|
||||
const labelMapImageIds = (results as { labelMapImages?: { imageId: string }[][] })
|
||||
.labelMapImages?.flat()
|
||||
.map(image => image.imageId);
|
||||
|
||||
log.debug(SEG_LOAD_LOG_PREFIX, 'SEG parse complete', {
|
||||
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||
labelMapImageCount: labelMapImageIds?.length ?? 0,
|
||||
labelMapImageIds,
|
||||
segmentIndices: Object.keys(segDisplaySet.segments || {}),
|
||||
});
|
||||
}
|
||||
|
||||
function _segmentationExists(segDisplaySet) {
|
||||
|
||||
@ -1,6 +1,35 @@
|
||||
import { utilities as cstUtils } from '@cornerstonejs/tools';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { useUIStateStore } from '@ohif/extension-default';
|
||||
|
||||
import LogicalContourOperationsOptions from './components/LogicalContourOperationsOptions';
|
||||
import SimplifyContourOptions from './components/SimplifyContourOptions';
|
||||
import SmoothContoursOptions from './components/SmoothContoursOptions';
|
||||
|
||||
export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
const { segmentationService, toolbarService, toolGroupService } = servicesManager.services;
|
||||
return [
|
||||
{
|
||||
name: 'cornerstone.SimplifyContourOptions',
|
||||
defaultComponent: SimplifyContourOptions,
|
||||
},
|
||||
{
|
||||
name: 'cornerstone.LogicalContourOperationsOptions',
|
||||
defaultComponent: LogicalContourOperationsOptions,
|
||||
},
|
||||
{
|
||||
name: 'cornerstone.SmoothContoursOptions',
|
||||
defaultComponent: SmoothContoursOptions,
|
||||
},
|
||||
{
|
||||
name: 'cornerstone.isActiveSegmentationUtility',
|
||||
evaluate: ({ button }) => {
|
||||
const { uiState } = useUIStateStore.getState();
|
||||
return {
|
||||
isActive: uiState[`activeSegmentationUtility`] === button.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.hasSegmentation',
|
||||
evaluate: ({ viewportId }) => {
|
||||
@ -10,6 +39,30 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.hasSegmentationOfType',
|
||||
evaluate: ({ viewportId, segmentationRepresentationType }) => {
|
||||
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
|
||||
|
||||
if (!segmentations?.length) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: i18n.t('SegmentationPanel:No segmentations available'),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!segmentations.some(segmentation =>
|
||||
Boolean(segmentation.type === segmentationRepresentationType)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: `No ${segmentationRepresentationType} segmentations available`,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.segmentation',
|
||||
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
|
||||
@ -21,7 +74,7 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
if (!segmentations?.length) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: disabledText ?? 'No segmentations available',
|
||||
disabledText: disabledText ?? i18n.t('SegmentationPanel:No segmentations available'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,7 +82,7 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
if (!Object.keys(activeSegmentation.segments).length) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: 'Add segment to enable this tool',
|
||||
disabledText: i18n.t('SegmentationPanel:Add segment to enable this tool'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -38,7 +91,7 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
if (!toolGroup) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: disabledText ?? 'Not available on the current viewport',
|
||||
disabledText: disabledText ?? i18n.t('SegmentationPanel:Not available on the current viewport'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,7 +107,7 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
if (!toolGroup.hasTool(toolName) && !toolNames) {
|
||||
return {
|
||||
disabled: true,
|
||||
disabledText: disabledText ?? 'Not available on the current viewport',
|
||||
disabledText: disabledText ?? i18n.t('SegmentationPanel:Not available on the current viewport'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -68,5 +121,23 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.segmentation.synchronizeDrawingRadius',
|
||||
evaluate: ({ button, radiusOptionId }) => {
|
||||
const toolGroupIds = toolGroupService.getToolGroupIds();
|
||||
if (!toolGroupIds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const toolGroupId of toolGroupIds) {
|
||||
const brushSize = cstUtils.segmentation.getBrushSizeForToolGroup(toolGroupId);
|
||||
|
||||
if (brushSize) {
|
||||
const option = toolbarService.getOptionById(button, radiusOptionId);
|
||||
option.value = brushSize;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import getSopClassHandlerModule from './getSopClassHandlerModule';
|
||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||
import getCommandsModule from './commandsModule';
|
||||
import getCustomizationModule from './getCustomizationModule';
|
||||
import { getToolbarModule } from './getToolbarModule';
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
@ -28,6 +29,7 @@ const extension = {
|
||||
*/
|
||||
id,
|
||||
getCommandsModule,
|
||||
getCustomizationModule,
|
||||
getToolbarModule,
|
||||
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
|
||||
const ExtendedOHIFCornerstoneSEGViewport = props => {
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
|
||||
function createSEGToolGroupAndAddTools({
|
||||
commandsManager,
|
||||
toolGroupService,
|
||||
customizationService,
|
||||
toolGroupId,
|
||||
}) {
|
||||
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
|
||||
|
||||
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
|
||||
const updatedTools = commandsManager.run('initializeSegmentLabelTool', { tools });
|
||||
|
||||
return toolGroupService.createToolGroupAndAddTools(toolGroupId, updatedTools);
|
||||
}
|
||||
|
||||
export default createSEGToolGroupAndAddTools;
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
export function isLocalSchemeImageId(imageId: string): boolean {
|
||||
return /^(wadouri:|dicomfile:|dicomweb:)/.test(imageId);
|
||||
}
|
||||
|
||||
export function stripFrameFromImageId(imageId: string): string {
|
||||
const queryIndex = imageId.indexOf('?');
|
||||
if (queryIndex === -1) {
|
||||
return imageId;
|
||||
}
|
||||
|
||||
const basePath = imageId.slice(0, queryIndex);
|
||||
const rebuiltQuery = imageId
|
||||
.slice(queryIndex + 1)
|
||||
.split('&')
|
||||
.filter(param => param.split('=')[0] !== 'frame')
|
||||
.join('&');
|
||||
|
||||
return rebuiltQuery ? `${basePath}?${rebuiltQuery}` : basePath;
|
||||
}
|
||||
|
||||
export function appendFrameToImageId(baseImageId: string, frame: number): string {
|
||||
const withoutFrame = stripFrameFromImageId(baseImageId);
|
||||
const separator = withoutFrame.includes('?') ? '&' : '?';
|
||||
|
||||
return `${withoutFrame}${separator}frame=${frame}`;
|
||||
}
|
||||
|
||||
export function getFrameIndexFromImageId(imageId: string): number {
|
||||
const frameMatch = imageId.match(/(?:&|\?)frame=(\d+)/);
|
||||
|
||||
return frameMatch ? Number(frameMatch[1]) : 1;
|
||||
}
|
||||
100
extensions/cornerstone-dicom-seg/src/utils/segmentationConfig.ts
Normal file
100
extensions/cornerstone-dicom-seg/src/utils/segmentationConfig.ts
Normal file
@ -0,0 +1,100 @@
|
||||
export const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||
export const BITMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.4';
|
||||
/** RLE Lossless — OHIF default SEG store transfer syntax. */
|
||||
export const DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.5';
|
||||
/** OHIF default SEG store mode (Label Map Segmentation SOP Class). */
|
||||
export const DEFAULT_SEG_STORE_MODE = 'labelmap' as const;
|
||||
|
||||
export type SegmentationMode = 'labelmap' | 'bitmap';
|
||||
|
||||
/**
|
||||
* Per-data-source overrides for the SEG store defaults. A data source may set
|
||||
* these under `configuration.segmentation.store` to override the values coming
|
||||
* from the `segmentation.store.*` customizations. Different back ends support
|
||||
* different SEG encodings, so the data source is allowed to win over the
|
||||
* app-wide customization default.
|
||||
*/
|
||||
export type SegmentationStoreOverride = {
|
||||
defaultMode?: SegmentationMode;
|
||||
transferSyntaxUID?: string;
|
||||
};
|
||||
|
||||
type SegmentationCustomizationReader = {
|
||||
getCustomization: (customizationId: string) => unknown;
|
||||
};
|
||||
|
||||
function getStoreDefaultMode(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): SegmentationMode {
|
||||
const mode =
|
||||
override?.defaultMode ??
|
||||
(customizationService?.getCustomization('segmentation.store.defaultMode') as
|
||||
| SegmentationMode
|
||||
| undefined) ??
|
||||
DEFAULT_SEG_STORE_MODE;
|
||||
|
||||
return mode === 'bitmap' ? 'bitmap' : 'labelmap';
|
||||
}
|
||||
|
||||
function getStoreTransferSyntaxUID(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): string {
|
||||
return (
|
||||
override?.transferSyntaxUID ??
|
||||
(customizationService?.getCustomization(
|
||||
'segmentation.store.transferSyntaxUID'
|
||||
) as string | undefined) ??
|
||||
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the parser type for loading a DICOM SEG instance.
|
||||
* Uses the instance SOP Class UID when it is a known SEG class; otherwise falls back to store defaultMode.
|
||||
*/
|
||||
export function getSegmentationParserType(
|
||||
sopClassUID: string | undefined,
|
||||
customizationService?: SegmentationCustomizationReader
|
||||
): SegmentationMode {
|
||||
if (sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||
return 'labelmap';
|
||||
}
|
||||
|
||||
if (sopClassUID === BITMAP_SEG_SOP_CLASS_UID) {
|
||||
return 'bitmap';
|
||||
}
|
||||
|
||||
return getStoreDefaultMode(customizationService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to @cornerstonejs/adapters generateSegmentation when exporting or storing SEG.
|
||||
*
|
||||
* Defaults to **Label Map + RLE Lossless**. Customizations (or per-data-source
|
||||
* `configuration.segmentation.store`) are only needed to opt into bitmap and/or
|
||||
* uncompressed Explicit VR Little Endian.
|
||||
*/
|
||||
export function getSegmentationSaveOptions(
|
||||
customizationService?: SegmentationCustomizationReader,
|
||||
override?: SegmentationStoreOverride
|
||||
): {
|
||||
sopClassUID: string;
|
||||
transferSyntaxUID: string;
|
||||
transferSyntaxUid: string;
|
||||
} {
|
||||
const defaultMode = getStoreDefaultMode(customizationService, override);
|
||||
const sopClassUID =
|
||||
defaultMode === 'bitmap' ? BITMAP_SEG_SOP_CLASS_UID : LABELMAP_SEG_SOP_CLASS_UID;
|
||||
const transferSyntaxUID = getStoreTransferSyntaxUID(
|
||||
customizationService,
|
||||
override
|
||||
);
|
||||
|
||||
return {
|
||||
sopClassUID,
|
||||
transferSyntaxUID,
|
||||
transferSyntaxUid: transferSyntaxUID,
|
||||
};
|
||||
}
|
||||
@ -60,15 +60,26 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
// In such cases, we attempt to handle this scenario gracefully by
|
||||
// invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable,
|
||||
// (eg.: we can prompt them with an explanation and provide a link to the full study).
|
||||
|
||||
// Additional guard: If no customization handler is registered for missing
|
||||
// referenced display sets, skip SEG rendering to avoid a viewport crash.
|
||||
if (!referencedDisplaySetInstanceUID) {
|
||||
const missingReferenceDisplaySetHandler = customizationService.getCustomization(
|
||||
'missingReferenceDisplaySetHandler'
|
||||
);
|
||||
const { handled } = missingReferenceDisplaySetHandler();
|
||||
if (handled) {
|
||||
if (typeof missingReferenceDisplaySetHandler === 'function') {
|
||||
const { handled } = missingReferenceDisplaySetHandler();
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"No customization 'missingReferenceDisplaySetHandler' registered. Skipping SEG rendering."
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
@ -84,10 +95,13 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
};
|
||||
|
||||
const getCornerstoneViewport = useCallback(() => {
|
||||
// Stack uses the referenced series (data[0]); SEG is applied as an overlay (data[1]).
|
||||
// Passing only the SEG display set leaves the stack with derived labelmap imageIds,
|
||||
// which are not displayable without the underlying grayscale series.
|
||||
return (
|
||||
<OHIFCornerstoneViewport
|
||||
{...props}
|
||||
displaySets={[segDisplaySet]}
|
||||
displaySets={[referencedDisplaySet, segDisplaySet]}
|
||||
viewportOptions={{
|
||||
viewportType: viewportOptions.viewportType,
|
||||
toolGroupId: toolGroupId,
|
||||
@ -100,7 +114,14 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
|
||||
}, [
|
||||
viewportId,
|
||||
segDisplaySet,
|
||||
referencedDisplaySet,
|
||||
toolGroupId,
|
||||
props,
|
||||
viewportOptions,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segIsLoading) {
|
||||
@ -134,7 +155,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
const { unsubscribe } = segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
|
||||
evt => {
|
||||
if (evt.segDisplaySet.displaySetInstanceUID === segDisplaySet.displaySetInstanceUID) {
|
||||
if (evt.segDisplaySet?.displaySetInstanceUID === segDisplaySet?.displaySetInstanceUID) {
|
||||
setSegIsLoading(false);
|
||||
}
|
||||
|
||||
@ -214,7 +235,12 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
||||
|
||||
// This creates a custom tool group which has the lifetime of this view
|
||||
// only, and does NOT interfere with currently displayed segmentations.
|
||||
toolGroup = createSEGToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
|
||||
toolGroup = createSEGToolGroupAndAddTools({
|
||||
commandsManager,
|
||||
toolGroupService,
|
||||
customizationService,
|
||||
toolGroupId,
|
||||
});
|
||||
|
||||
return () => {
|
||||
// remove the segmentation representations if seg displayset changed
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
@ -36,9 +36,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone-dicom-sr',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone-dicom-sr',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-sr",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for an SR Cornerstone Viewport",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@ -8,9 +8,7 @@
|
||||
"main": "dist/ohif-extension-cornerstone-dicom-sr.umd.js",
|
||||
"module": "src/index.tsx",
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
"node": ">=24"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
@ -19,36 +17,38 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:cornerstone": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev",
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"dev:cornerstone": "pnpm run dev",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "pnpm run build",
|
||||
"start": "pnpm run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/extension-cornerstone": "3.11.0-beta.99",
|
||||
"@ohif/extension-measurement-tracking": "3.11.0-beta.99",
|
||||
"@ohif/ui": "3.11.0-beta.99",
|
||||
"dcmjs": "^0.42.0",
|
||||
"dicom-parser": "^1.8.9",
|
||||
"hammerjs": "^2.0.8",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1"
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/ui": "workspace:*",
|
||||
"dcmjs": "0.52.0",
|
||||
"dicom-parser": "1.8.21",
|
||||
"hammerjs": "2.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^3.30.3",
|
||||
"@cornerstonejs/core": "^3.30.3",
|
||||
"@cornerstonejs/tools": "^3.30.3",
|
||||
"classnames": "^2.3.2"
|
||||
}
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"classnames": "2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ohif-extension"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { metaData, utilities } from '@cornerstonejs/core';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
import OHIF, { DicomMetadataStore } from '@ohif/core';
|
||||
import dcmjs from 'dcmjs';
|
||||
import OHIF from '@ohif/core';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
|
||||
@ -32,12 +31,7 @@ const _generateReport = (measurementData, additionalFindingTypes, options: Optio
|
||||
additionalFindingTypes
|
||||
);
|
||||
|
||||
const report = MeasurementReport.generateReport(
|
||||
filteredToolState,
|
||||
metaData,
|
||||
utilities.worldToImageCoords,
|
||||
options
|
||||
);
|
||||
const report = MeasurementReport.generateReport(filteredToolState, metaData, options);
|
||||
|
||||
const { dataset } = report;
|
||||
|
||||
@ -47,14 +41,12 @@ const _generateReport = (measurementData, additionalFindingTypes, options: Optio
|
||||
dataset.SpecificCharacterSet = 'ISO_IR 192';
|
||||
}
|
||||
|
||||
dataset.InstanceNumber = options.InstanceNumber ?? 1;
|
||||
|
||||
return dataset;
|
||||
};
|
||||
|
||||
const commandsModule = (props: withAppTypes) => {
|
||||
const { servicesManager, extensionManager, commandsManager } = props;
|
||||
const { customizationService, viewportGridService, displaySetService } = servicesManager.services;
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const actions = {
|
||||
changeColorMeasurement: ({ uid }) => {
|
||||
@ -80,25 +72,8 @@ const commandsModule = (props: withAppTypes) => {
|
||||
/**
|
||||
*
|
||||
* @param measurementData An array of measurements from the measurements service
|
||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* as opposed to Finding Sites.
|
||||
* that you wish to serialize.
|
||||
*/
|
||||
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
|
||||
const srDataset = _generateReport(measurementData, additionalFindingTypes, options);
|
||||
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
|
||||
|
||||
//Create a URL for the binary.
|
||||
const objectUrl = URL.createObjectURL(reportBlob);
|
||||
window.location.assign(objectUrl);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param measurementData An array of measurements from the measurements service
|
||||
* that you wish to serialize.
|
||||
* @param dataSource The dataSource that you wish to use to persist the data.
|
||||
* @param dataSource The data source name ('download', 'copyToClipboard', or a named data source).
|
||||
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* @return The naturalized report
|
||||
@ -109,44 +84,44 @@ const commandsModule = (props: withAppTypes) => {
|
||||
additionalFindingTypes,
|
||||
options = {},
|
||||
}) => {
|
||||
// Use the @cornerstonejs adapter for converting to/from DICOM
|
||||
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
||||
log.info('[DICOMSR] storeMeasurements');
|
||||
|
||||
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
|
||||
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
|
||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||
dataSource,
|
||||
defaultFileName: 'dicom-sr.dcm',
|
||||
});
|
||||
|
||||
if (!storeFn) {
|
||||
log.error('[DICOMSR] No valid store for dataSource:', dataSource);
|
||||
return Promise.reject({});
|
||||
}
|
||||
|
||||
try {
|
||||
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
|
||||
const naturalizedReport = _generateReport(
|
||||
measurementData,
|
||||
additionalFindingTypes,
|
||||
options
|
||||
);
|
||||
|
||||
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
|
||||
const { ContentSequence } = naturalizedReport;
|
||||
// The content sequence has 5 or more elements, of which
|
||||
// the `[4]` element contains the annotation data, so this is
|
||||
// checking that there is some annotation data present.
|
||||
if (!ContentSequence?.[4].ContentSequence?.length) {
|
||||
if (!ContentSequence?.[4]?.ContentSequence?.length) {
|
||||
console.log('naturalizedReport missing imaging content', naturalizedReport);
|
||||
throw new Error('Invalid report, no content');
|
||||
}
|
||||
if (!naturalizedReport.SOPClassUID) {
|
||||
throw new Error('No sop class uid');
|
||||
}
|
||||
|
||||
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
|
||||
|
||||
let dicomDict;
|
||||
if (typeof onBeforeDicomStore === 'function') {
|
||||
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
|
||||
}
|
||||
|
||||
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
|
||||
|
||||
if (StudyInstanceUID) {
|
||||
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
||||
}
|
||||
|
||||
// The "Mode" route listens for DicomMetadataStore changes
|
||||
// When a new instance is added, it listens and
|
||||
// automatically calls makeDisplaySets
|
||||
DicomMetadataStore.addInstances([naturalizedReport], true);
|
||||
await storeFn(naturalizedReport, { measurementData, dicomDict });
|
||||
|
||||
return naturalizedReport;
|
||||
} catch (error) {
|
||||
@ -169,7 +144,6 @@ const commandsModule = (props: withAppTypes) => {
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
downloadReport: actions.downloadReport,
|
||||
storeMeasurements: actions.storeMeasurements,
|
||||
hydrateStructuredReport: actions.hydrateStructuredReport,
|
||||
};
|
||||
|
||||
@ -63,18 +63,12 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
|
||||
|
||||
const updateViewport = useCallback(
|
||||
newMeasurementSelected => {
|
||||
const { StudyInstanceUID, displaySetInstanceUID, sopClassUids } = srDisplaySet;
|
||||
const { StudyInstanceUID, displaySetInstanceUID } = srDisplaySet;
|
||||
|
||||
if (!StudyInstanceUID || !displaySetInstanceUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sopClassUids && sopClassUids.length > 1) {
|
||||
// Todo: what happens if there are multiple SOP Classes? Why we are
|
||||
// not throwing an error?
|
||||
console.warn('More than one SOPClassUID in the same series is not yet supported.');
|
||||
}
|
||||
|
||||
_getViewportReferencedDisplaySetData(
|
||||
srDisplaySet,
|
||||
newMeasurementSelected,
|
||||
@ -92,7 +86,7 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
|
||||
const { presentationIds } = viewportOptions;
|
||||
const measurement = srDisplaySet.measurements[newMeasurementSelected];
|
||||
setPositionPresentation(presentationIds.positionPresentationId, {
|
||||
viewReference: {
|
||||
viewReference: measurement.viewReference || {
|
||||
referencedImageId: measurement.imageId,
|
||||
},
|
||||
});
|
||||
@ -258,6 +252,9 @@ async function _getViewportReferencedDisplaySetData(
|
||||
}
|
||||
|
||||
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
if (!referencedDisplaySet?.images) {
|
||||
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
|
||||
}
|
||||
|
||||
const image0 = referencedDisplaySet.images[0];
|
||||
const referencedDisplaySetMetadata = {
|
||||
|
||||
@ -9,7 +9,7 @@ function OHIFCornerstoneSRTextViewport(props: withAppTypes) {
|
||||
const instance = displaySet.instances[0];
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-auto p-4 text-white">
|
||||
<div className="text-foreground relative flex h-full w-full flex-col overflow-auto p-4">
|
||||
<div>
|
||||
{/* The root level is always a container */}
|
||||
<OHIFCornerstoneSRContainer container={instance} />
|
||||
|
||||
@ -15,10 +15,14 @@ import { CodeNameCodeSequenceValues, CodingSchemeDesignators } from './enums';
|
||||
|
||||
const { sopClassDictionary } = utils;
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
const { ImageSet, MetadataProvider: metadataProvider } = classes;
|
||||
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
|
||||
const { MetadataProvider: metadataProvider } = classes;
|
||||
const {
|
||||
TEXT_ANNOTATION_POSITION,
|
||||
COMMENT_CODE,
|
||||
CodeScheme: Cornerstone3DCodeScheme,
|
||||
} = adaptersSR.Cornerstone3D;
|
||||
|
||||
type InstanceMetadata = Types.InstanceMetadata;
|
||||
type InstanceMetadata = OhifTypes.InstanceMetadata;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
@ -31,6 +35,7 @@ const sopClassUids = [
|
||||
sopClassDictionary.BasicTextSR,
|
||||
sopClassDictionary.EnhancedSR,
|
||||
sopClassDictionary.ComprehensiveSR,
|
||||
sopClassDictionary.Comprehensive3DSR,
|
||||
];
|
||||
|
||||
const validateSameStudyUID = (uid: string, instances): void => {
|
||||
@ -49,7 +54,7 @@ const validateSameStudyUID = (uid: string, instances): void => {
|
||||
* @param instances is a list of instances from THIS series that are not
|
||||
* in this DICOM SR Display Set already.
|
||||
*/
|
||||
function addInstances(instances: InstanceMetadata[], displaySetService: DisplaySetService) {
|
||||
function addInstances(instances: InstanceMetadata[], _displaySetService: DisplaySetService) {
|
||||
this.instances.push(...instances);
|
||||
utils.sortStudyInstances(this.instances);
|
||||
// The last instance is the newest one, so is the one most interesting.
|
||||
@ -64,9 +69,10 @@ function addInstances(instances: InstanceMetadata[], displaySetService: DisplayS
|
||||
* DICOM SR SOP Class Handler
|
||||
* For all referenced images in the TID 1500/300 sections, add an image to the
|
||||
* display.
|
||||
* @param instances is a set of instances all from the same series
|
||||
* @param servicesManager is the services that can be used for creating
|
||||
* @returns The list of display sets created for the given instances object
|
||||
* @param {InstanceMetadata[]} instances - A set of instances all from the same series
|
||||
* @param {AppTypes.ServicesManager} servicesManager - The services that can be used for creating
|
||||
* @param {AppTypes.ExtensionManager} extensionManager - The extension manager
|
||||
* @returns {Types.DisplaySet[]} The list of display sets created for the given instances object
|
||||
*/
|
||||
function _getDisplaySetsFromSeries(
|
||||
instances,
|
||||
@ -94,6 +100,7 @@ function _getDisplaySetsFromSeries(
|
||||
SeriesTime,
|
||||
ConceptNameCodeSequence,
|
||||
SOPClassUID,
|
||||
imageId: predecessorImageId,
|
||||
} = instance;
|
||||
validateSameStudyUID(instance.StudyInstanceUID, instances);
|
||||
|
||||
@ -122,6 +129,7 @@ function _getDisplaySetsFromSeries(
|
||||
isImagingMeasurementReport,
|
||||
sopClassUids,
|
||||
instance,
|
||||
predecessorImageId,
|
||||
addInstances,
|
||||
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('SR')}`,
|
||||
};
|
||||
@ -138,7 +146,7 @@ function _getDisplaySetsFromSeries(
|
||||
* @param extensionManager - The extension manager containing data sources.
|
||||
*/
|
||||
async function _load(
|
||||
srDisplaySet: Types.DisplaySet,
|
||||
srDisplaySet: OhifTypes.DisplaySet,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
extensionManager: AppTypes.ExtensionManager
|
||||
) {
|
||||
@ -178,6 +186,10 @@ async function _load(
|
||||
srDisplaySet.referencedImages = [];
|
||||
srDisplaySet.measurements = [];
|
||||
}
|
||||
const { predecessorImageId } = srDisplaySet;
|
||||
for (const measurement of srDisplaySet.measurements) {
|
||||
measurement.predecessorImageId = predecessorImageId;
|
||||
}
|
||||
|
||||
const mappings = measurementService.getSourceMappings(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
@ -188,8 +200,13 @@ async function _load(
|
||||
srDisplaySet.isRehydratable = isRehydratable(srDisplaySet, mappings);
|
||||
srDisplaySet.isLoaded = true;
|
||||
|
||||
/** Check currently added displaySets and add measurements if the sources exist */
|
||||
displaySetService.activeDisplaySets.forEach(activeDisplaySet => {
|
||||
/** Check currently added displaySets and add measurements if the sources exist.
|
||||
* Walk the SR's study first in default series order (not load order) so SCOORD3D
|
||||
* FrameOfReference matching picks a stable series when several share FOR. */
|
||||
const displaySetsForSRPass = utils.sortDisplaySetsCopy(displaySetService.activeDisplaySets, {
|
||||
studyInstanceUIDFirst: srDisplaySet.StudyInstanceUID,
|
||||
});
|
||||
displaySetsForSRPass.forEach(activeDisplaySet => {
|
||||
_checkIfCanAddMeasurementsToDisplaySet(
|
||||
srDisplaySet,
|
||||
activeDisplaySet,
|
||||
@ -216,17 +233,15 @@ async function _load(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if measurements can be added to a display set.
|
||||
*
|
||||
* @param srDisplaySet - The source display set containing measurements.
|
||||
* @param newDisplaySet - The new display set to check if measurements can be added.
|
||||
* @param dataSource - The data source used to retrieve image IDs.
|
||||
* @param servicesManager - The services manager.
|
||||
*/
|
||||
function _measurementBelongsToDisplaySet({ measurement, displaySet }) {
|
||||
return (
|
||||
measurement.coords[0].ReferencedFrameOfReferenceSequence === displaySet.FrameOfReferenceUID
|
||||
);
|
||||
}
|
||||
|
||||
function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
srDisplaySet,
|
||||
newDisplaySet,
|
||||
srDisplaySet: OhifTypes.DisplaySet,
|
||||
newDisplaySet: OhifTypes.DisplaySet,
|
||||
dataSource,
|
||||
servicesManager: AppTypes.ServicesManager
|
||||
) {
|
||||
@ -236,18 +251,10 @@ function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
measurement => measurement.loaded === false
|
||||
);
|
||||
|
||||
if (
|
||||
unloadedMeasurements.length === 0 ||
|
||||
!(newDisplaySet instanceof ImageSet) ||
|
||||
newDisplaySet.unsupported
|
||||
) {
|
||||
if (!unloadedMeasurements.length || newDisplaySet.unsupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// const { sopClassUids } = newDisplaySet;
|
||||
// Create a Set for faster lookups
|
||||
// const sopClassUidSet = new Set(sopClassUids);
|
||||
|
||||
// Create a Map to efficiently look up ImageIds by SOPInstanceUID and frame number
|
||||
const imageIdMap = new Map<string, string>();
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet);
|
||||
@ -266,6 +273,7 @@ function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
|
||||
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
|
||||
let measurement = unloadedMeasurements[j];
|
||||
const is3DMeasurement = measurement.coords?.[0]?.ValueType === 'SCOORD3D';
|
||||
|
||||
const onBeforeSRAddMeasurement = customizationService.getCustomization(
|
||||
'onBeforeSRAddMeasurement'
|
||||
@ -280,9 +288,15 @@ function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
}
|
||||
|
||||
// if it is 3d SR we can just add the SR annotation
|
||||
if (is3DSR) {
|
||||
addSRAnnotation(measurement, null, null);
|
||||
if (
|
||||
is3DSR &&
|
||||
is3DMeasurement &&
|
||||
_measurementBelongsToDisplaySet({ measurement, displaySet: newDisplaySet })
|
||||
) {
|
||||
addSRAnnotation({ measurement, displaySet: newDisplaySet });
|
||||
measurement.loaded = true;
|
||||
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
|
||||
unloadedMeasurements.splice(j, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -300,15 +314,12 @@ function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
imageId &&
|
||||
_measurementReferencesSOPInstanceUID(measurement, ReferencedSOPInstanceUID, frame)
|
||||
) {
|
||||
addSRAnnotation(measurement, imageId, frame);
|
||||
|
||||
// Update measurement properties
|
||||
addSRAnnotation({ measurement, imageId, frameNumber: frame, displaySet: newDisplaySet });
|
||||
measurement.loaded = true;
|
||||
measurement.imageId = imageId;
|
||||
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
|
||||
measurement.ReferencedSOPInstanceUID = ReferencedSOPInstanceUID;
|
||||
measurement.frameNumber = frame;
|
||||
|
||||
unloadedMeasurements.splice(j, 1);
|
||||
}
|
||||
}
|
||||
@ -316,10 +327,10 @@ function _checkIfCanAddMeasurementsToDisplaySet(
|
||||
|
||||
/**
|
||||
* Checks if a measurement references a specific SOP Instance UID.
|
||||
* @param measurement - The measurement object.
|
||||
* @param SOPInstanceUID - The SOP Instance UID to check against.
|
||||
* @param frameNumber - The frame number to check against (optional).
|
||||
* @returns True if the measurement references the specified SOP Instance UID, false otherwise.
|
||||
* @param {any} measurement - The measurement object.
|
||||
* @param {string} sopInstanceUID - The SOP Instance UID to check against.
|
||||
* @param {number} frameNumber - The frame number to check against (optional).
|
||||
* @returns {boolean} True if the measurement references the specified SOP Instance UID, false otherwise.
|
||||
*/
|
||||
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frameNumber) {
|
||||
const { coords } = measurement;
|
||||
@ -351,10 +362,8 @@ function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frame
|
||||
/**
|
||||
* Retrieves the SOP class handler module.
|
||||
*
|
||||
* @param {Object} options - The options for retrieving the SOP class handler module.
|
||||
* @param {Object} options.servicesManager - The services manager.
|
||||
* @param {Object} options.extensionManager - The extension manager.
|
||||
* @returns {Array} An array containing the SOP class handler module.
|
||||
* @param {OhifTypes.Extensions.ExtensionParams} params - The extension parameters.
|
||||
* @returns {Array} An array containing the SOP class handler modules.
|
||||
*/
|
||||
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
|
||||
const { servicesManager, extensionManager } = params;
|
||||
@ -378,8 +387,8 @@ function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams)
|
||||
/**
|
||||
* Retrieves the measurements from the ImagingMeasurementReportContentSequence.
|
||||
*
|
||||
* @param {Array} ImagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
|
||||
* @returns {Array} - The array of measurements.
|
||||
* @param {any[]} imagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
|
||||
* @returns {any[]} The array of measurements.
|
||||
*/
|
||||
function _getMeasurements(ImagingMeasurementReportContentSequence) {
|
||||
const ImagingMeasurements = ImagingMeasurementReportContentSequence.find(
|
||||
@ -417,8 +426,8 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
|
||||
/**
|
||||
* Retrieves merged content sequences by tracking unique identifiers.
|
||||
*
|
||||
* @param {Array} MeasurementGroups - The measurement groups.
|
||||
* @returns {Object} - The merged content sequences by tracking unique identifiers.
|
||||
* @param {any[]} measurementGroups - The measurement groups.
|
||||
* @returns {Object} The merged content sequences by tracking unique identifiers.
|
||||
*/
|
||||
function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups) {
|
||||
const mergedContentSequencesByTrackingUniqueIdentifiers = {};
|
||||
@ -465,15 +474,11 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups
|
||||
* it calls the _processTID1410Measurement function.
|
||||
* Otherwise, it calls the _processNonGeometricallyDefinedMeasurement function.
|
||||
*
|
||||
* @param {Array<Object>} mergedContentSequence - The merged content sequence to process.
|
||||
* @returns {any} - The processed measurement result.
|
||||
* @param {any[]} mergedContentSequence - The merged content sequence to process.
|
||||
* @returns {any} The processed measurement result.
|
||||
*/
|
||||
function _processMeasurement(mergedContentSequence) {
|
||||
if (
|
||||
mergedContentSequence.some(
|
||||
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
|
||||
)
|
||||
) {
|
||||
if (mergedContentSequence.some(group => isScoordOr3d(group) && !isTextPosition(group))) {
|
||||
return _processTID1410Measurement(mergedContentSequence);
|
||||
}
|
||||
|
||||
@ -485,8 +490,8 @@ function _processMeasurement(mergedContentSequence) {
|
||||
* TID 1410 style measurements have a SCOORD or SCOORD3D at the top level,
|
||||
* and non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D.
|
||||
*
|
||||
* @param mergedContentSequence - The merged content sequence containing the measurements.
|
||||
* @returns The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
|
||||
* @param {any[]} mergedContentSequence - The merged content sequence containing the measurements.
|
||||
* @returns {any} The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
|
||||
*/
|
||||
function _processTID1410Measurement(mergedContentSequence) {
|
||||
// Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level,
|
||||
@ -511,12 +516,25 @@ function _processTID1410Measurement(mergedContentSequence) {
|
||||
|
||||
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
|
||||
|
||||
const { ConceptNameCodeSequence: conceptNameItem } = graphicItem;
|
||||
const { CodeValue: graphicValue, CodingSchemeDesignator: graphicDesignator } = conceptNameItem;
|
||||
const graphicCode = `${graphicDesignator}:${graphicValue}`;
|
||||
|
||||
const pointDataItem = _getCoordsFromSCOORDOrSCOORD3D(graphicItem);
|
||||
const is3DMeasurement = pointDataItem.ValueType === 'SCOORD3D';
|
||||
const pointLength = is3DMeasurement ? 3 : 2;
|
||||
const pointsLength = pointDataItem.GraphicData.length / pointLength;
|
||||
|
||||
const measurement = {
|
||||
loaded: false,
|
||||
labels: [],
|
||||
coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)],
|
||||
coords: [pointDataItem],
|
||||
TrackingUniqueIdentifier: UIDREFContentItem.UID,
|
||||
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
|
||||
graphicCode,
|
||||
is3DMeasurement,
|
||||
pointsLength,
|
||||
graphicType: pointDataItem.GraphicType,
|
||||
};
|
||||
|
||||
NUMContentItems.forEach(item => {
|
||||
@ -546,8 +564,8 @@ function _processTID1410Measurement(mergedContentSequence) {
|
||||
/**
|
||||
* Processes the non-geometrically defined measurement from the merged content sequence.
|
||||
*
|
||||
* @param mergedContentSequence The merged content sequence containing the measurement data.
|
||||
* @returns The processed measurement object.
|
||||
* @param {any[]} mergedContentSequence The merged content sequence containing the measurement data.
|
||||
* @returns {any} The processed measurement object.
|
||||
*/
|
||||
function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
|
||||
@ -567,6 +585,12 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSite
|
||||
);
|
||||
|
||||
const commentSites = mergedContentSequence.filter(
|
||||
item =>
|
||||
item.ConceptNameCodeSequence.CodingSchemeDesignator === COMMENT_CODE.schemeDesignator &&
|
||||
item.ConceptNameCodeSequence.CodeValue === COMMENT_CODE.value
|
||||
);
|
||||
|
||||
const measurement = {
|
||||
loaded: false,
|
||||
labels: [],
|
||||
@ -575,6 +599,14 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
|
||||
};
|
||||
|
||||
if (commentSites) {
|
||||
for (const group of commentSites) {
|
||||
if (group.TextValue) {
|
||||
measurement.labels.push({ label: group.TextValue, value: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
finding &&
|
||||
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
|
||||
@ -610,15 +642,39 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
NUMContentItems.forEach(item => {
|
||||
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
|
||||
|
||||
const { ValueType } = ContentSequence;
|
||||
if (!ValueType === 'SCOORD') {
|
||||
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
|
||||
return;
|
||||
}
|
||||
// Handle spatial reference ONLY if ContentSequence exists.
|
||||
// ContentSequence may be a scalar SCOORD or an array when additional named
|
||||
// SCOORDs (e.g. control points) are nested alongside the primary geometry.
|
||||
// Pick the primary geometry entry: prefer the SCOORD without a
|
||||
// ConceptNameCodeSequence (plain polyline), falling back to the first SCOORD.
|
||||
if (ContentSequence) {
|
||||
const scoordItem = Array.isArray(ContentSequence)
|
||||
? (ContentSequence.find(
|
||||
cs =>
|
||||
(cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D') &&
|
||||
!cs.ConceptNameCodeSequence
|
||||
) ?? ContentSequence.find(cs => cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D'))
|
||||
: ContentSequence;
|
||||
|
||||
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
|
||||
if (coords) {
|
||||
measurement.coords.push(coords);
|
||||
if (!scoordItem) {
|
||||
console.warn(
|
||||
'ContentSequence array contains no SCOORD or SCOORD3D entry, skipping annotation.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { ValueType } = scoordItem;
|
||||
|
||||
if (ValueType !== 'SCOORD' && ValueType !== 'SCOORD3D') {
|
||||
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = _getCoordsFromSCOORDOrSCOORD3D(scoordItem);
|
||||
|
||||
if (coords) {
|
||||
measurement.coords.push(coords);
|
||||
}
|
||||
}
|
||||
|
||||
if (MeasuredValueSequence) {
|
||||
@ -633,8 +689,8 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
|
||||
|
||||
/**
|
||||
* Extracts coordinates from a graphic item of type SCOORD or SCOORD3D.
|
||||
* @param {object} graphicItem - The graphic item containing the coordinates.
|
||||
* @returns {object} - The extracted coordinates.
|
||||
* @param {any} graphicItem - The graphic item containing the coordinates.
|
||||
* @returns {any} The extracted coordinates.
|
||||
*/
|
||||
const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
|
||||
const { ValueType, GraphicType, GraphicData } = graphicItem;
|
||||
@ -648,9 +704,9 @@ const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
|
||||
|
||||
/**
|
||||
* Retrieves the label and value from the provided ConceptNameCodeSequence and MeasuredValueSequence.
|
||||
* @param {Object} ConceptNameCodeSequence - The ConceptNameCodeSequence object.
|
||||
* @param {Object} MeasuredValueSequence - The MeasuredValueSequence object.
|
||||
* @returns {Object} - An object containing the label and value.
|
||||
* @param {any} conceptNameCodeSequence - The ConceptNameCodeSequence object.
|
||||
* @param {any} measuredValueSequence - The MeasuredValueSequence object.
|
||||
* @returns {Object} An object containing the label and value.
|
||||
* The label represents the CodeMeaning from the ConceptNameCodeSequence.
|
||||
* The value represents the formatted NumericValue and CodeValue from the MeasuredValueSequence.
|
||||
* Example: { label: 'Long Axis', value: '31.00 mm' }
|
||||
@ -669,8 +725,8 @@ function _getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredVal
|
||||
/**
|
||||
* Retrieves a list of referenced images from the Imaging Measurement Report Content Sequence.
|
||||
*
|
||||
* @param {Array} ImagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
|
||||
* @returns {Array} - The list of referenced images.
|
||||
* @param {any[]} imagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
|
||||
* @returns {any[]} The list of referenced images.
|
||||
*/
|
||||
function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
|
||||
const ImageLibrary = ImagingMeasurementReportContentSequence.find(
|
||||
@ -717,7 +773,7 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
|
||||
* Otherwise, the sequence is wrapped in an array and returned.
|
||||
*
|
||||
* @param {any} sequence - The DICOM sequence to convert.
|
||||
* @returns {any[]} - The converted array.
|
||||
* @returns {any[]} The converted array.
|
||||
*/
|
||||
function _getSequenceAsArray(sequence) {
|
||||
if (!sequence) {
|
||||
@ -726,4 +782,17 @@ function _getSequenceAsArray(sequence) {
|
||||
return Array.isArray(sequence) ? sequence : [sequence];
|
||||
}
|
||||
|
||||
function isScoordOr3d(group) {
|
||||
return group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D';
|
||||
}
|
||||
|
||||
function isTextPosition(group) {
|
||||
const concept = group.ConceptNameCodeSequence[0];
|
||||
return (
|
||||
concept &&
|
||||
concept.CodeValue === TEXT_ANNOTATION_POSITION.value &&
|
||||
concept.CodingSchemeDesignator === TEXT_ANNOTATION_POSITION.schemeDesignator
|
||||
);
|
||||
}
|
||||
|
||||
export default getSopClassHandlerModule;
|
||||
|
||||
@ -9,19 +9,13 @@ import {
|
||||
LengthTool,
|
||||
PlanarFreehandROITool,
|
||||
RectangleROITool,
|
||||
utilities as csToolsUtils,
|
||||
} from '@cornerstonejs/tools';
|
||||
import { Types, MeasurementService } from '@ohif/core';
|
||||
import { StackViewport, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
import { Types } from '@ohif/core';
|
||||
|
||||
import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool';
|
||||
import SCOORD3DPointTool from './tools/SCOORD3DPointTool';
|
||||
import SRSCOOR3DProbeMapper from './utils/SRSCOOR3DProbeMapper';
|
||||
import addToolInstance from './utils/addToolInstance';
|
||||
import toolNames from './tools/toolNames';
|
||||
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
|
||||
/**
|
||||
* @param {object} configuration
|
||||
*/
|
||||
@ -29,8 +23,6 @@ export default function init({
|
||||
configuration = {},
|
||||
servicesManager,
|
||||
}: Types.Extensions.ExtensionParams): void {
|
||||
const { measurementService, cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
addToolInstance(toolNames.DICOMSRDisplay, DICOMSRDisplayTool);
|
||||
addToolInstance(toolNames.SRLength, LengthTool);
|
||||
addToolInstance(toolNames.SRBidirectional, BidirectionalTool);
|
||||
@ -40,26 +32,10 @@ export default function init({
|
||||
addToolInstance(toolNames.SRAngle, AngleTool);
|
||||
addToolInstance(toolNames.SRPlanarFreehandROI, PlanarFreehandROITool);
|
||||
addToolInstance(toolNames.SRRectangleROI, RectangleROITool);
|
||||
addToolInstance(toolNames.SRSCOORD3DPoint, SCOORD3DPointTool);
|
||||
|
||||
// TODO - fix the SR display of Cobb Angle, as it joins the two lines
|
||||
addToolInstance(toolNames.SRCobbAngle, CobbAngleTool);
|
||||
|
||||
const csTools3DVer1MeasurementSource = measurementService.getSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
const { POINT } = measurementService.VALUE_TYPES;
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'SRSCOORD3DPoint',
|
||||
POINT,
|
||||
SRSCOOR3DProbeMapper.toAnnotation,
|
||||
SRSCOOR3DProbeMapper.toMeasurement
|
||||
);
|
||||
|
||||
// Modify annotation tools to use dashed lines on SR
|
||||
const dashedLine = {
|
||||
lineDash: '4,4',
|
||||
|
||||
@ -69,7 +69,12 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
|
||||
trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier)
|
||||
);
|
||||
|
||||
if (!viewport._actors?.size) {
|
||||
const hasActors =
|
||||
typeof viewport.getActors === 'function'
|
||||
? viewport.getActors().length > 0
|
||||
: Boolean(viewport._actors?.size);
|
||||
|
||||
if (!hasActors) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import {
|
||||
annotation,
|
||||
drawing,
|
||||
utilities,
|
||||
Types as cs3DToolsTypes,
|
||||
AnnotationDisplayTool,
|
||||
} from '@cornerstonejs/tools';
|
||||
import toolNames from './toolNames';
|
||||
import { Annotation } from '@cornerstonejs/tools/dist/types/types';
|
||||
|
||||
export default class SCOORD3DPointTool extends AnnotationDisplayTool {
|
||||
static toolName = toolNames.SRSCOORD3DPoint;
|
||||
|
||||
constructor(
|
||||
toolProps = {},
|
||||
defaultToolProps = {
|
||||
configuration: {},
|
||||
}
|
||||
) {
|
||||
super(toolProps, defaultToolProps);
|
||||
}
|
||||
|
||||
_getTextBoxLinesFromLabels(labels) {
|
||||
// TODO -> max 5 for now (label + shortAxis + longAxis), need a generic solution for this!
|
||||
|
||||
const labelLength = Math.min(labels.length, 5);
|
||||
const lines = [];
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// This tool should not inherit from AnnotationTool and we should not need
|
||||
// to add the following lines.
|
||||
isPointNearTool = () => null;
|
||||
getHandleNearImagePoint = () => null;
|
||||
|
||||
renderAnnotation = (enabledElement: Types.IEnabledElement, svgDrawingHelper: any): void => {
|
||||
const { viewport } = enabledElement;
|
||||
const { element } = viewport;
|
||||
|
||||
const annotations = annotation.state.getAnnotations(this.getToolName(), element);
|
||||
|
||||
// Todo: We don't need this anymore, filtering happens in triggerAnnotationRender
|
||||
if (!annotations?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter toolData to only render the data for the active SR.
|
||||
const filteredAnnotations = annotations;
|
||||
if (!viewport._actors?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleSpecifier: cs3DToolsTypes.AnnotationStyle.StyleSpecifier = {
|
||||
toolGroupId: this.toolGroupId,
|
||||
toolName: this.getToolName(),
|
||||
viewportId: enabledElement.viewport.id,
|
||||
};
|
||||
|
||||
for (let i = 0; i < filteredAnnotations.length; i++) {
|
||||
const annotation = filteredAnnotations[i];
|
||||
|
||||
const annotationUID = annotation.annotationUID;
|
||||
const { renderableData } = annotation.data;
|
||||
const { POINT: points } = renderableData;
|
||||
|
||||
styleSpecifier.annotationUID = annotationUID;
|
||||
|
||||
const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotation);
|
||||
const lineDash = this.getStyle('lineDash', styleSpecifier, annotation);
|
||||
const color = this.getStyle('color', styleSpecifier, annotation);
|
||||
|
||||
const options = {
|
||||
color,
|
||||
lineDash,
|
||||
lineWidth,
|
||||
};
|
||||
|
||||
const point = points[0][0];
|
||||
|
||||
// check if viewport can render it
|
||||
const viewable = viewport.isReferenceViewable(
|
||||
{ FrameOfReferenceUID: annotation.metadata.FrameOfReferenceUID, cameraFocalPoint: point },
|
||||
{ asNearbyProjection: true }
|
||||
);
|
||||
|
||||
if (!viewable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// render the point
|
||||
const arrowPointCanvas = viewport.worldToCanvas(point);
|
||||
// Todo: configure this
|
||||
const arrowEndCanvas = [arrowPointCanvas[0] + 20, arrowPointCanvas[1] + 20];
|
||||
const canvasCoordinates = [arrowPointCanvas, arrowEndCanvas];
|
||||
|
||||
drawing.drawArrow(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
'1',
|
||||
canvasCoordinates[1],
|
||||
canvasCoordinates[0],
|
||||
{
|
||||
color: options.color,
|
||||
width: options.lineWidth,
|
||||
}
|
||||
);
|
||||
|
||||
this.renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderTextBox(
|
||||
svgDrawingHelper,
|
||||
viewport,
|
||||
canvasCoordinates,
|
||||
annotation,
|
||||
styleSpecifier,
|
||||
options = {}
|
||||
) {
|
||||
if (!canvasCoordinates || !annotation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { annotationUID, data = {} } = annotation;
|
||||
const { labels } = data;
|
||||
|
||||
const textLines = [];
|
||||
|
||||
for (const label of labels) {
|
||||
// make this generic
|
||||
// fix this
|
||||
if (label.label === '363698007') {
|
||||
textLines.push(`Finding Site: ${label.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { color } = options;
|
||||
|
||||
const adaptedCanvasCoordinates = canvasCoordinates;
|
||||
// adapt coordinates if there is an adapter
|
||||
const canvasTextBoxCoords = utilities.drawing.getTextBoxCoordsCanvas(adaptedCanvasCoordinates);
|
||||
|
||||
if (!annotation.data?.handles?.textBox?.worldPosition) {
|
||||
annotation.data.handles.textBox.worldPosition = viewport.canvasToWorld(canvasTextBoxCoords);
|
||||
}
|
||||
|
||||
const textBoxPosition = viewport.worldToCanvas(annotation.data.handles.textBox.worldPosition);
|
||||
|
||||
const textBoxUID = '1';
|
||||
const textBoxOptions = this.getLinkedTextBoxStyle(styleSpecifier, annotation);
|
||||
|
||||
const boundingBox = drawing.drawLinkedTextBox(
|
||||
svgDrawingHelper,
|
||||
annotationUID,
|
||||
textBoxUID,
|
||||
textLines,
|
||||
textBoxPosition,
|
||||
canvasCoordinates,
|
||||
{},
|
||||
{
|
||||
...textBoxOptions,
|
||||
color,
|
||||
}
|
||||
);
|
||||
|
||||
const { x: left, y: top, width, height } = boundingBox;
|
||||
|
||||
annotation.data.handles.textBox.worldBoundingBox = {
|
||||
topLeft: viewport.canvasToWorld([left, top]),
|
||||
topRight: viewport.canvasToWorld([left + width, top]),
|
||||
bottomLeft: viewport.canvasToWorld([left, top + height]),
|
||||
bottomRight: viewport.canvasToWorld([left + width, top + height]),
|
||||
};
|
||||
}
|
||||
|
||||
public getLinkedTextBoxStyle(
|
||||
specifications: cs3DToolsTypes.AnnotationStyle.StyleSpecifier,
|
||||
annotation?: Annotation
|
||||
): Record<string, unknown> {
|
||||
// Todo: this function can be used to set different styles for different toolMode
|
||||
// for the textBox.
|
||||
|
||||
return {
|
||||
visibility: this.getStyle('textBoxVisibility', specifications, annotation),
|
||||
fontFamily: this.getStyle('textBoxFontFamily', specifications, annotation),
|
||||
fontSize: this.getStyle('textBoxFontSize', specifications, annotation),
|
||||
color: this.getStyle('textBoxColor', specifications, annotation),
|
||||
shadow: this.getStyle('textBoxShadow', specifications, annotation),
|
||||
background: this.getStyle('textBoxBackground', specifications, annotation),
|
||||
lineWidth: this.getStyle('textBoxLinkLineWidth', specifications, annotation),
|
||||
lineDash: this.getStyle('textBoxLinkLineDash', specifications, annotation),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ const toolNames = {
|
||||
SRCobbAngle: 'SRCobbAngle',
|
||||
SRRectangleROI: 'SRRectangleROI',
|
||||
SRPlanarFreehandROI: 'SRPlanarFreehandROI',
|
||||
SRSCOORD3DPoint: 'SRSCOORD3DPoint',
|
||||
};
|
||||
|
||||
export default toolNames;
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
const SRSCOOR3DProbe = {
|
||||
toAnnotation: measurement => {},
|
||||
|
||||
/**
|
||||
* Maps cornerstone annotation event data to measurement service format.
|
||||
*
|
||||
* @param {Object} cornerstone Cornerstone event data
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
toMeasurement: (
|
||||
csToolsEventDetail,
|
||||
displaySetService,
|
||||
CornerstoneViewportService,
|
||||
getValueTypeFromToolType,
|
||||
customizationService
|
||||
) => {
|
||||
const { annotation } = csToolsEventDetail;
|
||||
const { metadata, data, annotationUID } = annotation;
|
||||
|
||||
if (!metadata || !data) {
|
||||
console.warn('Probe tool: Missing metadata or data');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { toolName } = metadata;
|
||||
const { points } = data.handles;
|
||||
|
||||
const displayText = getDisplayText(annotation);
|
||||
return {
|
||||
uid: annotationUID,
|
||||
points,
|
||||
metadata,
|
||||
toolName: metadata.toolName,
|
||||
label: data.label,
|
||||
displayText: displayText,
|
||||
data: data.cachedStats,
|
||||
type: getValueTypeFromToolType?.(toolName) ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function getDisplayText(annotation) {
|
||||
const { data } = annotation;
|
||||
|
||||
if (!data) {
|
||||
return [''];
|
||||
}
|
||||
const { labels } = data;
|
||||
|
||||
const displayText = [];
|
||||
|
||||
for (const label of labels) {
|
||||
// make this generic
|
||||
if (label.label === '33636980076') {
|
||||
displayText.push(`Finding Site: ${label.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return displayText;
|
||||
}
|
||||
|
||||
export default SRSCOOR3DProbe;
|
||||
@ -1,11 +1,59 @@
|
||||
import { Types, annotation } from '@cornerstonejs/tools';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
|
||||
import getRenderableData from './getRenderableData';
|
||||
import toolNames from '../tools/toolNames';
|
||||
|
||||
export default function addSRAnnotation(measurement, imageId, frameNumber) {
|
||||
const { MeasurementReport } = adaptersSR.Cornerstone3D;
|
||||
|
||||
/**
|
||||
* Adds a DICOM SR (Structured Report) annotation to the annotation manager.
|
||||
* This function processes measurement data from DICOM SR and converts it into
|
||||
* a format suitable for display in the Cornerstone3D viewer.
|
||||
*
|
||||
* @param {Object} params - The parameters object
|
||||
* @param {Object} params.measurement - The DICOM SR measurement data containing coordinates, labels, and metadata
|
||||
* @param {Array} params.measurement.coords - Array of coordinate objects with GraphicType, ValueType, and other properties
|
||||
* @param {string} params.measurement.TrackingUniqueIdentifier - Unique identifier for the measurement
|
||||
* @param {string} params.measurement.TrackingIdentifier - Tracking identifier for adapter lookup
|
||||
* @param {Array} [params.measurement.labels] - Optional array of label objects
|
||||
* @param {string} [params.measurement.displayText] - Optional display text for the annotation
|
||||
* @param {Object} [params.measurement.textBox] - Optional text box configuration
|
||||
* @param {string|null} [params.imageId] - Optional image ID for the referenced image (defaults to null)
|
||||
* @param {number|null} [params.frameNumber] - Optional frame number for multi-frame images (defaults to null)
|
||||
* @param {Object} params.displaySet - The display set containing the image
|
||||
* @param {string} params.displaySet.displaySetInstanceUID - Unique identifier for the display set
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* addSRAnnotation({
|
||||
* measurement: {
|
||||
* TrackingUniqueIdentifier: '1.2.3.4.5',
|
||||
* TrackingIdentifier: 'POINT',
|
||||
* coords: [{
|
||||
* GraphicType: 'POINT',
|
||||
* ValueType: 'SCOORD',
|
||||
* // ... other coordinate properties
|
||||
* }],
|
||||
* labels: [{ value: 'Measurement Point' }],
|
||||
* displayText: 'Point measurement'
|
||||
* },
|
||||
* imageId: 'wadouri:file://path/to/image.dcm', // Optional
|
||||
* frameNumber: 0, // Optional
|
||||
* displaySet: { displaySetInstanceUID: '1.2.3.4' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export default function addSRAnnotation({ measurement, imageId = null, frameNumber = null, displaySet }) {
|
||||
/** @type {string} The tool name to use for the annotation, defaults to DICOMSRDisplay */
|
||||
let toolName = toolNames.DICOMSRDisplay;
|
||||
|
||||
/**
|
||||
* @type {Object} Renderable data organized by graphic type
|
||||
* Groups coordinate data by GraphicType for efficient rendering
|
||||
*/
|
||||
const renderableData = measurement.coords.reduce((acc, coordProps) => {
|
||||
acc[coordProps.GraphicType] = acc[coordProps.GraphicType] || [];
|
||||
acc[coordProps.GraphicType].push(getRenderableData({ ...coordProps, imageId }));
|
||||
@ -16,32 +64,58 @@ export default function addSRAnnotation(measurement, imageId, frameNumber) {
|
||||
const { ValueType: valueType, GraphicType: graphicType } = measurement.coords[0];
|
||||
const graphicTypePoints = renderableData[graphicType];
|
||||
|
||||
/** TODO: Read the tool name from the DICOM SR identification type in the future. */
|
||||
/**
|
||||
* TODO: Read the tool name from the DICOM SR identification type in the future.
|
||||
*/
|
||||
let frameOfReferenceUID = null;
|
||||
let planeRestriction = null;
|
||||
|
||||
/**
|
||||
* Store the view reference for use in initial navigation
|
||||
*/
|
||||
if (imageId) {
|
||||
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
|
||||
frameOfReferenceUID = imagePlaneModule?.frameOfReferenceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the view reference for use in initial navigation
|
||||
*/
|
||||
if (valueType === 'SCOORD3D') {
|
||||
toolName = toolNames.SRSCOORD3DPoint;
|
||||
|
||||
// get the ReferencedFrameOfReferenceUID from the measurement
|
||||
frameOfReferenceUID = measurement.coords[0].ReferencedFrameOfReferenceSequence;
|
||||
planeRestriction = {
|
||||
FrameOfReferenceUID: frameOfReferenceUID,
|
||||
point: graphicTypePoints[0][0],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the view reference for use in initial navigation
|
||||
*/
|
||||
measurement.viewReference = {
|
||||
planeRestriction,
|
||||
FrameOfReferenceUID: frameOfReferenceUID,
|
||||
referencedImageId: imageId,
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Types.Annotation} The annotation object to be added to the annotation manager
|
||||
* Contains all necessary metadata and data for rendering the DICOM SR measurement
|
||||
*/
|
||||
const SRAnnotation: Types.Annotation = {
|
||||
annotationUID: TrackingUniqueIdentifier,
|
||||
highlighted: false,
|
||||
isLocked: false,
|
||||
isPreview: toolName === toolNames.DICOMSRDisplay,
|
||||
invalidated: false,
|
||||
metadata: {
|
||||
toolName,
|
||||
planeRestriction,
|
||||
valueType,
|
||||
graphicType,
|
||||
FrameOfReferenceUID: frameOfReferenceUID,
|
||||
referencedImageId: imageId,
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
},
|
||||
data: {
|
||||
label: measurement.labels?.[0]?.value || undefined,
|
||||
@ -59,8 +133,11 @@ export default function addSRAnnotation(measurement, imageId, frameNumber) {
|
||||
};
|
||||
|
||||
/**
|
||||
* const annotationManager = annotation.annotationState.getAnnotationManager();
|
||||
* was not triggering annotation_added events.
|
||||
* Add the annotation to the annotation state manager.
|
||||
* Note: Using annotation.state.addAnnotation() instead of annotationManager.addAnnotation()
|
||||
* because the latter was not triggering annotation_added events properly.
|
||||
*
|
||||
* @param {Types.Annotation} SRAnnotation - The annotation to add
|
||||
*/
|
||||
annotation.state.addAnnotation(SRAnnotation);
|
||||
}
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import OHIF from '@ohif/core';
|
||||
import { annotation } from '@cornerstonejs/tools';
|
||||
const { log } = OHIF;
|
||||
import { NO_IMAGE_ID } from '@cornerstonejs/adapters';
|
||||
|
||||
function getFilteredCornerstoneToolState(measurementData, additionalFindingTypes) {
|
||||
const filteredToolState = {};
|
||||
|
||||
function addToFilteredToolState(annotation, toolType) {
|
||||
if (!annotation.metadata?.referencedImageId) {
|
||||
log.warn(`[DICOMSR] No referencedImageId found for ${toolType} ${annotation.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = annotation.metadata.referencedImageId;
|
||||
const imageId = annotation.metadata?.referencedImageId ?? NO_IMAGE_ID;
|
||||
|
||||
if (!filteredToolState[imageId]) {
|
||||
filteredToolState[imageId] = {};
|
||||
|
||||
@ -11,7 +11,11 @@ const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
|
||||
* @returns {string} The extracted label.
|
||||
*/
|
||||
export default function getLabelFromDCMJSImportedToolData(toolData) {
|
||||
const { findingSites = [], finding } = toolData;
|
||||
const { findingSites = [], finding, annotation } = toolData;
|
||||
|
||||
if (annotation.data.label) {
|
||||
return annotation.data.label;
|
||||
}
|
||||
|
||||
let freeTextLabel = findingSites.find(
|
||||
fs => fs.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
|
||||
|
||||
@ -36,6 +36,14 @@ function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
|
||||
ValueType,
|
||||
imageId,
|
||||
});
|
||||
|
||||
if (!imageId) {
|
||||
// without the image id it's not possible to perform the calculations below
|
||||
// these calculations also do not seem to be needed, since everything works
|
||||
// just fine when we skip them. At least for SCOORD3D annotations.
|
||||
return pointsWorld;
|
||||
}
|
||||
|
||||
// We do not have an explicit draw circle svg helper in Cornerstone3D at
|
||||
// this time, but we can use the ellipse svg helper to draw a circle, so
|
||||
// here we reshape the data for that purpose.
|
||||
@ -90,6 +98,13 @@ function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
|
||||
imageId,
|
||||
});
|
||||
|
||||
if (!imageId) {
|
||||
// without the image id it's not possible to perform the calculations below
|
||||
// these calculations also do not seem to be needed, since everything works
|
||||
// just fine when we skip them. At least for SCOORD3D annotations.
|
||||
return pointsWorld;
|
||||
}
|
||||
|
||||
const majorAxisStart = vec3.fromValues(...pointsWorld[0]);
|
||||
const majorAxisEnd = vec3.fromValues(...pointsWorld[1]);
|
||||
const minorAxisStart = vec3.fromValues(...pointsWorld[2]);
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { utilities, metaData } from '@cornerstonejs/core';
|
||||
import { utilities, metaData, type Types } from '@cornerstonejs/core';
|
||||
import OHIF, { DicomMetadataStore } from '@ohif/core';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import getLabelFromDCMJSImportedToolData from './getLabelFromDCMJSImportedToolData';
|
||||
import { adaptersSR } from '@cornerstonejs/adapters';
|
||||
import { annotation as CsAnnotation } from '@cornerstonejs/tools';
|
||||
import { annotation as CsAnnotation, type Types as ToolTypes } from '@cornerstonejs/tools';
|
||||
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
|
||||
|
||||
const { locking } = CsAnnotation;
|
||||
const { guid } = OHIF.utils;
|
||||
const { MeasurementReport, CORNERSTONE_3D_TAG } = adaptersSR.Cornerstone3D;
|
||||
const { MeasurementReport } = adaptersSR.Cornerstone3D;
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
|
||||
|
||||
const convertCode = (codingValues, code) => {
|
||||
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') {
|
||||
@ -37,8 +38,10 @@ const convertSites = (codingValues, sites) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Hydrates a structured report, for default viewports.
|
||||
*
|
||||
* Hydrates a structured report
|
||||
* Handles 2d and 3d hydration from SCOORD and SCOORD3D points
|
||||
* For 3D hydration, chooses a volume display set to display with
|
||||
* FOr 2D hydration, chooses the (first) display set containing the referenced image.
|
||||
*/
|
||||
export default function hydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, commandsManager }: withAppTypes,
|
||||
@ -51,6 +54,11 @@ export default function hydrateStructuredReport(
|
||||
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
const {
|
||||
StudyInstanceUID: studyUID,
|
||||
SeriesInstanceUID: seriesUID,
|
||||
instance: { SOPInstanceUID: sopUID },
|
||||
} = displaySet;
|
||||
|
||||
// TODO -> We should define a strict versioning somewhere.
|
||||
const mappings = measurementService.getSourceMappings(
|
||||
@ -64,31 +72,23 @@ export default function hydrateStructuredReport(
|
||||
);
|
||||
}
|
||||
|
||||
const instance = DicomMetadataStore.getInstance(
|
||||
displaySet.StudyInstanceUID,
|
||||
displaySet.SeriesInstanceUID,
|
||||
displaySet.SOPInstanceUID
|
||||
);
|
||||
const instance = DicomMetadataStore.getInstance(studyUID, seriesUID, sopUID);
|
||||
|
||||
const sopInstanceUIDToImageId = {};
|
||||
const imageIdsForToolState = {};
|
||||
|
||||
displaySet.measurements.forEach(measurement => {
|
||||
const { ReferencedSOPInstanceUID, imageId, frameNumber } = measurement;
|
||||
const { ReferencedSOPInstanceUID, imageId, frameNumber = 1 } = measurement;
|
||||
const key = `${ReferencedSOPInstanceUID}:${frameNumber}`;
|
||||
|
||||
if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) {
|
||||
sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId;
|
||||
imageIdsForToolState[ReferencedSOPInstanceUID] = [];
|
||||
}
|
||||
if (!imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber]) {
|
||||
imageIdsForToolState[ReferencedSOPInstanceUID][frameNumber] = imageId;
|
||||
if (imageId && !sopInstanceUIDToImageId[key]) {
|
||||
sopInstanceUIDToImageId[key] = imageId;
|
||||
}
|
||||
});
|
||||
|
||||
// Mapping of legacy datasets is now directly handled by adapters module
|
||||
const datasetToUse = instance;
|
||||
|
||||
// Use dcmjs to generate toolState.
|
||||
// Use CS3D adapters to generate toolState.
|
||||
let storedMeasurementByAnnotationType = MeasurementReport.generateToolState(
|
||||
datasetToUse,
|
||||
// NOTE: we need to pass in the imageIds to dcmjs since the we use them
|
||||
@ -96,7 +96,6 @@ export default function hydrateStructuredReport(
|
||||
// that measurements were added to the display set are the same order as
|
||||
// the measurementGroups in the instance.
|
||||
sopInstanceUIDToImageId,
|
||||
utilities.imageToWorldCoords,
|
||||
metaData
|
||||
);
|
||||
|
||||
@ -119,40 +118,17 @@ export default function hydrateStructuredReport(
|
||||
}
|
||||
});
|
||||
|
||||
// Set the series touched as tracked.
|
||||
const imageIds = [];
|
||||
|
||||
// TODO: notification if no hydratable?
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
|
||||
const imageId =
|
||||
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
|
||||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
|
||||
|
||||
if (!imageIds.includes(imageId)) {
|
||||
imageIds.push(imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let targetStudyInstanceUID;
|
||||
const SeriesInstanceUIDs = [];
|
||||
|
||||
for (let i = 0; i < imageIds.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
|
||||
// Set the series touched as tracked.
|
||||
const imageIds = getImageIds(hydratableMeasurementsInSR, sopInstanceUIDToImageId);
|
||||
|
||||
for (const imageId of imageIds) {
|
||||
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
|
||||
if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) {
|
||||
SeriesInstanceUIDs.push(SeriesInstanceUID);
|
||||
}
|
||||
|
||||
if (!targetStudyInstanceUID) {
|
||||
targetStudyInstanceUID = StudyInstanceUID;
|
||||
} else if (targetStudyInstanceUID !== StudyInstanceUID) {
|
||||
@ -160,38 +136,86 @@ export default function hydrateStructuredReport(
|
||||
}
|
||||
}
|
||||
|
||||
// For 3d annotations there are no image IDs,
|
||||
// so we need to find the display sets by frame of reference to get the SeriesInstanceUIDs
|
||||
const frameOfReferenceUIDs = getFrameOfReferenceUIDs(
|
||||
hydratableMeasurementsInSR,
|
||||
sopInstanceUIDToImageId
|
||||
);
|
||||
const displaySetsByFrameOfReferenceUID = new Map();
|
||||
|
||||
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
|
||||
const displaySetsFOR = displaySetService.getDisplaySetsBy(
|
||||
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
|
||||
);
|
||||
const ds = getReferencedDisplaySet(
|
||||
displaySet,
|
||||
displaySetsFOR,
|
||||
FrameOfReferenceUID,
|
||||
displaySetService
|
||||
);
|
||||
if (!ds) {
|
||||
continue;
|
||||
}
|
||||
displaySetsByFrameOfReferenceUID.set(FrameOfReferenceUID, ds);
|
||||
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
|
||||
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
|
||||
}
|
||||
if (!targetStudyInstanceUID) {
|
||||
targetStudyInstanceUID = ds.StudyInstanceUID;
|
||||
} else if (targetStudyInstanceUID !== ds.StudyInstanceUID) {
|
||||
console.warn('NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets reference data for what frame of reference and the referenced
|
||||
* image id, or for 3d measurements, the volumeId to apply this annotation to.
|
||||
*/
|
||||
function getReferenceData(toolData): ToolTypes.AnnotationMetadata {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
|
||||
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
||||
|
||||
if (!imageId) {
|
||||
return getReferenceData3D(toolData, servicesManager, displaySetsByFrameOfReferenceUID);
|
||||
}
|
||||
|
||||
const instance = metaData.get('instance', imageId);
|
||||
const {
|
||||
FrameOfReferenceUID,
|
||||
// SOPInstanceUID,
|
||||
// SeriesInstanceUID,
|
||||
// StudyInstanceUID,
|
||||
} = instance;
|
||||
|
||||
return {
|
||||
referencedImageId: imageId,
|
||||
FrameOfReferenceUID,
|
||||
};
|
||||
}
|
||||
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
|
||||
const imageId =
|
||||
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
|
||||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
|
||||
|
||||
toolData.uid = guid();
|
||||
|
||||
const instance = metaData.get('instance', imageId);
|
||||
const {
|
||||
FrameOfReferenceUID,
|
||||
// SOPInstanceUID,
|
||||
// SeriesInstanceUID,
|
||||
// StudyInstanceUID,
|
||||
} = instance;
|
||||
const referenceData = getReferenceData(toolData);
|
||||
const { referencedImageId } = referenceData;
|
||||
|
||||
const annotation = {
|
||||
annotationUID: toolData.annotation.annotationUID,
|
||||
data: toolData.annotation.data,
|
||||
predecessorImageId: toolData.predecessorImageId,
|
||||
metadata: {
|
||||
...referenceData,
|
||||
toolName: annotationType,
|
||||
referencedImageId: imageId,
|
||||
FrameOfReferenceUID,
|
||||
},
|
||||
};
|
||||
utilities.updatePlaneRestriction(annotation.data.handles.points, annotation.metadata);
|
||||
|
||||
const source = measurementService.getSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
@ -225,8 +249,8 @@ export default function hydrateStructuredReport(
|
||||
locking.setAnnotationLocked(newAnnotationUID, true);
|
||||
}
|
||||
|
||||
if (!imageIds.includes(imageId)) {
|
||||
imageIds.push(imageId);
|
||||
if (referencedImageId && !imageIds.includes(referencedImageId)) {
|
||||
imageIds.push(referencedImageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -238,3 +262,182 @@ export default function hydrateStructuredReport(
|
||||
SeriesInstanceUIDs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique imageIds from hydratable measurements that have an imageId reference
|
||||
* (i.e., 2D/SCOORD annotations).
|
||||
*/
|
||||
function getImageIds(hydratableMeasurementsInSR, sopInstanceUIDToImageId): string[] {
|
||||
const imageIds: string[] = [];
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
// Add the measurement to toolState
|
||||
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
|
||||
// files, and looking up the imageId from sopInstanceUIDToImageId results
|
||||
// in the wrong value.
|
||||
const frameNumber = toolData.annotation.data?.frameNumber || 1;
|
||||
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
||||
|
||||
if (imageId && !imageIds.includes(imageId)) {
|
||||
imageIds.push(imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
return imageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique FrameOfReferenceUIDs from hydratable measurements that have no imageId reference
|
||||
* (i.e., 3D/SCOORD3D annotations). This excludes annotations handled by the getImageIds function.
|
||||
*/
|
||||
function getFrameOfReferenceUIDs(hydratableMeasurementsInSR, sopInstanceUIDToImageId): string[] {
|
||||
const frameOfReferenceUIDs: string[] = [];
|
||||
|
||||
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
|
||||
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
|
||||
toolDataForAnnotationType.forEach(toolData => {
|
||||
const frameNumber = toolData.annotation.data?.frameNumber || 1;
|
||||
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
||||
|
||||
if (!imageId) {
|
||||
const { FrameOfReferenceUID } = toolData.annotation.metadata;
|
||||
if (FrameOfReferenceUID && !frameOfReferenceUIDs.includes(FrameOfReferenceUID)) {
|
||||
frameOfReferenceUIDs.push(FrameOfReferenceUID);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return frameOfReferenceUIDs;
|
||||
}
|
||||
|
||||
/**
|
||||
* For 3d annotations, there are often several display sets which could
|
||||
* be used to display the annotation. Choose the first annotation with the
|
||||
* same frame of reference that is reconstructable, or the first display set
|
||||
* otherwise.
|
||||
*/
|
||||
function chooseDisplaySet(displaySets, reference) {
|
||||
if (!displaySets?.length) {
|
||||
console.warn('No display set found for', reference);
|
||||
return;
|
||||
}
|
||||
const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets);
|
||||
if (sortedDisplaySets.length === 1) {
|
||||
return sortedDisplaySets[0];
|
||||
}
|
||||
const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable);
|
||||
if (volumeDs) {
|
||||
return volumeDs;
|
||||
}
|
||||
return sortedDisplaySets[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* SCOORD3D only identifies a frame of reference, so many series can be valid
|
||||
* candidates. The SR loader has already selected and recorded a stable display
|
||||
* set for each measurement. Reuse that selection during hydration so the
|
||||
* viewport series and annotation volume cannot depend on display-set load order.
|
||||
*/
|
||||
function getReferencedDisplaySet(
|
||||
srDisplaySet,
|
||||
displaySets,
|
||||
FrameOfReferenceUID,
|
||||
displaySetService
|
||||
) {
|
||||
const referencedDisplaySetInstanceUID = srDisplaySet.measurements?.find(measurement =>
|
||||
measurement.coords?.some(
|
||||
coord =>
|
||||
coord.ValueType === 'SCOORD3D' &&
|
||||
coord.ReferencedFrameOfReferenceSequence === FrameOfReferenceUID
|
||||
)
|
||||
)?.displaySetInstanceUID;
|
||||
|
||||
const referencedDisplaySet = referencedDisplaySetInstanceUID
|
||||
? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
referencedDisplaySet?.FrameOfReferenceUID === FrameOfReferenceUID &&
|
||||
!referencedDisplaySet.isDerivedDisplaySet
|
||||
) {
|
||||
return referencedDisplaySet;
|
||||
}
|
||||
|
||||
return chooseDisplaySet(displaySets, FrameOfReferenceUID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the additional reference data appropriate for a 3d reference.
|
||||
* This will choose a volume id, frame of reference and a plane restriction.
|
||||
*/
|
||||
function getReferenceData3D(
|
||||
toolData,
|
||||
servicesManager: Types.ServicesManager,
|
||||
displaySetsByFrameOfReferenceUID = new Map()
|
||||
) {
|
||||
const { FrameOfReferenceUID } = toolData.annotation.metadata;
|
||||
const { points } = toolData.annotation.data.handles;
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const displaySetsFOR = displaySetService.getDisplaySetsBy(
|
||||
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID
|
||||
);
|
||||
if (!displaySetsFOR.length || !points?.length) {
|
||||
return {
|
||||
FrameOfReferenceUID,
|
||||
};
|
||||
}
|
||||
const ds =
|
||||
displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) ||
|
||||
chooseDisplaySet(displaySetsFOR, toolData.annotation);
|
||||
const cameraView = chooseCameraView(ds, points);
|
||||
|
||||
const viewReference = {
|
||||
...cameraView,
|
||||
volumeId: ds.displaySetInstanceUID,
|
||||
FrameOfReferenceUID,
|
||||
};
|
||||
utilities.updatePlaneRestriction(points, viewReference);
|
||||
return viewReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a possible camera view - right now this is fairly basic,
|
||||
* just setting the unknowns to null.
|
||||
*/
|
||||
function chooseCameraView(_ds, points) {
|
||||
const selectedPoints = choosePoints(points);
|
||||
const cameraFocalPoint = <Point3>centerOf(selectedPoints);
|
||||
// These are sufficient to be null for now and can be set on first view
|
||||
let viewPlaneNormal: Types.Point3 = null;
|
||||
let viewUp: Types.Point3 = null;
|
||||
|
||||
return {
|
||||
cameraFocalPoint,
|
||||
viewPlaneNormal,
|
||||
viewUp,
|
||||
};
|
||||
}
|
||||
|
||||
function centerOf(points) {
|
||||
const scale = 1 / points.length;
|
||||
const center = vec3.create();
|
||||
for (const point of points) {
|
||||
vec3.scaleAndAdd(center, center, point, scale);
|
||||
}
|
||||
return center;
|
||||
}
|
||||
|
||||
function choosePoints(points) {
|
||||
if (points.length === 1 || points.length === 2) {
|
||||
return points;
|
||||
}
|
||||
const firstIndex = 0;
|
||||
const secondIndex = Math.ceil(points.length / 4);
|
||||
const thirdIndex = Math.ceil(points.length / 2);
|
||||
// TODO - check if colinear, if so try to find another 3 points.
|
||||
|
||||
const newPoints = [points[firstIndex], points[secondIndex], points[thirdIndex]];
|
||||
return newPoints;
|
||||
}
|
||||
|
||||
@ -22,13 +22,20 @@ export default function isRehydratable(displaySet, mappings) {
|
||||
const { measurements } = displaySet;
|
||||
|
||||
for (let i = 0; i < measurements.length; i++) {
|
||||
const { TrackingIdentifier } = measurements[i] || {};
|
||||
if (!TrackingIdentifier) {
|
||||
console.warn('No tracking identifier for measurement ', measurements[i]);
|
||||
const measurement = measurements[i];
|
||||
if (!measurement) {
|
||||
continue;
|
||||
}
|
||||
const { TrackingIdentifier = '', graphicType, graphicCode, pointsLength } = measurement;
|
||||
if (!TrackingIdentifier && !graphicType) {
|
||||
console.warn('No tracking identifier or graphicType for measurement ', measurement);
|
||||
continue;
|
||||
}
|
||||
const adapter = MeasurementReport.getAdapterForTrackingIdentifier(TrackingIdentifier);
|
||||
const hydratable = adapter && mappingDefinitions.has(adapter.toolType);
|
||||
const adapters = MeasurementReport.getAdaptersForTypes(graphicCode, graphicType, pointsLength);
|
||||
const hydratable =
|
||||
(adapter && mappingDefinitions.has(adapter.toolType)) ||
|
||||
(adapters && adapters.some(adapter => mappingDefinitions.has(adapter.toolType)));
|
||||
|
||||
if (hydratable) {
|
||||
return true;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
@ -35,9 +35,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dynamic-volume",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for 4D volumes data",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-extension-cornerstone-dynamic-volume.umd.js",
|
||||
"module": "src/index.ts",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
@ -19,31 +22,34 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package": "pnpm run build",
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"start": "yarn run dev",
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"start": "pnpm run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/extension-cornerstone": "3.11.0-beta.99",
|
||||
"@ohif/extension-default": "3.11.0-beta.99",
|
||||
"@ohif/i18n": "3.11.0-beta.99",
|
||||
"@ohif/ui": "3.11.0-beta.99",
|
||||
"dcmjs": "^0.42.0",
|
||||
"dicom-parser": "^1.8.21",
|
||||
"hammerjs": "^2.0.8",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1"
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/i18n": "workspace:*",
|
||||
"@ohif/ui": "workspace:*",
|
||||
"dcmjs": "0.52.0",
|
||||
"dicom-parser": "1.8.21",
|
||||
"hammerjs": "2.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/core": "^3.30.3",
|
||||
"@cornerstonejs/tools": "^3.30.3",
|
||||
"classnames": "^2.3.2"
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"classnames": "2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import * as importedActions from './actions';
|
||||
import { utilities, Enums } from '@cornerstonejs/tools';
|
||||
import { cache } from '@cornerstonejs/core';
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
const LABELMAP = Enums.SegmentationRepresentations.Labelmap;
|
||||
import * as importedActions from './actions';
|
||||
|
||||
const { downloadCsv } = utils;
|
||||
|
||||
const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
|
||||
const services = servicesManager.services;
|
||||
@ -37,7 +39,7 @@ const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
|
||||
});
|
||||
return computedDisplaySets;
|
||||
},
|
||||
exportTimeReportCSV: ({ segmentations, config, options, summaryStats }) => {
|
||||
exportTimeReportCSV: ({ segmentations, summaryStats }) => {
|
||||
const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet();
|
||||
|
||||
const volumeId = dynamic4DDisplaySet?.displaySetInstanceUID;
|
||||
@ -203,15 +205,7 @@ const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
|
||||
|
||||
// Generate filename and trigger download
|
||||
const filename = `${instance.PatientID}.csv`;
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
downloadCsv(csvContent, { filename });
|
||||
},
|
||||
swapDynamicWithComputedDisplaySet: ({ displaySet }) => {
|
||||
const computedDisplaySet = displaySet;
|
||||
|
||||
@ -22,7 +22,7 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager, co
|
||||
<>
|
||||
<Toolbox
|
||||
buttonSectionId={toolbarService.sections.dynamicToolbox}
|
||||
title="Threshold Tools"
|
||||
title="Buttons:Threshold Tools"
|
||||
/>
|
||||
<PanelSegmentation
|
||||
servicesManager={servicesManager}
|
||||
|
||||
@ -5,7 +5,7 @@ function DynamicDataPanel({ servicesManager, commandsManager, tab }: withAppType
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col text-white"
|
||||
className="text-foreground flex flex-col"
|
||||
data-cy={'dynamic-volume-panel'}
|
||||
>
|
||||
<PanelGenerateImage
|
||||
|
||||
@ -10,7 +10,7 @@ function WorkflowPanel({ servicesManager }: { servicesManager: ServicesManager }
|
||||
return (
|
||||
<div
|
||||
data-cy={'workflow-panel'}
|
||||
className="bg-secondary-dark mb-1 px-3 py-4"
|
||||
className="bg-popover mb-1 px-3 py-4"
|
||||
>
|
||||
<div className="mb-1">Workflow</div>
|
||||
<div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require('@rspack/core');
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin;
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
|
||||
@ -35,9 +35,11 @@ module.exports = (env, argv) => {
|
||||
sideEffects: true,
|
||||
},
|
||||
output: {
|
||||
library: {
|
||||
name: 'ohif-extension-cornerstone',
|
||||
type: 'umd',
|
||||
},
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-extension-cornerstone',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,11 @@
|
||||
const base = require('../../jest.config.base.js');
|
||||
const pkg = require('./package');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
displayName: pkg.name,
|
||||
moduleNameMapper: {
|
||||
...base.moduleNameMapper,
|
||||
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
|
||||
},
|
||||
// rootDir: "../.."
|
||||
// testMatch: [
|
||||
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone",
|
||||
"version": "3.11.0-beta.99",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for Cornerstone",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@ -13,9 +13,7 @@
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
"node": ">=24"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
@ -26,46 +24,54 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
|
||||
"dev:cornerstone": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --progress --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "yarn run build",
|
||||
"start": "yarn run dev"
|
||||
"clean:deep": "pnpm run clean && shx rm -rf node_modules",
|
||||
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch",
|
||||
"dev:cornerstone": "pnpm run dev",
|
||||
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js",
|
||||
"build:package-1": "pnpm run build",
|
||||
"start": "pnpm run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cornerstonejs/codec-charls": "^1.2.3",
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.4",
|
||||
"@cornerstonejs/codec-openjph": "^2.4.5",
|
||||
"@cornerstonejs/dicom-image-loader": "^3.30.3",
|
||||
"@ohif/core": "3.11.0-beta.99",
|
||||
"@ohif/ui": "3.11.0-beta.99",
|
||||
"dcmjs": "^0.42.0",
|
||||
"dicom-parser": "^1.8.21",
|
||||
"hammerjs": "^2.0.8",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-resize-detector": "^10.0.1"
|
||||
"@cornerstonejs/codec-charls": "1.2.3",
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "1.3.0",
|
||||
"@cornerstonejs/codec-openjph": "2.4.7",
|
||||
"@cornerstonejs/dicom-image-loader": "5.4.17",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/ui": "workspace:*",
|
||||
"dcmjs": "0.52.0",
|
||||
"dicom-parser": "1.8.21",
|
||||
"hammerjs": "2.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-resize-detector": "10.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@cornerstonejs/adapters": "^3.30.3",
|
||||
"@cornerstonejs/ai": "^3.30.3",
|
||||
"@cornerstonejs/core": "^3.30.3",
|
||||
"@cornerstonejs/labelmap-interpolation": "^3.30.3",
|
||||
"@cornerstonejs/polymorphic-segmentation": "^3.30.3",
|
||||
"@cornerstonejs/tools": "^3.30.3",
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/ai": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/labelmap-interpolation": "5.4.17",
|
||||
"@cornerstonejs/metadata": "5.4.17",
|
||||
"@cornerstonejs/polymorphic-segmentation": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"@icr/polyseg-wasm": "0.4.0",
|
||||
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
|
||||
"@kitware/vtk.js": "32.12.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lodash.compact": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.zip": "^4.2.0",
|
||||
"shader-loader": "^1.3.1",
|
||||
"worker-loader": "^3.0.8"
|
||||
"@kitware/vtk.js": "35.5.3",
|
||||
"html2canvas": "1.4.1",
|
||||
"immutability-helper": "3.1.1",
|
||||
"lodash.compact": "3.0.1",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.flatten": "4.4.0",
|
||||
"lodash.zip": "4.2.0",
|
||||
"shader-loader": "1.3.1",
|
||||
"worker-loader": "3.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ import CinePlayer from '../components/CinePlayer';
|
||||
import type { Types } from '@ohif/core';
|
||||
|
||||
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
|
||||
import ViewportColorbarsContainer from '../components/ViewportColorbar';
|
||||
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
|
||||
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
|
||||
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
|
||||
@ -305,31 +304,6 @@ const OHIFCornerstoneViewport = React.memo(
|
||||
loadViewportData();
|
||||
}, [viewportOptions, displaySets, dataSource]);
|
||||
|
||||
/**
|
||||
* There are two scenarios for jump to click
|
||||
* 1. Current viewports contain the displaySet that the annotation was drawn on
|
||||
* 2. Current viewports don't contain the displaySet that the annotation was drawn on
|
||||
* and we need to change the viewports displaySet for jumping.
|
||||
* Since measurement_jump happens via events and listeners, the former case is handled
|
||||
* by the measurement_jump direct callback, but the latter case is handled first by
|
||||
* the viewportGrid to set the correct displaySet on the viewport, AND THEN we check
|
||||
* the cache for jumping to see if there is any jump queued, then we jump to the correct slice.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isJumpToMeasurementDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
|
||||
event => handleJumpToMeasurement(event, elementRef, viewportId, cornerstoneViewportService)
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
|
||||
|
||||
const Notification = customizationService.getCustomization('ui.notificationComponent');
|
||||
|
||||
return (
|
||||
@ -387,54 +361,6 @@ const OHIFCornerstoneViewport = React.memo(
|
||||
areEqual
|
||||
);
|
||||
|
||||
// Helper function to handle jumping to measurements
|
||||
function handleJumpToMeasurement(event, elementRef, viewportId, cornerstoneViewportService) {
|
||||
const { measurement, isConsumed } = event;
|
||||
if (!measurement || isConsumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledElement = getEnabledElement(elementRef.current);
|
||||
|
||||
if (!enabledElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
|
||||
|
||||
const { metadata, displaySetInstanceUID } = measurement;
|
||||
|
||||
const viewportDisplaySets = cornerstoneViewportService.getViewportDisplaySets(viewportId);
|
||||
|
||||
const showingDisplaySet = viewportDisplaySets.find(
|
||||
ds => ds.displaySetInstanceUID === displaySetInstanceUID
|
||||
);
|
||||
|
||||
let metadataToUse = metadata;
|
||||
// if it is not showing the displaySet we need to remove the FOR from the metadata
|
||||
if (!showingDisplaySet) {
|
||||
metadataToUse = {
|
||||
...metadata,
|
||||
FrameOfReferenceUID: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Todo: make it work with cases where we want to define FOR based measurements too
|
||||
if (!viewport.isReferenceViewable(metadataToUse, WITH_NAVIGATION)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
viewport.setViewReference(metadata);
|
||||
viewport.render();
|
||||
} catch (e) {
|
||||
console.warn('Unable to apply', metadata, e);
|
||||
}
|
||||
|
||||
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
|
||||
event?.consume?.();
|
||||
}
|
||||
|
||||
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
|
||||
const { synchronizersStore } = useSynchronizersStore.getState();
|
||||
const synchronizers = synchronizersStore[viewportId];
|
||||
@ -558,16 +484,4 @@ function areEqual(prevProps, nextProps) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to check if display sets have changed
|
||||
function haveDisplaySetsChanged(prevDisplaySets, currentDisplaySets) {
|
||||
if (prevDisplaySets.length !== currentDisplaySets.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentDisplaySets.some((currentDS, index) => {
|
||||
const prevDS = prevDisplaySets[index];
|
||||
return currentDS.displaySetInstanceUID !== prevDS.displaySetInstanceUID;
|
||||
});
|
||||
}
|
||||
|
||||
export default OHIFCornerstoneViewport;
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ViewportImageScrollbar from './ViewportImageScrollbar';
|
||||
import ViewportSliceProgressScrollbar from './ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar';
|
||||
import CustomizableViewportOverlay from './CustomizableViewportOverlay';
|
||||
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
|
||||
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
|
||||
|
||||
function CornerstoneOverlays(props: withAppTypes) {
|
||||
const { viewportId, element, scrollbarHeight, servicesManager } = props;
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const { cornerstoneViewportService, customizationService } = servicesManager.services;
|
||||
const [imageSliceData, setImageSliceData] = useState({
|
||||
imageIndex: 0,
|
||||
numberOfSlices: 0,
|
||||
@ -43,17 +44,31 @@ function CornerstoneOverlays(props: withAppTypes) {
|
||||
}
|
||||
}
|
||||
|
||||
const viewportScrollbarVariant = customizationService.getCustomization('viewportScrollbar.variant');
|
||||
const useProgressScrollbar = viewportScrollbarVariant !== 'legacy';
|
||||
|
||||
return (
|
||||
<div className="noselect">
|
||||
<ViewportImageScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
{useProgressScrollbar ? (
|
||||
<ViewportSliceProgressScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
) : (
|
||||
<ViewportImageScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomizableViewportOverlay
|
||||
imageSliceData={imageSliceData}
|
||||
|
||||
@ -1,20 +1,22 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import PropTypes from 'prop-types';
|
||||
import { metaData, Enums, utilities, eventTarget } from '@cornerstonejs/core';
|
||||
import { metaData, Enums, eventTarget } from '@cornerstonejs/core';
|
||||
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
|
||||
import type { ImageSliceData } from '@cornerstonejs/core/types';
|
||||
import { ViewportOverlay } from '@ohif/ui-next';
|
||||
import { ViewportOverlay, formatDICOMDate } from '@ohif/ui-next';
|
||||
import type { InstanceMetadata } from '@ohif/core/src/types';
|
||||
import { formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
|
||||
import { formatDICOMTime, formatNumberPrecision } from './utils';
|
||||
import { utils } from '@ohif/core';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
|
||||
|
||||
import './CustomizableViewportOverlay.css';
|
||||
import { useViewportRendering } from '../../hooks';
|
||||
|
||||
const EPSILON = 1e-4;
|
||||
const { formatPN } = utils;
|
||||
const { formatPN, formatValue } = utils;
|
||||
|
||||
type ViewportData = StackViewportData | VolumeViewportData;
|
||||
|
||||
@ -67,10 +69,9 @@ function CustomizableViewportOverlay({
|
||||
}) {
|
||||
const { cornerstoneViewportService, customizationService, toolGroupService, displaySetService } =
|
||||
servicesManager.services;
|
||||
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
|
||||
const [scale, setScale] = useState(1);
|
||||
const [annotationState, setAnnotationState] = useState(0);
|
||||
const { isViewportBackgroundLight: isLight } = useViewportRendering(viewportId);
|
||||
const { isViewportBackgroundLight: isLight, windowLevel: voi } = useViewportRendering(viewportId);
|
||||
const { imageIndex } = imageSliceData;
|
||||
|
||||
// Historical usage defined the overlays as separate items due to lack of
|
||||
@ -110,30 +111,6 @@ function CustomizableViewportOverlay({
|
||||
};
|
||||
}, [viewportData, viewportId, instanceNumber, cornerstoneViewportService]);
|
||||
|
||||
/**
|
||||
* Updating the VOI when the viewport changes its voi
|
||||
*/
|
||||
useEffect(() => {
|
||||
const updateVOI = eventDetail => {
|
||||
const { range } = eventDetail.detail;
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lower, upper } = range;
|
||||
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
|
||||
|
||||
setVOI({ windowCenter, windowWidth });
|
||||
};
|
||||
|
||||
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
|
||||
};
|
||||
}, [viewportId, viewportData, voi, element]);
|
||||
|
||||
const annotationModified = useCallback(evt => {
|
||||
if (evt.detail.annotation.metadata.toolName === UltrasoundPleuraBLineTool.toolName) {
|
||||
// Update the annotation state to trigger a re-render
|
||||
@ -209,7 +186,12 @@ function CustomizableViewportOverlay({
|
||||
} else {
|
||||
const renderItem = customizationService.transform(item);
|
||||
|
||||
if (typeof renderItem.contentF === 'function') {
|
||||
if (
|
||||
renderItem &&
|
||||
typeof renderItem === 'object' &&
|
||||
'contentF' in renderItem &&
|
||||
typeof renderItem.contentF === 'function'
|
||||
) {
|
||||
return renderItem.contentF(overlayItemProps);
|
||||
}
|
||||
}
|
||||
@ -226,6 +208,7 @@ function CustomizableViewportOverlay({
|
||||
scale,
|
||||
instanceNumber,
|
||||
annotationState,
|
||||
isLight,
|
||||
]
|
||||
);
|
||||
|
||||
@ -288,7 +271,7 @@ function getDisplaySets(viewportData, displaySetService) {
|
||||
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
|
||||
let instanceNumber;
|
||||
|
||||
switch (viewportData.viewportType) {
|
||||
switch (getViewportDataShapeType(viewportData)) {
|
||||
case Enums.ViewportType.STACK:
|
||||
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
|
||||
break;
|
||||
@ -355,8 +338,11 @@ function _getInstanceNumberFromVolume(
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = cornerstoneViewport.getCamera();
|
||||
const { viewPlaneNormal } = camera;
|
||||
const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal();
|
||||
|
||||
if (!viewPlaneNormal) {
|
||||
return;
|
||||
}
|
||||
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
|
||||
|
||||
const scanAxisNormal = direction.slice(6, 9);
|
||||
@ -369,7 +355,10 @@ function _getInstanceNumberFromVolume(
|
||||
const imageId = imageIds[imageIndex];
|
||||
|
||||
if (!imageId) {
|
||||
return {};
|
||||
// No image at this index (e.g. a single-image volume scrolled out of
|
||||
// range). Return undefined so the overlay falls back to the slice count
|
||||
// instead of rendering an empty object as "[object Object]".
|
||||
return;
|
||||
}
|
||||
|
||||
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
|
||||
@ -381,7 +370,8 @@ function OverlayItem(props) {
|
||||
const { instance, customization = {} } = props;
|
||||
const { color, attribute, title, label, background } = customization;
|
||||
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
|
||||
if (value === undefined || value === null) {
|
||||
const displayValue = formatValue(value);
|
||||
if (displayValue === null || displayValue === '') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@ -391,7 +381,7 @@ function OverlayItem(props) {
|
||||
title={title}
|
||||
>
|
||||
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
||||
<span className="ml-0 mr-2 shrink-0">{value}</span>
|
||||
<span className="ml-0 shrink-0">{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -402,6 +392,7 @@ function OverlayItem(props) {
|
||||
*/
|
||||
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
|
||||
const { windowWidth, windowCenter } = voi;
|
||||
const { title } = customization;
|
||||
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
|
||||
return null;
|
||||
}
|
||||
@ -410,6 +401,7 @@ function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color: customization?.color }}
|
||||
title={title}
|
||||
>
|
||||
<span className="mr-0.5 shrink-0 opacity-[0.70]">W:</span>
|
||||
<span className="mr-2.5 shrink-0">{windowWidth.toFixed(0)}</span>
|
||||
@ -443,11 +435,13 @@ function InstanceNumberOverlayItem({
|
||||
customization,
|
||||
}: OverlayItemProps) {
|
||||
const { imageIndex, numberOfSlices } = imageSliceData;
|
||||
const { title } = customization;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color: (customization && customization.color) || undefined }}
|
||||
title={title}
|
||||
>
|
||||
<span>
|
||||
{instanceNumber !== undefined && instanceNumber !== null ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user