From 0bf92666273e504f426dfa214c6fdf88467a0c71 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 13:21:47 +0200 Subject: [PATCH 01/18] Add support for `YXCS` axes in asserts/image.py --- lib/galaxy/tool_util/verify/asserts/image.py | 28 ++++++++++++++++- test-data/im5_uint8.tif | Bin 0 -> 2883 bytes test/functional/tools/validation_image.xml | 30 ++++++++++++------- 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 test-data/im5_uint8.tif diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index c56bd144bc61..5e7f962dc210 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -415,7 +415,30 @@ def _get_image( # Try reading with tifffile first. It fails if the file is not a TIFF. try: - im_arr = tifffile.imread(buf) + with tifffile.TiffFile(buf) as im_file: + assert len(im_file.series) == 1, f'Image has unsupported number of series: {len(im_file.series)}' + im_axes = im_file.series[0].axes + + # Verify that the image format is supported + assert frozenset('XY') <= frozenset(im_axes) <= frozenset('XYCS'), f'Image has unsupported axes: {im_axes}' + + # Treat sample axis "S" as channel axis "C" and fail if both are present + assert 'C' not in im_axes or 'S' not in im_axes, f'Image has sample and channel axes which is not supported: {im_axes}' + im_axes = im_axes.replace('S', 'C') + + # Read the image data + im_arr = im_file.asarray() + + # Normalize order of axes Y and X + ypos = im_axes.find('Y') + xpos = im_axes.find('X') + if ypos > xpos: + im_arr = im_arr.swapaxes(ypos, xpos) + + # Normalize image axes to YXC + cpos = im_axes.find('C') + if -1 < cpos < 2: + im_arr = numpy.rollaxis(im_arr, cpos, 3) # If tifffile failed, then the file is not a tifffile. In that case, try with Pillow. except tifffile.TiffFileError: @@ -423,6 +446,9 @@ def _get_image( with Image.open(buf) as im: im_arr = numpy.array(im) + # Verify that the image format is supported + assert im_arr.ndim in (2, 3), f'Image has unsupported dimension: {im_arr.ndim}' + # Select the specified channel (if any). if channel is not None: im_arr = im_arr[:, :, int(channel)] diff --git a/test-data/im5_uint8.tif b/test-data/im5_uint8.tif new file mode 100644 index 0000000000000000000000000000000000000000..5dca8cad973d5ebdd9df601c00ea50b4a54a90ba GIT binary patch literal 2883 zcmeHJS9B9s5S?wZv=S~dwy`b8qKG(PIV)*p3u7T!#uy0<1Vac3DMDV!>u7fs%L*oy z^xk7gNbiO8-b0$xN$q+K<;w%Zx~^q5t~sF4~qa%EuKQM*Tiflo>28vP->F=vh4Ruk|*G4@ihBnkC--$OfVTw zXBAOXbwUxf_)yU6BM4Ubtq_isz^07?-5uE8R&2?MJ8=0s7L7%Xq)s}8lpY@-dv-9F zW^wNT1=6^(n9&Ocq_CEPi_DWiA5lQ3`9!BUt&*i$_MlUS7~lE!B=q3UC<&FSzM8~Ve* zupBR#ip&<#YMW48Qd(v&pIA{@HL1F$)={@py>s%EsncB3cW&5a#>_@{)2@=o>y!P> zErD6Bv)h7mLgDsE$K1|&UGt-{1q-_uEnc$Q(w=3@cVDr`%02g5wf8>z?iXL3=v9)b zKDB@Uw5A)G0|xM?c;K3$wFe!1$f1WFzV3)4k2?C8V~;!jgcDCX`IJ*nJN=9^&pLZ} z{f2YS-FV*l7hHJJ#g|-q+2vPUdDYd|T)XMI>uN@45HB`yY64 z^Ft3m^5|nDk3aF`Q%^th?3U-Af8oWKUVi1(*Is|)&9~lu=iT?-|KP)qKK|s>&p!X+ z%dfuv=G*VKe*eRdKmGj6ufP5N$De=wz3rc|dW>7+3hZDCjQ{_4urGY`LmEFd1We!U|?g#}Y+U=HFx1F3X7`D1I zSnUY8>ZX=bn=v^&@+6nL*2WV)%dkR~R9)(7tjXKF$x>1?U269F-P5MH8d%O`o=`D) zW=o(&mIFSS6)aZKR_XFJds{o_hh}knk=1Ip)iws&=5~bJ=Wv`LT8k|9dZ{hc5$TF> zEGHD1Ok#yg4n`M6BRt3RCV>}hb?!j>g09&-C-5d7-*_eUQfoNWR{XynMl0|I{9heL HiTeKntS+do literal 0 HcmV?d00001 diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index a3c93ea84f67..aa5b2ebfb8a0 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -11,7 +11,7 @@ - + @@ -22,7 +22,7 @@ - + @@ -30,7 +30,7 @@ - + @@ -38,7 +38,7 @@ - + @@ -54,7 +54,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -70,7 +70,7 @@ - + @@ -78,7 +78,7 @@ - + @@ -86,16 +86,26 @@ - + + + + + + + + + + + - + From d027f0fd1c32ce443b771c6b7624c1ce2f8f2027 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 16:46:53 +0200 Subject: [PATCH 02/18] Add support for `TZ` axes in asserts/image.py --- lib/galaxy/tool_util/verify/asserts/image.py | 126 ++++++++++++++----- 1 file changed, 98 insertions(+), 28 deletions(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 5e7f962dc210..f553a4a418f7 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -326,7 +326,7 @@ def assert_has_image_width( """ im_arr = _get_image(output_bytes) _assert_number( - im_arr.shape[1], + im_arr.shape[3], # Image axes are normalized like "TZYXC" width, delta, min, @@ -352,7 +352,7 @@ def assert_has_image_height( """ im_arr = _get_image(output_bytes) _assert_number( - im_arr.shape[0], + im_arr.shape[2], # Image axes are normalized like "TZYXC" height, delta, min, @@ -378,9 +378,8 @@ def assert_has_image_channels( Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``. """ im_arr = _get_image(output_bytes) - n_channels = 1 if im_arr.ndim < 3 else im_arr.shape[2] # we assume here that the image is a 2-D image _assert_number( - n_channels, + im_arr.shape[-1], # Image axes are normalized like "TZYXC" channels, delta, min, @@ -392,53 +391,101 @@ def assert_has_image_channels( def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, float]: - while im_arr.ndim > 2: - im_arr = im_arr.sum(axis=2) - im_arr = numpy.abs(im_arr) - if im_arr.sum() == 0: + im_arr_yx = im_arr.sum(axis=(0, 1, 4)) # Image axes are normalized like "TZYXC" + im_arr_yx = numpy.abs(im_arr_yx) + if im_arr_yx.sum() == 0: return (numpy.nan, numpy.nan) - im_arr = im_arr / im_arr.sum() - yy, xx = numpy.indices(im_arr.shape) - return (im_arr * xx).sum(), (im_arr * yy).sum() + im_arr_yx = im_arr_yx / im_arr_yx.sum() + yy, xx = numpy.indices(im_arr_yx.shape) + return (im_arr_yx * xx).sum(), (im_arr_yx * yy).sum() + + +def _move_char(s: str, pos_src: int, pos_dst: int) -> str: + s_list = list(s) + c = s_list.pop(pos_src) + if pos_dst > pos_src: + pos_dst -= 1 + if pos_dst < 0: + pos_dst = len(s_list) + pos_dst + 1 + s_list.insert(pos_dst, c) + return ''.join(s_list) def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, ) -> "numpy.typing.NDArray": - """ - Returns the output image or a specific channel. + """Returns the output image with the axes ``TZYXC``, optionally restricted to a specific channel. - The function tries to read the image using tifffile and Pillow. + The function tries to read the image using tifffile and Pillow. The image axes are normalized like ``TZYXC``, + treating sample axis ``S`` as an alias for the channel axis ``C``. For images which cannot be read by tifffile, + two- and three-dimensional data is supported. Two-dimensional images are assumed to be in ``YX`` axes order, + and three-dimensional images are assumed to be in ``YXC`` axes order. """ buf = io.BytesIO(output_bytes) # Try reading with tifffile first. It fails if the file is not a TIFF. try: with tifffile.TiffFile(buf) as im_file: - assert len(im_file.series) == 1, f'Image has unsupported number of series: {len(im_file.series)}' + assert len(im_file.series) == 1, f"Image has unsupported number of series: {len(im_file.series)}" im_axes = im_file.series[0].axes # Verify that the image format is supported - assert frozenset('XY') <= frozenset(im_axes) <= frozenset('XYCS'), f'Image has unsupported axes: {im_axes}' + assert frozenset("YX") <= frozenset(im_axes) <= frozenset("TZYXCS"), f"Image has unsupported axes: {im_axes}" # Treat sample axis "S" as channel axis "C" and fail if both are present - assert 'C' not in im_axes or 'S' not in im_axes, f'Image has sample and channel axes which is not supported: {im_axes}' - im_axes = im_axes.replace('S', 'C') + assert "C" not in im_axes or "S" not in im_axes, f"Image has sample and channel axes which is not supported: {im_axes}" + im_axes = im_axes.replace("S", "C") # Read the image data im_arr = im_file.asarray() - # Normalize order of axes Y and X - ypos = im_axes.find('Y') - xpos = im_axes.find('X') + # Step 1. In the three steps below, the optional axes are added, of if they arent't there yet: + + # (1.1) Append "C" axis if not present yet + if im_axes.find("C") == -1: + im_arr = im_arr[..., None] + im_axes += "C" + + # (1.2) Append "Z" axis if not present yet + if im_axes.find("Z") == -1: + im_arr = im_arr[..., None] + im_axes += "Z" + + # (1.3) Append "T" axis if not present yet + if im_axes.find("T") == -1: + im_arr = im_arr[..., None] + im_axes += "T" + + # Step 2. All supported axes are there now. Normalize the order of the axes: + + # (2.1) Normalize order of axes "Y" and "X" + ypos = im_axes.find("Y") + xpos = im_axes.find("X") if ypos > xpos: im_arr = im_arr.swapaxes(ypos, xpos) + im_axes[xpos], im_axes[ypos] = im_axes[ypos], im_axes[xpos] - # Normalize image axes to YXC - cpos = im_axes.find('C') - if -1 < cpos < 2: - im_arr = numpy.rollaxis(im_arr, cpos, 3) + # (2.2) Normalize the position of the "C" axis (should be last) + cpos = im_axes.find("C") + if cpos < len(im_axes) - 1: + im_arr = numpy.moveaxis(im_arr, cpos, -1) + im_axes = _move_char(im_axes, cpos, -1) + + # (2.3) Normalize the position of the "T" axis (should be first) + tpos = im_axes.find("T") + if tpos != 0: + im_arr = numpy.moveaxis(im_arr, tpos, 0) + im_axes = _move_char(im_axes, tpos, 0) + + # (2.4) Normalize the position of the "Z" axis (should be second) + zpos = im_axes.find("Z") + if zpos != 1: + im_arr = numpy.moveaxis(im_arr, zpos, 1) + im_axes = _move_char(im_axes, zpos, 1) + + # Verify that the normalizations were successful + assert im_axes == "TZYXC", f"Image axis normalization failed: {im_axes}" # If tifffile failed, then the file is not a tifffile. In that case, try with Pillow. except tifffile.TiffFileError: @@ -447,11 +494,19 @@ def _get_image( im_arr = numpy.array(im) # Verify that the image format is supported - assert im_arr.ndim in (2, 3), f'Image has unsupported dimension: {im_arr.ndim}' + assert im_arr.ndim in (2, 3), f"Image has unsupported dimension: {im_arr.ndim}" + + # Normalize the axes + if im_arr.ndim == 2: # Append "C" axis if not present yet + im_arr = im_arr[..., None] + im_arr = im_arr[None, None, ...] # Prepend "T" and "Z" axes + + # Verify that the normalizations were successful + assert im_arr.ndim == 5, "Image axis normalization failed" # Select the specified channel (if any). if channel is not None: - im_arr = im_arr[:, :, int(channel)] + im_arr = im_arr[..., [int(channel)]] # Return the image return im_arr @@ -595,7 +650,22 @@ def assert_has_image_mean_object_size( The labels must be unique. """ im_arr, present_labels = _get_image_labels(output_bytes, channel, labels, exclude_labels) - actual_mean_object_size = sum((im_arr == label).sum() for label in present_labels) / len(present_labels) + assert im_arr.shape[-1] == 1, f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" + object_sizes = sum( + [ + # Iterate over all XYZC time-frames (axis C is singleton) + [ + (im_arr_t == label).sum() for label in present_labels + ] + for im_arr_t in im_arr + ], + [] # Build list of all object sizes over all time-frames + ) + actual_mean_object_size = numpy.mean( + [ + object_size for object_size in object_sizes if object_size > 0 + ] + ) _assert_float( actual=actual_mean_object_size, label="mean object size", From 1050983124911e9f58f07e0b80c6391394910dee Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 16:59:32 +0200 Subject: [PATCH 03/18] Add test for `ZCYX` axes --- test-data/im6_uint8.tif | Bin 0 -> 134454 bytes test/functional/tools/validation_image.xml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 test-data/im6_uint8.tif diff --git a/test-data/im6_uint8.tif b/test-data/im6_uint8.tif new file mode 100644 index 0000000000000000000000000000000000000000..5221d413ea3f2a59ec31ab738a30e62bdee0d921 GIT binary patch literal 134454 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 aZ?_xmw{8}Pw)czeM*H`3g`p*Y;eP-j)+2@h literal 0 HcmV?d00001 diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index aa5b2ebfb8a0..43b89f1be536 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -100,6 +100,22 @@ + + + + + + + + + + + + + + + + From 253b550e155132598826ce6395b4e5d7621c3975 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 17:11:26 +0200 Subject: [PATCH 04/18] Add test for `ZYX` axes --- test-data/im7_uint8.tif | Bin 0 -> 10054 bytes test/functional/tools/validation_image.xml | 11 +++++++++++ 2 files changed, 11 insertions(+) create mode 100644 test-data/im7_uint8.tif diff --git a/test-data/im7_uint8.tif b/test-data/im7_uint8.tif new file mode 100644 index 0000000000000000000000000000000000000000..c6f3d7ba20f9b4126f2c2a1102f51b880059ef8d GIT binary patch literal 10054 zcmeHN_kSDNasTe3FUn%E=pcYaBSDY^0T3j?0+9qONQo2)5|nflMT!I|N-UCiD)uEh zopR^+oQr+VC3f6WZ6~qQdvTn8>CJJN-n-)@PH#?}c?;Zq?jP8natrZ;_h#qKeCPXS z<}H`1Rx>aIknq3IgQJmpam}%lCR}UGw0KUZ>aek{HD+s!t+9^A4vxnS`u<(|ky>l( z7QpiceeWA=`dZ`1q#xuAs{INX4YkJeG)})^?Kq7=|5ix;o>%qYY2Atmc<-wmK2NeM zG(PhH)uZkG#(X)S?Jw>L2W)a$F7ykhhX*^&(Y@n?r^_4na=A+Xk#Kr@d*|SIznshF z`isT>{#-6!$`9rT`igns@Z{und2jphv>_ZF95WT+X!~Kg(8oqt@3&&SBI4ukH>lqP zha*rrqSF8)Ohm5*0<5sX4hKXyiEI~SxZ!~pKKKzp5FxZ7j0mDo5JMaZB#}ZpI*>*u zGU!4#dXPmga>%2AJ`~Z90SuyqAq-;#qbTDX#xRZvR4|DtOk)PKm_rrwSipH)z#^7# z5zAOX4Xao~9qYJ+%eaE4uz{=C#5HVT8`rUeT{N(VecH?e+{7&$;t0n$!EM~ZU7X@+ z+{1l5z(c$RZ^bkCB789(;aNP!b9jO;!Si?lZ^M`3?RXLIz?b1Ad^z5Ucj0Bc8()D} z@Rj%~ya(^aSL1znKR$pD;zRfvd@a5XUypCVH{!$i2tJB$!Z+hv@G*QG-->U;x8pnT zo%k+%H@*koi|@nt;|K7A_#yl-egvPukK&W~G5k1w0zZkL!cXI8@G5>5KZl>kFW?vP zOZa8{3Vs#8hF{00@EiCveiLW-E&Miqhm!U8@cZ}!{2~4be~drDpW@H(=lBc!CH@M3 zjlaQX@VEFZ{tkbSf57MPkN7A2GyVntihsku<3I49_%Hl7K9B#QBKiXD|C`sF47|y} z|5FBR6!+)o9Gb7rS5Nki4`@iuxIyjnkea4KZS{m2=XIJjXm+SJTXwfWcNA);2h><^ z(C8wy_daU*m#K+gr1>^AU#4+E)i|L$E3GvL)R?!aQ8z7|kRH=k=p#_mZ;<|uIG-$KRc)%w!Rh8IiMMfmGSU2tyAa_%hBgy>$~Z5mEvdI?31kqeONp@Bx4L5 ztLqt0+p3jQmCKtX-64xj3_{B`i;~%42=CFD;X5IOEQTW*ov2K+$oAD}tFQB@5K@Mb zF?EmDGs~xR%)PdLp5!bJg^p2%b&oy=s$`ce39wmtPECKk=wQ!81_RG=B;l;Gncd)Y zF00<4<9LT`V0xS`n)kVEI-S&JY2I%eE4f`ROQ^@io^LolFkKF`jitf~r#k11ZTX@< z)f2MmnLf|)2461VOmzp70-Ac-D zG>TFapFTL$8%Zdl$M4XQ0;h{Dj>S87x`JMtI_E9QP&J5tc|KtzY0X9#!-n&HtKZIat(;q^w7fz5f7JJvOmwV5q~k=f^s{;rPnaM9;< zn8*^%qZ1>Y{SQYyt+`%|56t(R>+?6~I-Sw#>uB=?!(JRQATb!{DyIyZ#xB!#c5gxuZCtFNL(ja_hcf6x-Z6@xtPyifsW?y$MGj@Nu&B_cz zXOyB{efgQGu+7XA3HpMfZw9isS&I0;E_!Rqmd*{8Dy))lg)VydbXGV z*ab)Y!hK|G;c*6wvn|!$b-vzVCB!^u zv520wNHEfoa@j1DS00x@{@6+uEj@?ZOa|KT*C9o0*3dwq&0=Ey>GIgz*(-Z%ir3y0 z2!gwNV*U2)T)Rp2F9@FALjT&+cLxPM69`sUJeBCUu-E4>GJ$A!N0dk?HJJC?O@z?x zvKR$xGY6P|r`c$h-DZ`2Ld0c6SGdp{bcijC5ji%zdT}_O^ypPQqAgUpy0uwZ?h{nZ zPJ3u}=KRL3JL3e9ICMF^`E0s#;m(-LME-f7JJQwD9v)boP+Sa<&to;o?pAR08;sX# zwkc67?KU$HuHVxZ?(OopLan;!3=YpNlsiMYs9LC8(Al~1)(5Ml*&&-6e@IU6?%v$E zdVSQX`VaV`<71`V`CHRL3)|J0(p4@OQuR9*a{(rZ1SD%rF}{`ooFdumT^ZZ!TQU;u zjOH_vl%i^4fclfm^~KSozdz2(hvVA)gZJL`(rSLNm&%XL!$XfAJ=#7$TX0c=a9lE; zTdpmPu3sA?3pDTUJy%I&Jx)Xlm{^Fx``l6}z2I zMQp}9mA|^RSV{WRVd9(o<&$&Izxa5i&=VC^%ChPC$Im`CFslH^M_2A`FRoqc_c;V+VRoth;O2T`uQr@vjPa8z zyYGJG#@h0g%kwj7D%mT;c_ovv>zhpg*qu%U5(6ngrxrYLuvzM9PbNjPzA4zewOt!Z z1ZAg5uh!Rv^`~yEiCM=8fy;t7$5ct--!zR1up6PxZGq*3qWqVm3YB&4J!{$fx>O`~SvJI^Gr!&}Ncx;(vX0wW5TBBBP)AuigE~-BO!J zw6f?(FjhN#=4w|s=wkenV183llr_eie`rY7sNF}KX3V;bD__Q&pz23!v6rYa-lh1++x zvyq_7ZXtn=U#U%AT$;T!m-0v~Lv+&@t2Y`4FYJzVCOyj-X%YSKlcVlHp3CeZ~ zm)2a{nw*-cE|!u$=3lRySt>8BU4OJTkW~D3BC$F?zPi72ZEdkS*x|OB^yx@O5v}S{ z6+lu+cKSrKQQb_6H_7)rGI@e_4*?z3N`FIR~FFR`5$CX!#FE zQi*7YZHT(vT)Lwp-ZK~t`@-ye(edSCHkHct4X4_J7WO1DFjyYR4$luNp_I(FtTZ`2 zGC5z##`C3s)o9RpU2kScmn180kBbC6=kxizvYZ%7gaTH!BCK>K zBTBxf&FitVBJb!fPtAK}H| zDcUTwh#u0|3cDyt4!h`P9f7ToWyvW@p;$;3)&3s{D&at+gF3coVFz+V=_(bA=Q3Vd zw6Z5AqoJ8uO>Z1bbg5J`0r5f4xdPk!Ci@&`w%W)&c_$^NVYA!qHoB~oOtb_}r_3e8FoA=Q3 zKCO|TX@p$w*BW`vnB8|7b{a z&NV{5)uuJ_xNfxY literal 0 HcmV?d00001 diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index 43b89f1be536..c3e4feab71b7 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -106,6 +106,17 @@ + + + + + + + + + + + From 95ab2cad17d405e49b74ebafedb091ab0ae56e47 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 17:32:30 +0200 Subject: [PATCH 05/18] Add `has_image_depth` assertion --- lib/galaxy/tool_util/verify/asserts/image.py | 62 ++++++++++++++++++++ test/functional/tools/validation_image.xml | 3 + 2 files changed, 65 insertions(+) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index f553a4a418f7..5caae5d27279 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -69,6 +69,15 @@ validators=["check_non_negative_if_set"], ), ] +Depth = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Expected depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] WidthDelta = Annotated[ XmlInt, AssertionParameter( @@ -150,6 +159,33 @@ validators=["check_non_negative_if_set"], ), ] +DepthDelta = Annotated[ + XmlInt, + AssertionParameter( + "Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``.", + json_type="StrictInt", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +DepthMin = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Minimum allowed depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +DepthMax = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Maximum allowed depth of the image (number of slices).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] MeanIntensity = Annotated[ OptionalXmlFloat, AssertionParameter("The required mean value of the image intensities.", json_type=JSON_OPTIONAL_STRICT_NUMBER), @@ -390,6 +426,32 @@ def assert_has_image_channels( ) +def assert_has_image_depth( + output_bytes: OutputBytes, + depth: Depth = None, + delta: DepthDelta = 0, + min: DepthMin = None, + max: DepthMax = None, + negate: Negate = NEGATE_DEFAULT, +) -> None: + """Asserts the output is an image and has a specific depth (number of slices). + + The depth is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. + """ + im_arr = _get_image(output_bytes) + _assert_number( + im_arr.shape[1], # Image axes are normalized like "TZYXC" + depth, + delta, + min, + max, + negate, + "{expected} depth {n}+-{delta}", + "{expected} depth to be in [{min}:{max}]", + ) + + def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, float]: im_arr_yx = im_arr.sum(axis=(0, 1, 4)) # Image axes are normalized like "TZYXC" im_arr_yx = numpy.abs(im_arr_yx) diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index c3e4feab71b7..86c0147c56af 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -100,6 +100,7 @@ + @@ -113,6 +114,7 @@ + @@ -124,6 +126,7 @@ + From bfc4052ff501e09bf374c960c916a71f6b9f9a77 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 17:50:02 +0200 Subject: [PATCH 06/18] Add `has_image_frames` assertion --- lib/galaxy/tool_util/verify/asserts/image.py | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 5caae5d27279..8f3c01732c42 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -78,6 +78,15 @@ validators=["check_non_negative_if_set"], ), ] +Frames = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Expected number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] WidthDelta = Annotated[ XmlInt, AssertionParameter( @@ -186,6 +195,33 @@ validators=["check_non_negative_if_set"], ), ] +FramesDelta = Annotated[ + XmlInt, + AssertionParameter( + "Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``.", + json_type="StrictInt", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +FramesMin = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Minimum allowed number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] +FramesMax = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Maximum allowed number of frames in the image sequence (number of time steps).", + json_type="typing.Optional[StrictInt]", + xml_type="xs:nonNegativeInteger", + validators=["check_non_negative_if_set"], + ), +] MeanIntensity = Annotated[ OptionalXmlFloat, AssertionParameter("The required mean value of the image intensities.", json_type=JSON_OPTIONAL_STRICT_NUMBER), @@ -452,6 +488,32 @@ def assert_has_image_depth( ) +def assert_has_image_frames( + output_bytes: OutputBytes, + frames: Frames = None, + delta: FramesDelta = 0, + min: FramesMin = None, + max: FramesMax = None, + negate: Negate = NEGATE_DEFAULT, +) -> None: + """Asserts the output is an image and has a specific number of frames (number of time steps). + + The number of frames is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. + """ + im_arr = _get_image(output_bytes) + _assert_number( + im_arr.shape[0], # Image axes are normalized like "TZYXC" + frames, + delta, + min, + max, + negate, + "{expected} frames {n}+-{delta}", + "{expected} frames to be in [{min}:{max}]", + ) + + def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, float]: im_arr_yx = im_arr.sum(axis=(0, 1, 4)) # Image axes are normalized like "TZYXC" im_arr_yx = numpy.abs(im_arr_yx) From 371264ca90cc6964b009f4ebfd8a32ec0f54adec Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 18:02:05 +0200 Subject: [PATCH 07/18] Add test for `TYX` axes --- test-data/im8_uint16.tif | Bin 0 -> 28376 bytes test/functional/tools/validation_image.xml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 test-data/im8_uint16.tif diff --git a/test-data/im8_uint16.tif b/test-data/im8_uint16.tif new file mode 100644 index 0000000000000000000000000000000000000000..be4c15380cea074ed1668ef99f4dddc418a4d04a GIT binary patch literal 28376 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 P-`gdy?te)87cc)8eWTQ% literal 0 HcmV?d00001 diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index 86c0147c56af..d27e3049b788 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -101,6 +101,7 @@ + @@ -115,6 +116,7 @@ + @@ -127,12 +129,26 @@ + + + + + + + + + + + + + + From bd501713c7e5b37f56b0915b5761e3457d3bf461 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 18:13:49 +0200 Subject: [PATCH 08/18] Add `frame` attribute for image content-based assertions --- lib/galaxy/tool_util/verify/asserts/image.py | 29 ++++++++++++++++---- test/functional/tools/validation_image.xml | 2 ++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 8f3c01732c42..152cd9a93daa 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -290,6 +290,13 @@ json_type="typing.Optional[StrictInt]", ), ] +Frame = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).", + json_type="typing.Optional[StrictInt]", + ), +] CenterOfMass = Annotated[ str, AssertionParameter( @@ -538,8 +545,9 @@ def _move_char(s: str, pos_src: int, pos_dst: int) -> str: def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + frame: Optional[Union[int, str]] = None, ) -> "numpy.typing.NDArray": - """Returns the output image with the axes ``TZYXC``, optionally restricted to a specific channel. + """Returns the output image with the axes ``TZYXC``, optionally restricted to a specific `channel` and `frame`. The function tries to read the image using tifffile and Pillow. The image axes are normalized like ``TZYXC``, treating sample axis ``S`` as an alias for the channel axis ``C``. For images which cannot be read by tifffile, @@ -632,6 +640,10 @@ def _get_image( if channel is not None: im_arr = im_arr[..., [int(channel)]] + # Select the specified frame (if any). + if frame is not None: + im_arr = im_arr[[int(frame)], ...] + # Return the image return im_arr @@ -639,6 +651,7 @@ def _get_image( def assert_has_image_mean_intensity( output_bytes: OutputBytes, channel: Channel = None, + frame: Frame = None, mean_intensity: MeanIntensity = None, eps: MeanIntensityEps = 0.01, min: MeanIntensityMin = None, @@ -649,7 +662,7 @@ def assert_has_image_mean_intensity( The mean intensity value is plus/minus ``eps`` (e.g., ````). Alternatively the range of the expected mean intensity value can be specified by ``min`` and/or ``max``. """ - im_arr = _get_image(output_bytes, channel) + im_arr = _get_image(output_bytes, channel, frame) _assert_float( actual=im_arr.mean(), label="mean intensity", @@ -664,6 +677,7 @@ def assert_has_image_center_of_mass( output_bytes: OutputBytes, center_of_mass: CenterOfMass, channel: Channel = None, + frame: Frame = None, eps: CenterOfMassEps = 0.01, ) -> None: """Asserts the specified output is an image and has the specified center of mass. @@ -672,7 +686,7 @@ def assert_has_image_center_of_mass( or has an Euclidean distance of ``eps`` or less to that point (e.g., ````). """ - im_arr = _get_image(output_bytes, channel) + im_arr = _get_image(output_bytes, channel, frame) center_of_mass_parts = [c.strip() for c in center_of_mass.split(",")] assert len(center_of_mass_parts) == 2 center_of_mass_tuple = (float(center_of_mass_parts[0]), float(center_of_mass_parts[1])) @@ -687,6 +701,7 @@ def assert_has_image_center_of_mass( def _get_image_labels( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + frame: Optional[Union[int, str]] = None, labels: Optional[Union[str, List[int]]] = None, exclude_labels: Optional[Union[str, List[int]]] = None, ) -> Tuple["numpy.typing.NDArray", List[Any]]: @@ -694,7 +709,7 @@ def _get_image_labels( Determines the unique labels in the output image or a specific channel. """ assert labels is None or exclude_labels is None - im_arr = _get_image(output_bytes, channel) + im_arr = _get_image(output_bytes, channel, frame) def cast_label(label): label = label.strip() @@ -729,6 +744,7 @@ def cast_label(label): def assert_has_image_n_labels( output_bytes: OutputBytes, channel: Channel = None, + frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, n: NumLabels = None, @@ -744,7 +760,7 @@ def assert_has_image_n_labels( The primary usage of this assertion is to verify the number of objects in images with uniquely labeled objects. """ - present_labels = _get_image_labels(output_bytes, channel, labels, exclude_labels)[1] + present_labels = _get_image_labels(output_bytes, channel, frame, labels, exclude_labels)[1] _assert_number( len(present_labels), n, @@ -760,6 +776,7 @@ def assert_has_image_n_labels( def assert_has_image_mean_object_size( output_bytes: OutputBytes, channel: Channel = None, + frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, mean_object_size: MeanObjectSize = None, @@ -773,7 +790,7 @@ def assert_has_image_mean_object_size( The labels must be unique. """ - im_arr, present_labels = _get_image_labels(output_bytes, channel, labels, exclude_labels) + im_arr, present_labels = _get_image_labels(output_bytes, channel, frame, labels, exclude_labels) assert im_arr.shape[-1] == 1, f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" object_sizes = sum( [ diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index d27e3049b788..479ee9220463 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -146,6 +146,8 @@ + + From 6a6a954998f58f24e40978ade2dc739e874ea6ff Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 18:26:12 +0200 Subject: [PATCH 09/18] Add `slice` attribute for image content-based assertions --- lib/galaxy/tool_util/verify/asserts/image.py | 58 +++++++++++++++----- test/functional/tools/validation_image.xml | 4 ++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 152cd9a93daa..65b6a8e68402 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -290,6 +290,13 @@ json_type="typing.Optional[StrictInt]", ), ] +Slice = Annotated[ + OptionalXmlInt, + AssertionParameter( + "Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).", + json_type="typing.Optional[StrictInt]", + ), +] Frame = Annotated[ OptionalXmlInt, AssertionParameter( @@ -398,7 +405,8 @@ def assert_has_image_width( max: WidthMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific width (in pixels). + """ + Asserts the output is an image and has a specific width (in pixels). The width is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected width can be specified by ``min`` and/or ``max``. @@ -424,7 +432,8 @@ def assert_has_image_height( max: HeightMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific height (in pixels). + """ + Asserts the output is an image and has a specific height (in pixels). The height is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected height can be specified by ``min`` and/or ``max``. @@ -450,7 +459,8 @@ def assert_has_image_channels( max: ChannelsMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific number of channels. + """ + Asserts the output is an image and has a specific number of channels. The number of channels is plus/minus ``delta`` (e.g., ````). @@ -477,7 +487,8 @@ def assert_has_image_depth( max: DepthMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific depth (number of slices). + """ + Asserts the output is an image and has a specific depth (number of slices). The depth is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. @@ -503,7 +514,8 @@ def assert_has_image_frames( max: FramesMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has a specific number of frames (number of time steps). + """ + Asserts the output is an image and has a specific number of frames (number of time steps). The number of frames is plus/minus ``delta`` (e.g., ````). Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. @@ -545,9 +557,12 @@ def _move_char(s: str, pos_src: int, pos_dst: int) -> str: def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + slice: Optional[Union[int, str]] = None, frame: Optional[Union[int, str]] = None, ) -> "numpy.typing.NDArray": - """Returns the output image with the axes ``TZYXC``, optionally restricted to a specific `channel` and `frame`. + """ + Returns the output image with the axes ``TZYXC``, optionally restricted to a specific `channel`, `slice`, + `frame`, or a combination thereof. The function tries to read the image using tifffile and Pillow. The image axes are normalized like ``TZYXC``, treating sample axis ``S`` as an alias for the channel axis ``C``. For images which cannot be read by tifffile, @@ -640,6 +655,10 @@ def _get_image( if channel is not None: im_arr = im_arr[..., [int(channel)]] + # Select the specified slice (if any). + if slice is not None: + im_arr = im_arr[:, [int(slice)], ...] + # Select the specified frame (if any). if frame is not None: im_arr = im_arr[[int(frame)], ...] @@ -651,18 +670,20 @@ def _get_image( def assert_has_image_mean_intensity( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, frame: Frame = None, mean_intensity: MeanIntensity = None, eps: MeanIntensityEps = 0.01, min: MeanIntensityMin = None, max: MeanIntensityMax = None, ) -> None: - """Asserts the output is an image and has a specific mean intensity value. + """ + Asserts the output is an image and has a specific mean intensity value. The mean intensity value is plus/minus ``eps`` (e.g., ````). Alternatively the range of the expected mean intensity value can be specified by ``min`` and/or ``max``. """ - im_arr = _get_image(output_bytes, channel, frame) + im_arr = _get_image(output_bytes, channel, slice, frame) _assert_float( actual=im_arr.mean(), label="mean intensity", @@ -677,16 +698,18 @@ def assert_has_image_center_of_mass( output_bytes: OutputBytes, center_of_mass: CenterOfMass, channel: Channel = None, + slice: Slice = None, frame: Frame = None, eps: CenterOfMassEps = 0.01, ) -> None: - """Asserts the specified output is an image and has the specified center of mass. + """ + Asserts the specified output is an image and has the specified center of mass. Asserts the output is an image and has a specific center of mass, or has an Euclidean distance of ``eps`` or less to that point (e.g., ````). """ - im_arr = _get_image(output_bytes, channel, frame) + im_arr = _get_image(output_bytes, channel, slice, frame) center_of_mass_parts = [c.strip() for c in center_of_mass.split(",")] assert len(center_of_mass_parts) == 2 center_of_mass_tuple = (float(center_of_mass_parts[0]), float(center_of_mass_parts[1])) @@ -701,6 +724,7 @@ def assert_has_image_center_of_mass( def _get_image_labels( output_bytes: bytes, channel: Optional[Union[int, str]] = None, + slice: Optional[Union[int, str]] = None, frame: Optional[Union[int, str]] = None, labels: Optional[Union[str, List[int]]] = None, exclude_labels: Optional[Union[str, List[int]]] = None, @@ -709,7 +733,7 @@ def _get_image_labels( Determines the unique labels in the output image or a specific channel. """ assert labels is None or exclude_labels is None - im_arr = _get_image(output_bytes, channel, frame) + im_arr = _get_image(output_bytes, channel, slice, frame) def cast_label(label): label = label.strip() @@ -744,6 +768,7 @@ def cast_label(label): def assert_has_image_n_labels( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, @@ -753,14 +778,15 @@ def assert_has_image_n_labels( max: NumLabelsMax = None, negate: Negate = NEGATE_DEFAULT, ) -> None: - """Asserts the output is an image and has the specified labels. + """ + Asserts the output is an image and has the specified labels. Labels can be a number of labels or unique values (e.g., ````). The primary usage of this assertion is to verify the number of objects in images with uniquely labeled objects. """ - present_labels = _get_image_labels(output_bytes, channel, frame, labels, exclude_labels)[1] + present_labels = _get_image_labels(output_bytes, channel, slice, frame, labels, exclude_labels)[1] _assert_number( len(present_labels), n, @@ -776,6 +802,7 @@ def assert_has_image_n_labels( def assert_has_image_mean_object_size( output_bytes: OutputBytes, channel: Channel = None, + slice: Slice = None, frame: Frame = None, labels: Labels = None, exclude_labels: ExcludeLabels = None, @@ -784,13 +811,14 @@ def assert_has_image_mean_object_size( min: MeanObjectSizeMin = None, max: MeanObjectSizeMax = None, ) -> None: - """Asserts the output is an image with labeled objects which have the specified mean size (number of pixels), + """ + Asserts the output is an image with labeled objects which have the specified mean size (number of pixels), The mean size is plus/minus ``eps`` (e.g., ````). The labels must be unique. """ - im_arr, present_labels = _get_image_labels(output_bytes, channel, frame, labels, exclude_labels) + im_arr, present_labels = _get_image_labels(output_bytes, channel, slice, frame, labels, exclude_labels) assert im_arr.shape[-1] == 1, f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" object_sizes = sum( [ diff --git a/test/functional/tools/validation_image.xml b/test/functional/tools/validation_image.xml index 479ee9220463..c94c3581c3f1 100644 --- a/test/functional/tools/validation_image.xml +++ b/test/functional/tools/validation_image.xml @@ -118,6 +118,8 @@ + + @@ -133,6 +135,8 @@ + + From 3d680fb7496a04a84bbfb5d4f917a16409530cb2 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 18:30:53 +0200 Subject: [PATCH 10/18] Resync XSD and Pydantic --- .../tool_util/verify/assertion_models.py | 156 ++++++++++++++++++ lib/galaxy/tool_util/xsd/galaxy.xsd | 116 +++++++++++++ 2 files changed, 272 insertions(+) diff --git a/lib/galaxy/tool_util/verify/assertion_models.py b/lib/galaxy/tool_util/verify/assertion_models.py index 5f21e488e52b..8ca38431e743 100644 --- a/lib/galaxy/tool_util/verify/assertion_models.py +++ b/lib/galaxy/tool_util/verify/assertion_models.py @@ -1212,6 +1212,12 @@ class has_size_model(AssertionModel): has_image_center_of_mass_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_center_of_mass_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_center_of_mass_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" + has_image_center_of_mass_eps_description = ( """The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``).""" ) @@ -1236,6 +1242,16 @@ class has_image_center_of_mass_model(AssertionModel): description=has_image_center_of_mass_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_center_of_mass_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_center_of_mass_frame_description, + ) + eps: Annotated[typing.Union[StrictInt, StrictFloat], BeforeValidator(check_non_negative_if_set)] = Field( 0.01, description=has_image_center_of_mass_eps_description, @@ -1288,6 +1304,96 @@ class has_image_channels_model(AssertionModel): ) +has_image_depth_depth_description = """Expected depth of the image (number of slices).""" + +has_image_depth_delta_description = """Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``.""" + +has_image_depth_min_description = """Minimum allowed depth of the image (number of slices).""" + +has_image_depth_max_description = """Maximum allowed depth of the image (number of slices).""" + +has_image_depth_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" + + +class has_image_depth_model(AssertionModel): + r"""Asserts the output is an image and has a specific depth (number of slices). + + The depth is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_depth"] = "has_image_depth" + + depth: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_depth_description, + ) + + delta: Annotated[StrictInt, BeforeValidator(check_non_negative_if_set)] = Field( + 0, + description=has_image_depth_delta_description, + ) + + min: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_min_description, + ) + + max: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_depth_max_description, + ) + + negate: typing.Union[bool, str] = Field( + False, + description=has_image_depth_negate_description, + ) + + +has_image_frames_frames_description = """Expected number of frames in the image sequence (number of time steps).""" + +has_image_frames_delta_description = """Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``.""" + +has_image_frames_min_description = """Minimum allowed number of frames in the image sequence (number of time steps).""" + +has_image_frames_max_description = """Maximum allowed number of frames in the image sequence (number of time steps).""" + +has_image_frames_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" + + +class has_image_frames_model(AssertionModel): + r"""Asserts the output is an image and has a specific number of frames (number of time steps). + + The number of frames is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_frames"] = "has_image_frames" + + frames: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_frames_description, + ) + + delta: Annotated[StrictInt, BeforeValidator(check_non_negative_if_set)] = Field( + 0, + description=has_image_frames_delta_description, + ) + + min: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_min_description, + ) + + max: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( + None, + description=has_image_frames_max_description, + ) + + negate: typing.Union[bool, str] = Field( + False, + description=has_image_frames_negate_description, + ) + + has_image_height_height_description = """Expected height of the image (in pixels).""" has_image_height_delta_description = """Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``.""" @@ -1335,6 +1441,12 @@ class has_image_height_model(AssertionModel): has_image_mean_intensity_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_mean_intensity_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_mean_intensity_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" + has_image_mean_intensity_mean_intensity_description = """The required mean value of the image intensities.""" has_image_mean_intensity_eps_description = """The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean value of the image intensities has to be in the range ``value +- eps``.""" @@ -1357,6 +1469,16 @@ class has_image_mean_intensity_model(AssertionModel): description=has_image_mean_intensity_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_intensity_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_intensity_frame_description, + ) + mean_intensity: typing.Optional[typing.Union[StrictInt, StrictFloat]] = Field( None, description=has_image_mean_intensity_mean_intensity_description, @@ -1380,6 +1502,12 @@ class has_image_mean_intensity_model(AssertionModel): has_image_mean_object_size_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_mean_object_size_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_mean_object_size_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" + has_image_mean_object_size_labels_description = """List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``.""" has_image_mean_object_size_exclude_labels_description = """List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``.""" @@ -1411,6 +1539,16 @@ class has_image_mean_object_size_model(AssertionModel): description=has_image_mean_object_size_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_object_size_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_mean_object_size_frame_description, + ) + labels: typing.Optional[typing.List[int]] = Field( None, description=has_image_mean_object_size_labels_description, @@ -1450,6 +1588,12 @@ class has_image_mean_object_size_model(AssertionModel): has_image_n_labels_channel_description = """Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel).""" +has_image_n_labels_slice_description = ( + """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" +) + +has_image_n_labels_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" + has_image_n_labels_labels_description = """List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``.""" has_image_n_labels_exclude_labels_description = """List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``.""" @@ -1480,6 +1624,16 @@ class has_image_n_labels_model(AssertionModel): description=has_image_n_labels_channel_description, ) + slice: typing.Optional[StrictInt] = Field( + None, + description=has_image_n_labels_slice_description, + ) + + frame: typing.Optional[StrictInt] = Field( + None, + description=has_image_n_labels_frame_description, + ) + labels: typing.Optional[typing.List[int]] = Field( None, description=has_image_n_labels_labels_description, @@ -1587,6 +1741,8 @@ class has_image_width_model(AssertionModel): has_size_model, has_image_center_of_mass_model, has_image_channels_model, + has_image_depth_model, + has_image_frames_model, has_image_height_model, has_image_mean_intensity_model, has_image_mean_object_size_model, diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 3f077b79abdb..4e57898330d6 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -3051,6 +3051,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3097,6 +3107,82 @@ $attribute_list::5]]> + + + + ``). +Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. + +$attribute_list::5]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ``). +Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. + +$attribute_list::5]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3151,6 +3237,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3190,6 +3286,16 @@ $attribute_list::5]]> + + + + + + + + + + @@ -3240,6 +3346,16 @@ $attribute_list::5]]> + + + + + + + + + + From 2ab3bbb2d83c7bd167dd93de80c6f59529ac9684 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 18:36:53 +0200 Subject: [PATCH 11/18] Resync XSD and Pydantic --- .../tool_util/verify/assertion_models.py | 70 +++++++++++++------ 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/lib/galaxy/tool_util/verify/assertion_models.py b/lib/galaxy/tool_util/verify/assertion_models.py index 2ca379e43567..1c1e173c8350 100644 --- a/lib/galaxy/tool_util/verify/assertion_models.py +++ b/lib/galaxy/tool_util/verify/assertion_models.py @@ -1548,6 +1548,22 @@ class base_has_image_channels_model(AssertionModel): ) +class has_image_channels_model(base_has_image_channels_model): + r"""Asserts the output is an image and has a specific number of channels. + + The number of channels is plus/minus ``delta`` (e.g., ````). + + Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_channels"] = "has_image_channels" + + +class has_image_channels_model_nested(AssertionModel): + r"""Nested version of this assertion model.""" + + has_image_channels: base_has_image_channels_model + + has_image_depth_depth_description = """Expected depth of the image (number of slices).""" has_image_depth_delta_description = """Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``.""" @@ -1559,13 +1575,8 @@ class base_has_image_channels_model(AssertionModel): has_image_depth_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" -class has_image_depth_model(AssertionModel): - r"""Asserts the output is an image and has a specific depth (number of slices). - - The depth is plus/minus ``delta`` (e.g., ````). - Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``.""" - - that: Literal["has_image_depth"] = "has_image_depth" +class base_has_image_depth_model(AssertionModel): + """base model for has_image_depth describing attributes.""" depth: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( None, @@ -1593,6 +1604,21 @@ class has_image_depth_model(AssertionModel): ) +class has_image_depth_model(base_has_image_depth_model): + r"""Asserts the output is an image and has a specific depth (number of slices). + + The depth is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``.""" + + that: Literal["has_image_depth"] = "has_image_depth" + + +class has_image_depth_model_nested(AssertionModel): + r"""Nested version of this assertion model.""" + + has_image_depth: base_has_image_depth_model + + has_image_frames_frames_description = """Expected number of frames in the image sequence (number of time steps).""" has_image_frames_delta_description = """Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``.""" @@ -1604,13 +1630,8 @@ class has_image_depth_model(AssertionModel): has_image_frames_negate_description = """A boolean that can be set to true to negate the outcome of the assertion.""" -class has_image_frames_model(AssertionModel): - r"""Asserts the output is an image and has a specific number of frames (number of time steps). - - The number of frames is plus/minus ``delta`` (e.g., ````). - Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``.""" - - that: Literal["has_image_frames"] = "has_image_frames" +class base_has_image_frames_model(AssertionModel): + """base model for has_image_frames describing attributes.""" frames: Annotated[typing.Optional[StrictInt], BeforeValidator(check_non_negative_if_set)] = Field( None, @@ -1638,20 +1659,19 @@ class has_image_frames_model(AssertionModel): ) -class has_image_channels_model(base_has_image_channels_model): - r"""Asserts the output is an image and has a specific number of channels. - - The number of channels is plus/minus ``delta`` (e.g., ````). +class has_image_frames_model(base_has_image_frames_model): + r"""Asserts the output is an image and has a specific number of frames (number of time steps). - Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``.""" + The number of frames is plus/minus ``delta`` (e.g., ````). + Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``.""" - that: Literal["has_image_channels"] = "has_image_channels" + that: Literal["has_image_frames"] = "has_image_frames" -class has_image_channels_model_nested(AssertionModel): +class has_image_frames_model_nested(AssertionModel): r"""Nested version of this assertion model.""" - has_image_channels: base_has_image_channels_model + has_image_frames: base_has_image_frames_model has_image_height_height_description = """Expected height of the image (in pixels).""" @@ -2087,6 +2107,8 @@ class has_image_width_model_nested(AssertionModel): has_size_model_nested, has_image_center_of_mass_model_nested, has_image_channels_model_nested, + has_image_depth_model_nested, + has_image_frames_model_nested, has_image_height_model_nested, has_image_mean_intensity_model_nested, has_image_mean_object_size_model_nested, @@ -2147,6 +2169,10 @@ class assertion_dict(AssertionModel): has_image_channels: typing.Optional[base_has_image_channels_model] = None + has_image_depth: typing.Optional[base_has_image_depth_model] = None + + has_image_frames: typing.Optional[base_has_image_frames_model] = None + has_image_height: typing.Optional[base_has_image_height_model] = None has_image_mean_intensity: typing.Optional[base_has_image_mean_intensity_model] = None From 2c3bd9f28e456d71bfb90313f35caee3d5972a6c Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 20:55:06 +0200 Subject: [PATCH 12/18] Fix linting --- lib/galaxy/tool_util/verify/asserts/image.py | 30 +++++++++++--------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 65b6a8e68402..00a6762a0673 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -551,7 +551,7 @@ def _move_char(s: str, pos_src: int, pos_dst: int) -> str: if pos_dst < 0: pos_dst = len(s_list) + pos_dst + 1 s_list.insert(pos_dst, c) - return ''.join(s_list) + return "".join(s_list) def _get_image( @@ -578,10 +578,14 @@ def _get_image( im_axes = im_file.series[0].axes # Verify that the image format is supported - assert frozenset("YX") <= frozenset(im_axes) <= frozenset("TZYXCS"), f"Image has unsupported axes: {im_axes}" + assert ( + frozenset("YX") <= frozenset(im_axes) <= frozenset("TZYXCS") + ), f"Image has unsupported axes: {im_axes}" # Treat sample axis "S" as channel axis "C" and fail if both are present - assert "C" not in im_axes or "S" not in im_axes, f"Image has sample and channel axes which is not supported: {im_axes}" + assert ( + "C" not in im_axes or "S" not in im_axes + ), f"Image has sample and channel axes which is not supported: {im_axes}" im_axes = im_axes.replace("S", "C") # Read the image data @@ -819,22 +823,22 @@ def assert_has_image_mean_object_size( The labels must be unique. """ im_arr, present_labels = _get_image_labels(output_bytes, channel, slice, frame, labels, exclude_labels) - assert im_arr.shape[-1] == 1, f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" + assert ( + im_arr.shape[-1] == 1 + ), f"has_image_mean_object_size is undefined for multi-channel images (channels: {im_arr.shape[-1]})" + + # Build list of all object sizes over all time-frames object_sizes = sum( [ # Iterate over all XYZC time-frames (axis C is singleton) - [ - (im_arr_t == label).sum() for label in present_labels - ] + [(im_arr_t == label).sum() for label in present_labels] for im_arr_t in im_arr ], - [] # Build list of all object sizes over all time-frames - ) - actual_mean_object_size = numpy.mean( - [ - object_size for object_size in object_sizes if object_size > 0 - ] + [], ) + + # Compute the mean object size and verify + actual_mean_object_size = numpy.mean([object_size for object_size in object_sizes if object_size > 0]) _assert_float( actual=actual_mean_object_size, label="mean object size", From e90e50e9fa01a4f4c258f63aaf508ff367520e79 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 21:56:47 +0200 Subject: [PATCH 13/18] Trigger CI From 496e3bbe065a67b2fcc967576078eaeffaa15077 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 25 Sep 2024 22:17:42 +0200 Subject: [PATCH 14/18] Fix bug --- lib/galaxy/tool_util/verify/asserts/image.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 00a6762a0673..5ab09eaa483b 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -554,6 +554,12 @@ def _move_char(s: str, pos_src: int, pos_dst: int) -> str: return "".join(s_list) +def _swap_char(s: str, pos1: int, pos2: int) -> str: + s_list = list(s) + s_list[pos1], s_list[pos2] = s_list[pos2], s_list[pos1] + return "".join(s_list) + + def _get_image( output_bytes: bytes, channel: Optional[Union[int, str]] = None, @@ -615,7 +621,7 @@ def _get_image( xpos = im_axes.find("X") if ypos > xpos: im_arr = im_arr.swapaxes(ypos, xpos) - im_axes[xpos], im_axes[ypos] = im_axes[ypos], im_axes[xpos] + im_axes = _swap_char(im_axes, xpos, ypos) # (2.2) Normalize the position of the "C" axis (should be last) cpos = im_axes.find("C") From 161baf4328d46cf8f07967213bf9f234d9ef9da9 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Thu, 26 Sep 2024 13:12:33 +0200 Subject: [PATCH 15/18] Apply suggestions from code review Co-authored-by: Lucille Delisle --- lib/galaxy/tool_util/verify/assertion_models.py | 8 ++++---- lib/galaxy/tool_util/xsd/galaxy.xsd | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/tool_util/verify/assertion_models.py b/lib/galaxy/tool_util/verify/assertion_models.py index 1c1e173c8350..4e184334e368 100644 --- a/lib/galaxy/tool_util/verify/assertion_models.py +++ b/lib/galaxy/tool_util/verify/assertion_models.py @@ -1456,7 +1456,7 @@ class has_size_model_nested(AssertionModel): """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" ) -has_image_center_of_mass_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" +has_image_center_of_mass_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" has_image_center_of_mass_eps_description = ( """The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``).""" @@ -1735,7 +1735,7 @@ class has_image_height_model_nested(AssertionModel): """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" ) -has_image_mean_intensity_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" +has_image_mean_intensity_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" has_image_mean_intensity_mean_intensity_description = """The required mean value of the image intensities.""" @@ -1806,7 +1806,7 @@ class has_image_mean_intensity_model_nested(AssertionModel): """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" ) -has_image_mean_object_size_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" +has_image_mean_object_size_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" has_image_mean_object_size_labels_description = """List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``.""" @@ -1902,7 +1902,7 @@ class has_image_mean_object_size_model_nested(AssertionModel): """Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice).""" ) -has_image_n_labels_frame_description = """Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).""" +has_image_n_labels_frame_description = """Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).""" has_image_n_labels_labels_description = """List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``.""" diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 4e57898330d6..c8090c919568 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -3058,7 +3058,7 @@ $attribute_list::5]]> - + @@ -3244,7 +3244,7 @@ $attribute_list::5]]> - + @@ -3293,7 +3293,7 @@ $attribute_list::5]]> - + @@ -3353,7 +3353,7 @@ $attribute_list::5]]> - + From 874174a29bef34223514da11b85387f085c6d52b Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Thu, 26 Sep 2024 13:13:00 +0200 Subject: [PATCH 16/18] Update lib/galaxy/tool_util/verify/asserts/image.py Co-authored-by: Lucille Delisle --- lib/galaxy/tool_util/verify/asserts/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index 5ab09eaa483b..c2c6dfb368e5 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -300,7 +300,7 @@ Frame = Annotated[ OptionalXmlInt, AssertionParameter( - "Restricts the assertion to a specific frame of the image sequeqnce (where ``0`` corresponds to the first image frame).", + "Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame).", json_type="typing.Optional[StrictInt]", ), ] From 2d9910a043ee72e663e81560b37ac6ece3a79281 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Thu, 26 Sep 2024 15:05:48 +0200 Subject: [PATCH 17/18] Trigger CI From cc5e73716ffa99fc070f753f4cad1993027dd263 Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Fri, 27 Sep 2024 10:24:38 +0200 Subject: [PATCH 18/18] Fix bug in `_move_char` --- lib/galaxy/tool_util/verify/asserts/image.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/galaxy/tool_util/verify/asserts/image.py b/lib/galaxy/tool_util/verify/asserts/image.py index c2c6dfb368e5..d9972994d3fe 100644 --- a/lib/galaxy/tool_util/verify/asserts/image.py +++ b/lib/galaxy/tool_util/verify/asserts/image.py @@ -546,8 +546,6 @@ def _compute_center_of_mass(im_arr: "numpy.typing.NDArray") -> Tuple[float, floa def _move_char(s: str, pos_src: int, pos_dst: int) -> str: s_list = list(s) c = s_list.pop(pos_src) - if pos_dst > pos_src: - pos_dst -= 1 if pos_dst < 0: pos_dst = len(s_list) + pos_dst + 1 s_list.insert(pos_dst, c)