From df40f0d60557588fc2cbf986742a4d8dda47fea0 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 28 May 2026 00:10:05 +0200 Subject: [PATCH 1/2] Add tiling test suite for already-merged _tiling.py Ports test_tiling.py and the two TilingVisual_*.png baselines from the PR #982 working branch. The tested functions (build_tile_specs, compute_cell_info{,_multiscale,_tiled}, extract_tile{,_lazy}, verify_coverage) all already live on main from #1157; this PR closes the regression-coverage gap that #1157 left behind. Tests covering behaviour that only exists on the PR #982 branch (a target_scale guard and an AssertionError/ValueError divergence in verify_coverage) are not ported here; they will land alongside the matching _tiling.py improvements in a follow-up. --- .../TilingVisual_tile_assignment_gap.png | Bin 0 -> 20511 bytes .../TilingVisual_tile_assignment_touching.png | Bin 0 -> 9600 bytes tests/experimental/test_tiling.py | 524 ++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 tests/_images/TilingVisual_tile_assignment_gap.png create mode 100644 tests/_images/TilingVisual_tile_assignment_touching.png create mode 100644 tests/experimental/test_tiling.py diff --git a/tests/_images/TilingVisual_tile_assignment_gap.png b/tests/_images/TilingVisual_tile_assignment_gap.png new file mode 100644 index 0000000000000000000000000000000000000000..1fb306de3db656a50f4c07fbcf7d462d0248178a GIT binary patch literal 20511 zcmZ6yWmH=Y7c|-ymmbrOEKFUaotHAEh|GOb0!hQ;=AH>1#_?#uQoK@{i zo!yKa!SCdaob9dcoUJX4zq*1QohY+%d0I6zI&$_E+hV3%{}8p55*gI=fU@O>~y!|+qadluXH)) zf+EVL$aq%ebG4w!pY(JfY)h+B8;kaOBszEUdWd^nQOGY;$OlJUgzxWB-ispp$5_R$ z{)m2fTeFMGOtp6Hi2OE8eYM@#d3-#^{g1!)YRs)|{H_iEI~IHjixD}%=)@9EE`?>% zHwDAGNBmQ-vmp+39AN=mI!kOH3(3OoP|Bc#E^0YSRyiatBl1uqzA6+P8T4s?`4nm6 z|NE5twW5*|QCV48&GbonBR+eA^(?Q`Zq9k#fXz+g+qe38{oXR|+A7B#puppPE{TRB z7)+JrzKKF?6LWHNbCWi{FMvTNv|{9vkd(B!9j~a-aMp6fwBj&G()6&KjvXp~J|)Y% zk#5)V-7cWL^y7Y{`?^2U!rO2>&DKt`f$7=XP32Fc&A!m%mje0p^S|ulRd>0Pk;dGB zZ&=9dNjkRQH8k*D&(=nX-^TdQevj<*{1FR2f4&+wIh-jT8Xh*{=3HHGbv+UF@Zj|_ z(9#M~6ncQyX|V3T7$l3PuWXB(y;*S@-*`N#G|QfJQ!~@kBMHJF2`jDaNE5WT=K7MM zK;!f`mO{!B`g->BtrvF2uVuT=%*AG(Csz(`ZVGO00!*R%ueZmGy`TfK=XE5Wodned z-f8kCYzoP$7Sr*pNHpfQn9SanyMNKkwypBb8fMu(T&)UMyQ5;3mL;2iou7G~cGqL2 zNR2RsYOV^>ZF|C*J3`jrYY|R^-1q+k$7E$iMMR*Nf$D(|0>l{XQeT991+4Vw>FCZ^ z-TwW_%Y&aTkT+S?a!fMn4XS?mA@FiNeLtQ3+Gl&8kbrxut*=k&d)NBU=x++^LYoJB zT3TAlm0T(tf>;oG_g1uU)ZM?IWN)u`oys~oXqEHf?Ot~^d^g}>l**Pvy8MoO*<|D6 zh3fOCmt(Jzw?g3;z*B|YzmBHUmLvN)ZL{hZip zE9WZz1F67?nU2o*te8NIYw^VOcH-OMsXU9$x-D{xL!n}Jl#tJ(;p_7QbZ;AdT`onCXYB;hg13Z6Y-9rNG4(Sue4mD@%QY^v8Q*AvDPyLUtmMn- zdEfgeD%e(QE$m%G@MDOtXEhBZTwPsDYw9XVof&ErU!y?w9~Py?bsWh_G`ST~Z%#-6H z`sZ^%Wc=H0V`sH3qf2jbqX_0_PV?s$qh7?_-~W)V_QnUB9JUt3%DDe5^PPA2Jh3?K z%H`$fSF@7U!&A$pNPj|l?InBlgI#thff(jNanx!s@Q9@}F}#t>6?iYIRcrbYjoV#- z6wKEDJ2cC0U_g?~OvH&$=B(@G@y2B1{b%+`xx&w2FxdTSlzWj~s|I&-h^3&S*Z$Zt?c_6;oGETn7~0OhbXG;{HM2eW%~dJ`Y4mtn2tN%cJ6h zTq*<_Xh0%kN5^6Az*Q8!eYZUm6q)E&IgW%W|F?S3c+o3dS=|L0V2fFx z&FOy-+nBL%;n)OtFZG2P_!Gvqi!;MpyfCp|qgKD^Yni6|Uj0Iii{ zS>OT^US)OWTlTEvrQk&RQtDXl>oDS6_V&zf{a{H^tC{8Pc{$80LK$&L!8tTM_G$bs z51Z%dy4wA|*u3o~n%y^0LRM&C?xc0$Hcuu#CVRIpys~Yq;mWbbe%eKIJ<=`NWKh8& z`%6U=q53SR*5yW_-)s;zMU>h6LC4c69Qn5k&_72wjSBr>7Xw@H@?M57TM!vPp<>7V z=jGd`)fAWUv-8d8hvTew@VY_=y0@zkx4tWqiArYMCSz1m&r1o82e{)}3vOM1n^tJG z%b=}8_Unto?E=pEx$vIF_(3Lk(uwbkVSDe#M*sX8Vwo#Gu=Qq6hjlOet?y3>bZ!sc zu&+A6;UU^LV=Y`SLvP!8|kz`&m0wJq7PBNO^E;$ zMFB1U53VB3^nYRs`&x238B=5^CHQmuKEN%ML1=`YyIue4s6)Xcb~G5hk!Bb_&R)7% zsNb&G6V$__qzKa(g^=7xvF!wiNDDunt-4Y_g1y9BWlJ4Su8z8#y2n4qg%eZ?Yomm( zT+QJ#ENP8_&vKJ9{jP#dtM0y)qT#$<07^bbY~^Z`(yV3sQah^ZZ6Yn>!`ZF)Z8#lP zooHOR+ic!vze^UP?m?h{nGk-ZJ6SN0k4Mv!Urw- z5+-^HF%U^~!)*4@?WF+Q&27-K6?31X0Jyg$3*$F377B#)A-IcU0rpKNl)VCg7`(4I#|dQar^TP?vbq9a14=%}Yz0m;y{^kMemmwT zKed979tql%Yc#e4>%DXF?I4=#iNIaxG1lrmxquwoQ)m`Nidw@t3MfkzC@JXi3cyL` z79an-Kr74(HzFabfAZVi??ExBV*|f z!G0h8?#5Vd4lB$0p27GYkcj-f-pen^+`LLYrd=EET`=M29~-$8`Q8}TZlSjg)y5@K zfw2)Hvg0bmT*NwGU(Qi}WyI-+{sJ|AZw4WzjMKb+z4V-ISa(=*ur_)+rYg)MVD|1X z?FS(34K~tOs1~3HPbYrBMOQ4dHWhCFzWFo2sxO1hj&KRVt>Jz%&>Dvqmly0Mq9Ajz z$|O1!>q^UG6@b^L^i(^rr%hHQB@c2Rf+MRisPam|(?9H?hNPwI20s;t76g=o8Q$m;BpR`dk< z6e43i$W==+4DQ7VA{Ht*D_?XoNCrgaLh7Kalz6koWD^`S*KD**mtJ7uSio?9k{ZmE zpA{J;weOCH`*D+uj_r4N#-VrBG(__GBu!Pb@$gG*26?W;W8e`(wT`-n^(jB{%n1^~ z6sq4fq^Lc(@ycyf?0whRzY*gCa+zwI<%XtzI9KM6RtCm{-;2|6k(gTX&Q?4TgQWFF zPmsRYKeW|H{Ive9iuhxrnO=%^dEBSv3v>BKEAN{C7`{TNUi^>w+I3}QQ4t;qGwGDd z@x9@rHZa+9Ywh#OecaG!l0$^}*~*_>x2&JKLc99xphaKr2aL8_ZM8;oG%we){^)E_ z08QoRz|cX0Ffv3-|72!_=h*?8G!XK$x5P}UD-#_48>%j$3(0xzPw~co*$4@~f0ZXb z=(Z9P@+hoW^So>0KtV1cbxxhC%F%FR24f<=p0Rg*8?~_ZAkku)V*2PXK+@HGC$*^m z_JU_`81KNFCeD9=@^w&aB1eknJPeN>{qB!O3W$pWOI{hy_zLC2yg*n|!;k?@sif3o zg!nyl%GWcjpd(`?=1ABsx;@q}`Xskr6U$C13ty`fGndRU!Q|=iATz@SR3tO4%t6so z!D@99uaywt(6AnY~iIQp3-5iGlV;fB; z4XEfi0=H2}sqmw4Af;!ap48cOi}ijy_Dflf&^rT>FP2kV!G+O1HM8kTDOtZ}aJ`%z zJmrc0p_k5LWV1)};+m8q9Qq+DKuv&%G1P$-`x@o{Fzf6q?EdZ}@lZicnj|6wOHJ#S z^W5U5m5;T3-*J$<|BQJtdoYaH=ArCmbr*J_N1?@_btRABbSqwzAam3uo-c5xtNd0} zx)D$7A*lxaQ6I{Y9vaq+-;#AwFt*NQy#>S z{!0`7uZcra(0xvU0O^bi|FWoZ(iemS08sNT9qP$}l&i=3jdl{BWT~Qd*0S5dXKKAB zT(2%j1dG4aTleCTqY+N*vW*UELQ&%5U0N7I2Z(MF-9T<2F*kgRG_2KTX;buc)>YIS zJUm;^b!K=H$LnuOkV=*4qmAZ&|I_B$>tSl9k2c*4eSIte-S$0(?Z#2sa;8Z4(0)@q zZFC#%=*)+%Se*L132u>ER;%c5Ki$=&nfH+}d`BBUhoMHrx7!vkJJdURe;?n|{W;1v&gf6aQ7t8i>9?=#? zV%#$D)bkm15ZYdy(8rMBn59c%(SKR?NO%fgvaPGIrhe`XzJpu-7w-EH-zL_67E(k9 zp8mGJLl(aEL!N|94ZSqNx2tka9FX>}1C60s*3G`YoGzlBi024%(kJN|ln-2#B+_Wd zyYEL*RtJ9Gii1#n*=yiLp(2ScR`H$kj4R{jW3j+Q`-B%0eOa_h%|@=s+faFW09-z- zx%7IuFxQdVHE}%`D7(46%qrB1j$)G8j3Wl?$Op5u{D6O9XDC-E*1?PVV_poPXpeRw z_GzDaJd7Sxr-?Y3IVK)o6NffTuVIM5I>pu1RqN7k{Qx`6tF;AczAvyVuALFbOB3?T z$=n=r6G^uRx} z(BG%-UujTHswS$f(`uMP8;XQUZAuq|A?+F!G7PDB)B&RWZMP+WvfLp?ph#w`5_xyCd-_|%zUx~aMimQc#OMLTUV2eRs$?p63VLKKr6?9 zoAhc;XXX%hNhwJnHwnaNZ+m?F!lU$M9d#rI?rvCGd9x%>dRW$jQUVxwNsC-mbVE@D z5kRC`f=~p}ft3q%LQ5ZYdLmke;2L{_emN%_GY?;L5Gs*^x0MN{A!o64RQ+upzduNq z1RJkG;ruos9l*RBVeF4y9bpD3qP(~|iQiEP>7coC^J+yYmU%btGvbsCdEjSGcG9cs z`NS^-7CmUM`(!=jhW!-nrPKj{Vo*r1kQFwS6EQ$J%yd;qL0h|!@R3a&(CrAX8qO+G zh&7Dn2-K$I!p8d3$s@Zt4?KV9BV*>|>|nGl{0O&f!Q5|}cyImTSNL{BU%D+pNZI;l zaz5#?m_BHnYJ+Uz6cezr0SQ2~St?kPKol3H4*6P0iIVmIu>so(Q=C;CnatF0q(!Bz z^^hzVok$VE0c;<3Ei{1JU4#1El`^U7F|9(qIc+r3(SsU5MqJPUVy;5qHuB0>F{?UL z62$;j8W^sZXDsi3qV%L820elc2L1m$6o<99mUtI&VKupVPFLIBVN*XAUW1B`v@Y`F zBKSLCgz1G4Z>Jilmc0Gl(zouW(3TEh#Fm7Q6w*N3IOHsd;az-^hLm4XCT1?!aOz|{ zqkpJB4W%blx&xI%w3}1yZg5{n#YeVDdPfQ%;YuJ7)bZJNq3XTcstZBS28)gnhuf(x zgFy!|8Ud3yEp89Y%3^Wc9@5w;E!kvdD+GJ6>;LX2w_Ol1eA!vP*ck%>$cUh{Uf9I-}IHd_t;RulTE=lNoCawaG)!i@} zE}q@11IC|B68_wrI_Lb7N}R%ofL*X+iA)abOG}`?>D}8l_OK&Yk@Bga;}YJ6$$@m@lxi^ zGo?lvXR`+(yS@0}+i74i$wq>WKFU@Rzb>i7i*yLMR&4AsVP%rF@=Sk`KVR&d8~VpS@tQ_T&iOj>UMi2ubM)o?R~y>(ZS#+-yyr+DzmRMD zu)KGv8H)twB-NL_$bPNNX4>HkJy3A*YlI0Wj`}M{asKbqUn6ta|Ox z5@Xc}M+#3v@dJ(+wdXuxO2%#1du6S*wl-pufe8QUgCDDAmi|tTc~l0UwgI=1U$_VK zLgY<`+dsx?=nZM*@BjdsLxZ_sf3>JWgt5O^>#pH4Lq*>5;fbXRzYOff$G>5TYU>d` z!L(sPA+mjoptOAAz<5Ie*U`*Rp&TXMt_n&IG%fU#@{;R6yV6+SOI2M))BPSvjFS*3 z;L-eXX}%hm$i6Guz(qFarQ&mvXRPYXs4NTI92Tv!oPENv@!56 z7uzeHVIQO5k7rdmR=vRoX?5KbhxH{1i8rIjj?%*uE)@L|sh11HsJSH_sp*h|YWQ4} z^#Obi4j4Yw3MvGGq#dk)o1^g|>CYje(ri-crd5~+Y~+Y=#!Y>(n-&t2YI_nDx170? zUp0JBF$dOjIlsiFAO7Htk_hNE_D(RvZkt8S=AWgopa1L!$9 zUp<+u`J{VDdPf!Lfr(4#C#(lix^bWo>MB1W4B$BZ3nL>xo*_JNwwwlED>9< z*R#8KG(<(E4aT|Ghp;hffg5$ zC6t1y6{AQEz&HgZ>#jH%i1S+uePK)N+o*dC-Om&la^_Egg#2l%b=Gqf^@^q|u8UL_5GIw~+0+JT&aJjGQR-C%F!I(d1IAK$1gI&+-m zf_l4I*n(sY~$B`=XgN@}VBRURbywb1T zDNiWz8Ltb8cGQwH=^Zp-Y;AOjkC{R0lU>*$))yJQxh>qzd9v}7x<-gVT)V(Fx3OGt ztZ`%gv_6{banh2pZqk@+DbT`_waB71or8Lk) z-#1M0p-Ubt3v82V(&*&Hkz6KG%=G8F1lqmS8T!wqv5P<9E$^%&4N}|(92t@)+_e%y zSRss`U@}IP6!5xiAETN$MNR#dv{mvyG1#KAfyNIhNx;3@MM2QcOKD_?m&KKbh7IKd z53mk03+SknG&BY>YTB>G;zASijd9-dCS+Ku1&z~yJryJE!bglu*lJJS_nW&Ir#O5w zBhL>cs<~F^$-$?X4oEVjWnH5DzROGl)XAG`YDbN&fCr>msspFiEHQ@w>|vAYEJ)nc z=&Xiae*cr(R{oLr*VBijpS-NBQ5+a43H9QN%j$~nkcjkD){pfup>-Ct(ReQ2rQo3L zd^$m7s6(5BBBKrp7#`-Hv%b_Oyf=X%A{t*}1)yK(T zx|uR*oCy5hLY>Z~Po>4a>!|l8!ZmM8_3?HtlcjeKu^bMl>wtZg0NF>6rbYamv!Z?I z*Tb>1C(%|c&@v(T4J2zJ1g2W2N5*t8M-uaG@ISfUTwc5e)5N<+;%w}~!G<_HQyreoH8`O+uXBX9J*!g^9XMcir zbWsONWK%+G^CiiqqbmQ};+qJA421K42EWj9E2m3Te6OVp34-bJ1JwZkl#5kJ;q_lq zhxE80Zo9BQ@vw+)hdbOKlLyAS+Ehz{4{PW`>cCHRWH)!e)dBGhXzk{cH7i; z0xH}y;*Dak66LL)@~ASSsiJMy>7E*R%7U{}lrJ}iY+>Er{6={B;7R_UvqHEAb5_*c zu!d3I3zEq2qseVI zV=9f5|C)4u>q**E;#B1j`MIZOq3iO3e(7lU?PQ5Ls9zhLwQXvBt1acRJ)2@FF?sEn zR1fQLKbVWnC#)+$EYUf_Sy?JFW%RaQF1Yd~0fh{z`E|FXbX3uFRNz%rnoV0YXRFvv zh<8BTA!5IV2UEnfslTeZG7N@;tn)5uDSB-*N#DU-lw+$6V4wV$YzYzD4l5RVZh_1f zCd&)iYlX(b#1)3>mLE!{Vx2%zTKs`*KGbaQ>QH44u*cT|pv<)xDKxhqm28I~t^ylv zM~N^D2{+!wtb~o7cq3QfQ^*p4A6L-aJK>q%)5pg*wj$|p?ji;7D(fthdMS-7U!hfT zTy>!AC1dn^KZ#hY)`P&S>l&4Ar_N$}6V|2^rA4iM!ZVOxCicQ#MGkC{yr@vgne>Fot8B9Kz5sQGu6e#SOQlQSH0jWLLj|^Pi&S9$WSDDPPRTko5i#8pt5&wl zuHdd!g}6dlOacn^Y2T7{h0WjIIKt0Ym|wIeGU2OW%-{SLm0Im z3^YCx6QT|qgfliDZW)^C*E4VxuVov;x7CP?DE_Xe3ngQq!JwJElYhEziAs=u>f0;| z+igTTRPOGcB1b=qk5ViCPHptW>`Jx)BvuF)Qy@tpQO~z7g2(Uch+o9&kL!sPH*WCYi6F7V->4h$+Q?HC59>0xs5 zERzGBJCie{okr?@R7a=kiKUQkhr|pp_9hPwkoMSiUyqtAFN9m|^Z8p+&G=tSc1 zxis>h>%{(7$bznA&hNcwuZ3RQO+R<;aqLv-9x0dAGU}VNec!K1i=Xp{4YH3bxc+?w#+7WrGd$G&dG4ldT%i1)}$0!KTN7sdEQvUQU_ahKcBw9VhT`4(pcRw zr*Xkxnk@GZ(E^v!nknX@ff2V7jdIsB`0mg57pNqcb<+v~z26i*;1Lki#vsJ99J}&g zUS2Bc$v?Q%QAWWO-dAB^vRHiKAk$^+-Te)`LeRv*zR87#Xpmr;oDfmB)yHm{e%AB= z?EmB(c4!i#OtcSCD93jTp&dE@1hLS=ZKBPa3#I*;!oQ+>P2?-LEx+;aUp7TwtEfW! zQ@MTs=Xpp&MrV)heqm1i(Cr(h&K`)u=t>4-5xC)ro3uOs0{GrzNPhb!Q30N_915&6CRwDETw9uyWo^y)pvZ8@J?Gm@8cJdXUxU5U6vRRVguC4 z8GjKI3d8Vr<{J?lb)Az>NEq-R?qSK1NPud=dec^vNjdAE|5L>sg7&uiFjf>Z`4V|4 ziUs0m!EMfJHYF+pIamXH3UcbugniZClbUMBzSNiz9m+rN7T=?A4eLKVx!*$n&_vQv z6}E$*xq*{(U`bGhv%$GaCca*lt|njE9R95^)Ao+7 zF!B%3!m!ZTjzJxI-G>uAQj#D5rF8!2`3u(N?p6Y#gTE=H0 zeGl@J7jPV50TXLsA9{hGuik$>4lN4ihS*W0xjB{JgM-N>pwtTLKu2O2d?kmQCs{%% z>Ot_yQ4P{A$^x1N0xHO1Md1QDS`4A5!?$xtzmV-!I6_hIE%9{-qUT4BcQ#y9_40np zh`IyixRk;(UI+hpj&l!WfO)xqIVmM!m-|=YXR52Lk6V;pxk{wH*BxH8H&nJuITCqLm%%M$n6x!7?xdQ5>+{>i@n;Oa5`*hTq>_% zN`NC)Lz78`(IZ9~l5d_=iKF?QsOQ5qKWB-e{OVDS`qEBxTou1{%LP3^41B$y1a zQ)q6A`TjCk=2p^Au=GJgf0899b>O_Xz1QCYpN}*|-{Xy6r@B2(EOF`&%oY}$2}w6t z3qeL|cR3a*r`zdqd1Cp=2?IPTI(4Wae5Z#K+U>k!iOL$0dj3jHCk8j;LeXNOygGE_ z!F_j$HLY*M?kAo>qT}0c<>Ezl2dEsVjr6={>hNs#dq#8}Q6)ct*z}HWz4Hx;DdM$R zBhhik07osKnAw(F<9($;W#+1F_*$wU;r(n{5gnDXX!1+a_!|4qa+UmukR5k5z`+LB zjtlCsA3?+9m+_TfQBrplty}PTHyFXWzLql#sk|L@|&4<(w z@qv=&H`m%7F=aX2KhLA`|D8!W;kO-(Aczgq6MeZ{lf20DSAQQ}-H`V~rI)n##ey-2 z6hz}{C>d4uj06F^4#gwpwJaOlq*1bIO#c{_xjYUNrXx#EM$dWHF+bOq)2`%e=^WX%ibtMKc*dY@ zJslfI!n+KlZGkx@5#{{6CiY_SOTP@6s2!D+mIlDk>(zp+v9o0uwKC(ZdH9+Pv3>nM z>F_7cFGD984wo|b-o44vveooN*xDh#Kc!!ks7UI<{CbGFqbljDa{tE`F5HgIe1R5r z^@PEyBJ<*w4x1@{!0XXilnN4$QgUvgZ&17lEHRi@L8?Jy^gmK}~a0VKqU z9ivJOF+Whv-=i#?X>=5*0=6B%`~%7M_RBDcTEJBf>2C#uX1q&BCy)WArT>jgn}<4} zSRz*w@-=v>F{;f_rTQ^c5r!L<>Nkw^jqNDgi8Vg+UISg~>U7;4Fc+-V?d*T`ScmUE z97mPnI?v(lu#P6q)8ar>ft~?!FA@E=qFf8tdUS95*Q7S8-w&NN6~yyt;1I5KQhb#T zqV79bdY`M0epxf&a(=ay3vVF-P1+N2j zrTx(MH_wz;#1M(UCUwV$bPnaJJZF@ww?~f(&r_iX+kXFm*sr8ockgcJ&eS`|j`4?I zTc2Awc2-=uU!5*~;v%hSx8kr1rM~w!kxU@ASo63_IGJ~C3u<)j=m(z}j6c&;?sFYm zLqbZY0&l~wAoHsIsco__B$BW`iLJ_+|f>-dd>%wT;kaK4FjUg!^5orI<=MH6F->dw5H{A|3Ryr@Y z6icI0pE1K>faq860TzR6xS?znCuKi|Ihwg7<{O`WyDLwq&LS!b}P8~g@~nt1S2QAjK_ zI~B$xCl5v$IW2%P<8!;Zqr?VH23@olc}bi$S$p;;a~F@E(Z&dZR=LV%c^BCr^K@n^ z7+%A-__(-LEwxiq@JL^^-rZ*FYjdTazxsVY?tETse?aI_431W+n4I_{7TCz#E+<_N zTD&6-mvHy!Y3`vi8K4CZc(nKQ2KxNQ4VSRzBNlaaMuQ=smqkmkKWRkN3<6bj2)ijynM8u znF89)lFiM{Jo_1Te0oM#l^?mf;)XUcwV!6;>!mb^tIfYLKXew zf75tXoA$E260E2UtoVMmu8y6ah)--7QHK3TWNXXts68@1e&JeA)YRvtGs@dr^TiO> zxvcGLnV9-iO~WM=t3>d)N8vlRh9vs_c{?-PQ1ONIl*!ov#JXH?@i+rk#W@rmF84LdrtnwNY7c(v>6sv(IZZiTNW74 zV+5Iwt&5(M2*)?ttjCGXj2oQ|9J9Af?wkC}lDP9djA@v|h0_P1Y%JQ6XPUzJqw!Ew zM?JKRSOckMb+pnLgMbS5y{=?^YylQ`vv;i;)ItmcOy~P4e3(PHPK` zCANdj8`wNmK6%PAkdYq^Kl!3Bm62UF_t8Q|hP6($cpx|5d}(UB8CruCQzB(ty=XWx z7KFv&J5)WP`sJ7FiG+$OK(hiA8ie1G$i{Ii@014+ZW7)$+tMbDp@1sLC6{*O>u+KC zQ(y1^NXv6o^|nJC_rpY$grKH_?;TwoL5phxh7=$k!0&5&RG^wt^n7mxb~K!--oco2 ze<%ebV+aSA@x)Lp?P?8`uXdp-Q8@HM`5R&64i{pd)waCFXje^sK@a(X23jmK`OS?k zCU5vGs+DJpVUgF#i-YC9QfDP5jff)o?rK60)H3)juVGewdfxAe;lTMlph8x!#P65E9IqnmQtx7Mos}KjyY2OMQek7gbhwOar=K^jJRowD}~Qme}pkVDV5lmCe`)c|=Uf+b(L3 zY3U2tCna?GY@&RInhY5NisNE8>r{=#@d$LqKc}zj)B~sGZ)I6Yd_;blE-c zf9&_a<2dfN6MFeL-xOg`?v2 ztp81&_Ki)&_5$p8$wBHvwY4&T>ihwtGCR96OS#5~S~0*-6q526V5-f4L+ejY@E#hj^NR2 zIM<`JBW_6kFLp5ZU%c&j(Uh#EmE7WIYPHGWz{}c)SkaWDv7NNQq(v3SlpyHEUs0TL zl}I%yI?YU24hl~LKaRX13A+e`b0_wAejc!#h<38)se;5>8F4JAutYJ#QyLL0+eyk)rV5Bb`L## zs!t|Wz`cyiOv%t^ybwF*4!!u%C^f=GahsMlDX|kl`k5wLfaptuwcp^We{TUq;8T3) z-fgwkgg6G39tFf4)wcsZ3O_iW7pqhiOT2`VW@qy7MiLPH!!95RXM%Qd-Q|eTLwpjJ zd;wzY+5x8nQSX`I|O(L6O6Hn7- zVq^qkv^yWDlo@oA!aja)_UkoGOHn-H}e)_mJQ>!i)PSII-b(wDGC;%_-m z4jv;7@H!$tPpUP~svaF1Vw!BwmOzYw*9Vj;k%w}MvSx!87+e9bxRg03xD)&gc2;7J zRhAMX;!wm74kMhh%X3iNAfNhyP-kzY9UjImL zoQ-KEK*MJ+w8Rk?j_|W9^VFEs1~xkB+;CB|4F+KIQm{}ug+PGKHQrQ5r%?N#K3n^6 zy=9jMonxNXe~ZB6o0vB&4b8IM683+Y(bJC^16N$u+$sA+(`GZ?{W4pzUJdi*``A zEK~FEerLPR7uO$Pu+c=$cRBcV7&DZFZP{dbvjJ7rwqVN(6uBg?zBgyhR4!x3UIlF2 z7vC3=P-G5=FyyxS2f2g>DuJnYWc~iKRTCXNvEOW8=7 z79E1e6%65aX^_o`z1H?FFN$Mp4Hqe+SLs{-)(LK|0V$JuoXje5FluajTym%>+o}UK zQI8P8a+oXa`DHGM~R%oDN14CG!ii*-we%tKPYDQ>ODN2Zbcl(mM3 z6#9iS5%EXqt`hRGVuBzR1Sfc)h+ciJPXw@OVJ>X!S@!sw>TL7`!~>+#t*w z#;8iWly-s5j9(=~8}?lq(~imak~4qx=n2H}I-;i%D>o$La(K8;{l?WnP0Qb&Wf&l` z+*b9NS(L5Tg5qmA9#=24`glFpxbm0L0m=KFW-mEyrtpU^R%{t}V)x@RaOLSIsq(GF zFNMUtw%_v_#s46K;gIWPpJQq}X>^Yce$NkPvXdlU;Y3bY6v}u=j!vU6`!6gB^^rFl zhasj?HMp)WL^yctzi9IQ1Z!fNdxCBAO(0xUR^~F}>ISdjJwm*;0|KX#?YG z9T%xO6dnxbm}&@>Yl7Lu!_`Z0;iF5o5+nM8h#jysi2tSCEHQ%OLM#58pF8dTWmU_6 zHb0Y+`<+?nCHUvZhc9NIN?vB~^PX`k?6D(*rOYDclt|rXEr_&jjMUa>(V@5p-ZO%) z_T$02Vjn=K?!IqVczRaGc?W>BikO8~^9HNd1fO;vPTWzK#G|T8BZHMk*R#b)Znuu% zPkAGuOGQ?zRo1-lQB3q;xhfTMTkXUeYs|E{D=I!W3}WQ*VF)tWM>xWri}mjnHES1J z>oL^)GUgrr`#B}HJ|dsJf}Qt7=wqkGgcT3wkX(;N)=NiqU0Q-j(C&s}A?>lq-ei^Q zpI%K$5-f^Q8h2s7O8C+{aM6>EKU=%iVMM#DdPFXnIR zz(s!xIR(13wvvQxxf-~$vc=FRjempXz~Tb)j*cxCF?C=~WRzk_LKqx3*XFtb*}7)F zfJ8p9*#ZY+<;m;3L)$%g6GO+O zrufd|sAT_lsA^m-|F{B19=Il9voz`xU9)JZsaG}%$%v3A!9bZ1(IE;2soIG?ZO%D7HEgti*_0Wy z>5{h^_?k67Ascv{3#eX3M)WI-7v`ZYpkJ2dkX_3Uv-Bz!D$$H=*w4hh(s0TT zohJFTxy--&n{}bdgThM*D_yQZ(|ofwxkBEAr%adP8=>fLCx7@{bKnI?g9adesY0r% z0!bK@7SEF{>97l58&1&{KT80li34_qdWN8U#0-Ts#&%!34tKi=(z&RD>A+Hcedn9J zEPBtkl;_k+N4xcxXwAh0tlnf{1-_H;%tOUL2IIah^y|#h0(ge^CJY3QnO0ZRgn-=5 z$^Z1jfyJ>LWj}~A#Jdg?D0Og{tm=CpI$w2@ zax|v@-#M8h`6=d|@6fD#LG{08sy({Zu}Tb|{>#J5^}M!td+Xb3pqtBL78vHA0*tEF zkr(P#Z7uPb3GMxBQq~Qpzn85v#+8nVDt?pQWOUo2^>?$-188a+TkI_LU67PFfj|uj z<)&;OZitzP?f*>m+b*^yQx%Q5v?|x0&o+T~GFmL5CiZW>J5PQyj)*46d8%wS9vRZ~ zv4{V3poPGL8*-n`K-At+k3BbrI~GjcL3D&DK`y$Ix5>ek(BA1We$8Qi;)+tK7bG(K z&_-2_&^k2Vg^HwxLEdv%J}6b}c_+NnzEmadM+t-3&Bafdvr-oZq&BofP${;(y2@BHKOz`9NZI6Kh36JA zlk<6+{N+Qtgayiqi-TNC)Y&-)q2pWhR`o0=jQ#q-BvG5>Ot9k5N-If#S@r7K6~?%t zS*kf-Qa3X$F-g&pn$<}G#r6Mdi6l)jEsFwtrk`ddVEr-86KW#BMd(li_5x(3BTKMP z;40w~gyKWzGyZ^Z0npmUR}tchak?WU^n$&NhbOHjF zy~z!^n=3fO7y8N8LQrKC4yqfXnPe0Ivdh%J&cpyzq-^bv_;}R_$e|*;%=CkC8NK^F z5tk|NURaXaS7M0oy;L~_Pih-Aqy5cB(_ot!*PrjHraD7UgPE-S#r|OFM4T^}L>q1N z1R@9bS_e+TR%ENa9~^IqPQ;@4RPa%z!KNXNDgFcfXJ@`-^`rGc4HHdvn_Z#Sx(qa% z`uYtSRyAQs@3N9e)IC;|n%^&5ZHDKJ@Z_K4634qlOywKYc%jT+N*(gbk@9F;T--g~ zHbTrBB+l|2uA3mV5?8e^*+z;!&wU%nhdf>Ic7>sm6U$B}xXVzibkT zj>}veHqT`4F>J>00=ZIv)uW)he^Nz{Ar9X`vq{Ntt)9HbZmx#FiPcI2VR3S7*i@E1 zbwP^M<2P%W`LDzB#x+rS`H!$IX(k4(svPx)nwpxX!NQB(TAi#NjeO!|v$D;uZ7j@+ zpD{2RTE`BdiypaUl9f(Wab4e)W>=luR?jCk;Lvwi*Q;;U`eX+Kdq+c}a5qLSwS06A zq=k#Cv9WMIdsPYy!B_ZBx_qFz*{J%l*sHxajED_~c9(EmF-H5O- z)Z4uJx(vd86S`?-D9Y&k1<-*jnUYI_s8`=JJ)})TD~$_$hGbJobOu zV_aBw@EPHV!mbGYK{5}-A+@@jbpABP0&{u!J_&sNG$G(}#yJfa{8r;}t-Zy||3bjL zbHrj^dS)$3$4o(j=9EQOiLei|8!vBn@vS+a!VAhOFI8p$?fspp=~KfmX?e*Zkr z-}l^e&o$TF-}!t#@AvzqaCs)c|1m0KmqRrhyeUciiU}q4YrKojzdY?Z5BRP=>tc)1 ziG^@Q_X(~h>p!{W{L<3(Qf6?aQAtSC+)Q{t0BFXooMTZT)f|~8{+cOoZt3-( zZ3=kvU1D!*IR=b4U51KKsy}w;Mn5ksEbw3UI1W1QFIqO7S*bkxZ0T}3VqY><_sYp3 zg5Rg~J913~@2}6c(SI^y{eV?)IW9*lLLGzbO_PtIowO-1cb6Hbk5($VFTfbvUxqsz z3y`kQ4`uezA(zJSgF)kjIr{^?YfP6gyL*!iMaA;F`e4uy?5PFeF8fhT=FaS+KVNbR zna6am>K55apQj{N*%$oSObO(m+W1FNMTZ_UzV7-$GGzU9J5O2D$-ZpWlkP`6&yKC$ zsi=@6aM7yX-5~IC_VQR*ujKL7E>7!IeZ$kPU(No#%(j}G&sOb7&XBD--YCN$UgW%& zoC_hnrr^e%>>?rtr!3i%4$rV};%VbEJ{@qf>)QRgTnb_!K%ZGY3hoKj7C{s@)MIO4$r6~ag@1qx5XIalX|2ZU2 zmr`JHhd{fImz5i@8lRC~E&%G?dw8g+(1`(^)FOB_UPd(W)08yoW@tPspb&6-drhzC zg7y{O=bgN{kt3WKQin-rIIM2n%SfRQC(@YfN2vwu_Z#>l0u61i+ zA#>teJCeqV7ZC`4xb%f!A6Q1wZjdg+Iy-A;dMxq0Kqb}A5_Tbc>5?9*i&3c8rJ~05 z-5C)t9)7FOtknOnux2`~=5HsF+!6YizkjF1R3PGWGnD*Cz*^2L&61>4=waXK-+Tf# zLQO}?)p?AkOA0hr=!91N6=IzpG~9Km}hWzXGD9x$7_A1L}ptEc%H$$q0HRbIrf=a`OE^wiXLHG(i8bP5(0NhPKj zPR_+coQo4eaPG8XUje&p#YpY4c@6S;LyiU*mmF8Gv^o!KXwY}zl1PVx$(smX`J>xM z@{i}_N=JH4IkJ6vLV$j|Z*|ymu5GfdVz}`0Xc!wx*|Cy+3PFwVr zG0)4exZAg_YIgnDUFR@;L;@}${-Ilu^=W_;d0dna*Qj2X2UQM^MFL)gr*_$i=OHPf z)1Ti&CZP#;9<1MQi=y19Ypaz@ zJArlXtcy$g)l&NW zcDts7z2>^DDHIVVsQL(-n&9&HFvU8*;QSZ`KV+rojmR|2x+)f{|6YKvfTcHz<beHW!@Ii^1AK?Br$e8vy!#l0f(f0(V#Nlymd^zhS*tsY zce6ZcQ${v;)gdm{iK!Uaxc_dYsOC)y$6#o1vID$Y(?w%GG)v3zL8H1`ANNfQicM{viFmnVm`%E4jFV(Qe0UDNk16VypaViZs*UlSX@; z|MVzTFSJYH?qWU1#-SXwrwTC--aj+p=fPbKi~A%K@tk_rZd#^kw_zPw<7L)%&X>9< zn$A7GQkyk5f^ooYeY_Vys=!(hHYD{Pwh+&pW+>5b8}#F8w2Weh6%iRBTN_0?Iy!1# zY&Ulmv$NK=iO%VWP2uQL)tKFR$=Fum#wC}dxdx&`P3xe@u!#zmH-tYQL13*(1}n8e z;KmK?^xy2u_AI~7|HU}_UM(ZB=3*fqO3`LGo!*~V13PtZ(Lxr*C%3B-pfl&7^(22k zk7tG;A~x74ks)p|8|d?1&%mJE6Y&`xUd0kHS*P@MN73msh~i}be+23UWYa~ z=yLvzV%5M8j}s>tDa=<@(JK}g9!lmT(_gSi`kfsEPL}k5c4ipyW0a&>#vrr49l4uQ z{qo}+xKyTfKtY1YKukpp?->lJ%aO0i0|(1c1~vKDzyZhzz7!9B3TR~@D)>-wVW_Dt zukP2u?2k+OiQ|jy&w4K1sO)Q_w3R@g+A3Q;k&rKAb2|>=!gEm!;6sNQro1snO6ir? zN|)fZw(w@67_Kd~Cgu`OPN%BPD58W7k0&YHm3EEcowNPoUzwC!d@@^L>mc!k;wR|5&j?wo81mSglZ?zrXZA$U7h&m33c1or`w4 z2D=flP?9A2fh)|_il*B`8LQAw9vVFv(pgQXb#lBp;p=+xyCyV)WAzidy(vz0L`K4N zD;Ttvz$I-d?8!_ierW=D3O(l2?wJ|I$~4#ntlFW1ymm8|xam`k^;9#t{ttpoOUp0h z#Y}5^#sG5;`=tqZ&?3lZG=WH*qL833-#amJ!yr1Ze%^cI-Lv>*4rME1+#03?v!}Ge z?h90j7*(allseXYPHoqg*ivP^=2FM{5$Y9^s9*RjPO^@mDTH;L?2))LH3QPV2YhK> z_$_2?#el$)KO@*Mvo2F}awuQejI8Ji2wnF`W`MYp=eQKH&v0GrNjEYvY296)3)&vC zyN44ZlgYIQ8{HKE=ra*A?8t1je6{}H+~Uik6O8E?E)h{t7AzK9NTDn%pxu0@8U(n2 zu$C!UM}O6wcEnX3S9&Fze%GP95s->@A81MRIosx)nbbbVj@3=L@)LA)6<_*?h6`IS ziU~yi-2cS+Ww^|9?f2U0XxR!74n(A>-OZPaJh<;VT9S9*wNl6hTL+zH&czSreBE}NiDXTD9 zSztV2MI$_F#z*zhAtBpVG;H`K-o;B8^DC=W%me>Z&2g5>>=fQ+^tXe6;|NVa!HxZB z)YY0=gb?d(h0|XaE@-yY%WgrV{i9hR`mm9WjXQ|&7o{R09bRp=NwN?N!x;Zg@Nros zvx85UzWuQW4q!VWRrqy?YIlo?vRV@#cO!gU>KZpZ&+ILx!afT%?0>DV{rO|RzD;R` zc59kqQ@~8!85tWl{~oy>U#bid7Z>;KKiFFSaOyZ)x?fdkDt&AJFijJ~u=?AwB4`dV zI6Pc=Terl>Zd4?uoycTQRW78M$TnB|jWbJeQ7NhTHubXF+21A}Z2nb9rM9M8XoU@~ z+Be*n-?!+!0)(l>KF5ee_1`@B-vjm=0(_I;=XMJFe;IJe!Kv551>n;7c7*LXf0M^; z@9M)YsWQ4xs_Bx8`OS1M9^U-=tpOAJtgxwuFh{>69KT$BpTwB{-Mg1X)wne)ue_Tu zt9m?oG}DWxW-|1ADWHep34{v&EkOO59Fj=yYyO8q=W%xIzv9pR56HUz+;TB&{U_?w Wv$bfG)9QfLcf{DhT)$M;CHmhAy749e literal 0 HcmV?d00001 diff --git a/tests/_images/TilingVisual_tile_assignment_touching.png b/tests/_images/TilingVisual_tile_assignment_touching.png new file mode 100644 index 0000000000000000000000000000000000000000..f8b3ac98d90baa23b8cf38055030c8c29fd88291 GIT binary patch literal 9600 zcmb_iWl$Vll*OIk?hZi*m!QD{L4*51AXtE)g9o?Z4nYG1XK)EJI3##*CqQs_-{$*P zYJYCkPF43*kM?`-zH`qx_eQ9zDqv%fW5B_|VZTw7)dZf8pAR$?;4GoNlK?!4y2|Of zzH_v4^#D6t!l{5=o$MW5?QP6y-7TG6Y#bfqg4U}u!_}^@7Y~n$oS%tl6q#u-1lJl+%SqdtFzOG-*w<@Ue9v>MHhPD>-5 zJ#gnRs6+cOnk^?S4ga*5_ZrpbY*=VuXh=3k!ncWSws{${jy?2MBr&Y|soF(HODor~ z(IHL3SJZ!P`@)MfTHax(-S_Ti``yFUipAaKfgaQhe#Z0m+-7oWD$n?byI!po_UY*< zghSkQTOEs>KirgWZ)p05hofJmc9{2D&lf|RgcmO z`#3OPZ-+-s9TOgowm(j z`uH=M9St456Bz4_2!(%Yy3>4}P34*I?ap`7R}dZ{ygNy;Cq$fAu%~{kvjK+K)e*6y zjXRKhH2Eek5ntJqtsCcl##}wG3urc~05mu_c-_nG7ciK}vJOoC7B{cEsyhuuw~1FG zvr3m;Roh=L@Us8Y!(#BhoGYitim#A=Go`o}p{f6afIoWc?QYrjOcl*j2W#ZLT)&Bq zL6G^XV6b=-CJC3UkI$=E^shX8_De0vne{GPBTzk#>vfu^f#IwD+R;BDBqpna9Z@9Q zr)e3zF<4giStBPSZk6eDWJCNM4OZ3c-n4U1>4V79t84aP5DDM5_fcEe_VzaOishYP zC{@&s%j2lctD@NSOwN%F9zM1pW7Md%$~LRev);}_RI!!Owf&-zkzX;tT%mV^n%wry zf>qh6sr@s+PO9-<@A1rfdslyTJfDgt=I1g~n&Mb0=9GE3xDKD(X#5`bOwZP9HZFj*>Nm~bNA5io_r9^g4v^m%dojdO z4htdsyt(t$bE+YjNg!R{#P7y3yXXC&Q;LBZcZMAp*+U_Qat*}|_41F`p9q#!PGAa- z@6SeEx@-2pY!T<@u8VFn#`nD?0$O%vI_Ayq2l0bLLQY!E*AZ|6X|?RlE>kEa{aVX< z5{PFS=oAqsrIglT$ZJEFRgAsMS9l|G$uw+~B%VUvcHe)|Cy z(mjU8NqC%%{;dtWX~;8XLAq+fBw7*Kg^;ZUv3%otBHfLwxpp`Eo*AFCNF<>`k z)rtH0*pNob?Qben$7tgY_)1WfovLsDjJYhO%x#R!X6F^1KIIJVygx^I;g*K?W12!` z$?Jk|t7Y^>u?sVk?e9oljnffi;)vBHPbr3@yC;pkP#v>?)KKqT3ML^`kvjB@tP4_d zeRlTVO^}v;v#LrFxHX` zyWiOP2Oim$y|C8y><8Ho;$FSG)jLp&9J8R$g^d)@0|j>HD&g#Swl=ER^O1F(_Cv~& zZi@%m@+CXz=-&9*uR;Ui1WbaIRR^oH3c8E=c3#eL@Dw

YAL*9x8>^!mew(0>+%Q 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 literal 0 HcmV?d00001 diff --git a/tests/experimental/test_tiling.py b/tests/experimental/test_tiling.py new file mode 100644 index 000000000..d54973bdf --- /dev/null +++ b/tests/experimental/test_tiling.py @@ -0,0 +1,524 @@ +"""Tests for cell-aware tiling logic. + +Uses a deterministic "brick-pattern" grid of rectangular cells on a +500×500 image. Even rows are aligned; odd rows are shifted right by +half a cell width, like bricks in a wall. The image divides into 4 +tiles of 250×250. Because cell positions are predictable we can check +*exactly* which cell lands in which tile. +""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import xarray as xr + +from squidpy.experimental.im._tiling import ( + build_tile_specs, + compute_cell_info, + compute_cell_info_multiscale, + compute_cell_info_tiled, + extract_tile, + extract_tile_lazy, + verify_coverage, +) +from tests.conftest import PlotTester, PlotTesterMeta + +# --------------------------------------------------------------------------- +# Brick-pattern fixture +# --------------------------------------------------------------------------- + +_IMAGE_SIZE = 500 +_CELL_H = 20 +_CELL_W = 30 + + +def _make_brick_labels( + image_size: int = _IMAGE_SIZE, + cell_h: int = _CELL_H, + cell_w: int = _CELL_W, + gap: int = 10, +) -> tuple[np.ndarray, dict[int, tuple[float, float]]]: + """Create a brick-pattern label image and return centroids. + + Parameters + ---------- + image_size + Side length of the square image. + cell_h, cell_w + Height and width of each rectangular cell. + gap + Gap between cells (0 = touching). + + Returns + ------- + labels + ``(image_size, image_size)`` int32 array. + centroids + Mapping from label ID → ``(centroid_y, centroid_x)``. + """ + labels = np.zeros((image_size, image_size), dtype=np.int32) + centroids: dict[int, tuple[float, float]] = {} + + step_y = cell_h + gap + step_x = cell_w + gap + cell_id = 0 + + row_idx = 0 + y = gap // 2 # start with half-gap from top + while y + cell_h <= image_size: + # Odd rows shift right by half a cell+gap step + x_offset = (step_x // 2) if (row_idx % 2 == 1) else 0 + x = x_offset + gap // 2 + while x + cell_w <= image_size: + cell_id += 1 + labels[y : y + cell_h, x : x + cell_w] = cell_id + # Match regionprops centroid: mean of pixel indices [y, y+cell_h-1] + cy = y + (cell_h - 1) / 2.0 + cx = x + (cell_w - 1) / 2.0 + centroids[cell_id] = (cy, cx) + x += step_x + y += step_y + row_idx += 1 + + return labels, centroids + + +def _make_image(image_size: int = _IMAGE_SIZE, n_channels: int = 3) -> np.ndarray: + rng = np.random.default_rng(42) + return rng.integers(0, 255, (n_channels, image_size, image_size), dtype=np.uint8) + + +def _expected_tile_key(cy: float, cx: float, tile_size: int, image_size: int) -> tuple[int, int]: + """Which tile base-grid cell a centroid falls into.""" + max_row = (image_size - 1) // tile_size + max_col = (image_size - 1) // tile_size + row = min(int(cy) // tile_size, max_row) + col = min(int(cx) // tile_size, max_col) + return (row, col) + + +_TILE_SIZE = 250 # 500 / 250 = 2×2 = 4 tiles + + +def _specs_from_labels(labels, tile_size=_TILE_SIZE, overlap_margin="auto"): + """Convenience: compute cell info + build tile specs from a numpy label array.""" + cell_info = compute_cell_info(labels) + return build_tile_specs(labels.shape, cell_info, tile_size=tile_size, overlap_margin=overlap_margin) + + +def _label_ids(labels): + """All nonzero label IDs as a set.""" + ids = set(np.unique(labels).tolist()) + ids.discard(0) + return ids + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(params=[10, 0], ids=["gap=10", "gap=0"]) +def brick_labels(request): + """Brick-pattern labels with gap (non-touching) or without (touching).""" + gap = request.param + labels, centroids = _make_brick_labels(gap=gap) + return labels, centroids, gap + + +@pytest.fixture() +def brick_image(): + return _make_image() + + +# --------------------------------------------------------------------------- +# build_tile_specs — deterministic checks +# --------------------------------------------------------------------------- + + +class TestBuildTileSpecs: + def test_four_tiles(self, brick_labels): + """500×500 with tile_size=250 produces at most 4 tiles.""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + assert len(specs) <= 4 + + def test_full_coverage(self, brick_labels): + """Every cell is assigned to exactly one tile.""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + verify_coverage(_label_ids(labels), specs) + + def test_cell_assigned_to_centroid_tile(self, brick_labels): + """Each cell's tile matches the tile we predict from its centroid.""" + labels, centroids, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + + # Build actual mapping: cell_id → tile base origin + actual: dict[int, tuple[int, int]] = {} + for spec in specs: + for lid in spec.owned_ids: + actual[lid] = (spec.base[0], spec.base[1]) + + for lid, (cy, cx) in centroids.items(): + expected_row, expected_col = _expected_tile_key(cy, cx, _TILE_SIZE, _IMAGE_SIZE) + expected_origin = (expected_row * _TILE_SIZE, expected_col * _TILE_SIZE) + assert actual[lid] == expected_origin, ( + f"Cell {lid} centroid=({cy:.1f},{cx:.1f}): expected tile origin {expected_origin}, got {actual[lid]}" + ) + + def test_no_duplicates(self, brick_labels): + """No cell ID appears in more than one tile.""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + + seen: set[int] = set() + for spec in specs: + overlap = seen & spec.owned_ids + assert not overlap, f"Duplicate cell IDs: {overlap}" + seen |= spec.owned_ids + + def test_boundary_cells_exist(self, brick_labels): + """With the brick offset, some cells straddle the y=250 or x=250 boundary.""" + labels, centroids, gap = brick_labels + # A cell straddles a boundary if its rectangle crosses y=250 or x=250 + # but its centroid is on one side + boundary_cells = [] + for lid, (cy, cx) in centroids.items(): + half_h = _CELL_H / 2.0 + half_w = _CELL_W / 2.0 + y0, y1 = cy - half_h, cy + half_h + x0, x1 = cx - half_w, cx + half_w + crosses_y = y0 < 250 < y1 + crosses_x = x0 < 250 < x1 + if crosses_y or crosses_x: + boundary_cells.append(lid) + + # With cell_h=20 and various gaps, we expect some boundary cells + # (the brick offset makes this likely for odd rows near y=250) + # Just verify they're all assigned somewhere + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + all_owned = set() + for s in specs: + all_owned |= s.owned_ids + for lid in boundary_cells: + assert lid in all_owned, f"Boundary cell {lid} not assigned" + + def test_crop_contains_owned_cells_fully(self, brick_labels): + """Every owned cell's rectangle fits inside its tile's crop region.""" + labels, centroids, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE, overlap_margin="auto") + + for spec in specs: + cy0, cx0, cy1, cx1 = spec.crop + for lid in spec.owned_ids: + cent_y, cent_x = centroids[lid] + # Reconstruct cell pixel range from centroid + # Centroid is mean of [y, y+cell_h-1], so half-extent = (cell_h-1)/2 + cell_y0 = cent_y - (_CELL_H - 1) / 2.0 + cell_y1 = cent_y + (_CELL_H - 1) / 2.0 + cell_x0 = cent_x - (_CELL_W - 1) / 2.0 + cell_x1 = cent_x + (_CELL_W - 1) / 2.0 + assert cy0 <= cell_y0 and cell_y1 <= cy1, ( + f"Cell {lid} y-range [{cell_y0:.0f},{cell_y1:.0f}] not in crop y-range [{cy0},{cy1}]" + ) + assert cx0 <= cell_x0 and cell_x1 <= cx1, ( + f"Cell {lid} x-range [{cell_x0:.0f},{cell_x1:.0f}] not in crop x-range [{cx0},{cx1}]" + ) + + +class TestBuildTileSpecsEdgeCases: + def test_empty_labels(self): + labels = np.zeros((500, 500), dtype=np.int32) + specs = _specs_from_labels(labels, tile_size=250) + assert specs == [] + verify_coverage(_label_ids(labels), specs) + + def test_single_cell_whole_image(self): + """One cell that fills most of the image.""" + labels = np.zeros((500, 500), dtype=np.int32) + labels[10:490, 10:490] = 1 + specs = _specs_from_labels(labels, tile_size=250) + verify_coverage(_label_ids(labels), specs) + assert len(specs) == 1 # centroid is at ~(250,250), lands in one tile + + def test_invalid_tile_size(self): + with pytest.raises(ValueError, match="tile_size must be positive"): + build_tile_specs((100, 100), {}, tile_size=0) + + def test_tile_size_larger_than_image(self): + """tile_size > image → single tile.""" + labels, _ = _make_brick_labels(image_size=100, gap=5) + specs = _specs_from_labels(labels, tile_size=1000) + verify_coverage(_label_ids(labels), specs) + assert len(specs) == 1 + + +# --------------------------------------------------------------------------- +# extract_tile +# --------------------------------------------------------------------------- + + +class TestExtractTile: + def test_non_owned_cells_zeroed(self, brick_labels, brick_image): + """Only owned cells survive in the extracted tile mask.""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + + for spec in specs: + _, tile_lbl = extract_tile(brick_image, labels, spec) + present = set(np.unique(tile_lbl)) + present.discard(0) + assert present == spec.owned_ids, f"Tile base={spec.base}: expected {spec.owned_ids}, got {present}" + + def test_owned_cell_pixels_preserved(self, brick_labels, brick_image): + """Pixel values for owned cells match the original labels.""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + + for spec in specs: + cy0, cx0, cy1, cx1 = spec.crop + _, tile_lbl = extract_tile(brick_image, labels, spec) + for lid in spec.owned_ids: + orig_in_crop = labels[cy0:cy1, cx0:cx1] == lid + tile_matches = tile_lbl == lid + np.testing.assert_array_equal(orig_in_crop, tile_matches) + + def test_original_labels_not_mutated(self, brick_labels, brick_image): + labels, _, _ = brick_labels + labels_copy = labels.copy() + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + for spec in specs: + extract_tile(brick_image, labels, spec) + np.testing.assert_array_equal(labels, labels_copy) + + def test_image_crop_shape(self, brick_labels, brick_image): + """Extracted image has shape (C, crop_h, crop_w).""" + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + for spec in specs: + tile_img, tile_lbl = extract_tile(brick_image, labels, spec) + cy0, cx0, cy1, cx1 = spec.crop + assert tile_img.shape == (3, cy1 - cy0, cx1 - cx0) + assert tile_lbl.shape == (cy1 - cy0, cx1 - cx0) + + +# --------------------------------------------------------------------------- +# End-to-end roundtrip +# --------------------------------------------------------------------------- + + +class TestEndToEnd: + def test_roundtrip_no_cells_lost(self, brick_labels, brick_image): + """Build specs → extract tiles → union of labels == all cells.""" + labels, centroids, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + verify_coverage(_label_ids(labels), specs) + + recovered: set[int] = set() + for spec in specs: + _, tile_lbl = extract_tile(brick_image, labels, spec) + tile_ids = set(np.unique(tile_lbl)) + tile_ids.discard(0) + assert tile_ids == spec.owned_ids + recovered |= tile_ids + + assert recovered == set(centroids.keys()) + + def test_touching_cells_no_merge(self): + """With gap=0, adjacent cells still get distinct labels and assignments.""" + labels, centroids = _make_brick_labels(gap=0) + n_cells = len(centroids) + assert n_cells > 0 + + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + verify_coverage(_label_ids(labels), specs) + + # Total owned cells across all tiles == total cells + total_owned = sum(len(s.owned_ids) for s in specs) + assert total_owned == n_cells + + def test_nontouching_cells_same_result(self): + """With gap=10, same coverage guarantees hold.""" + labels, centroids = _make_brick_labels(gap=10) + n_cells = len(centroids) + assert n_cells > 0 + + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + verify_coverage(_label_ids(labels), specs) + + total_owned = sum(len(s.owned_ids) for s in specs) + assert total_owned == n_cells + + +# --------------------------------------------------------------------------- +# Visual test — tile assignment plot +# --------------------------------------------------------------------------- + +# Tile colors: one distinct color per tile quadrant +_TILE_COLORS = [ + (0.12, 0.47, 0.71), # blue — top-left + (1.00, 0.50, 0.05), # orange — top-right + (0.17, 0.63, 0.17), # green — bottom-left + (0.84, 0.15, 0.16), # red — bottom-right +] + + +def _plot_tile_assignment(labels, specs, title=""): + """Render each cell colored by its owning tile, with grid lines.""" + rgb = np.ones((*labels.shape, 3), dtype=np.float32) # white background + + for i, spec in enumerate(specs): + color = _TILE_COLORS[i % len(_TILE_COLORS)] + for lid in spec.owned_ids: + mask = labels == lid + rgb[mask] = color + + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + ax.imshow(rgb, origin="upper") + + # Draw tile base-grid lines + for spec in specs: + by0, bx0, by1, bx1 = spec.base + rect = plt.Rectangle( + (bx0 - 0.5, by0 - 0.5), + bx1 - bx0, + by1 - by0, + linewidth=1.5, + edgecolor="black", + facecolor="none", + linestyle="--", + ) + ax.add_patch(rect) + + ax.set_xlim(-0.5, labels.shape[1] - 0.5) + ax.set_ylim(labels.shape[0] - 0.5, -0.5) + ax.set_title(title or "Tile assignment") + ax.set_xlabel("x") + ax.set_ylabel("y") + + +# --------------------------------------------------------------------------- +# Lazy / multiscale helpers +# --------------------------------------------------------------------------- + + +def _make_multiscale_tree(labels: np.ndarray, n_scales: int = 3) -> xr.DataTree: + """Build a tiny multiscale DataTree by integer-downsampling.""" + scales: dict[str, xr.DataTree] = {} + for i in range(n_scales): + step = 2**i + sub = labels[::step, ::step] + ds = xr.Dataset({"image": xr.DataArray(sub, dims=("y", "x"))}) + scales[f"scale{i}"] = xr.DataTree(ds) + return xr.DataTree.from_dict(scales) + + +class TestComputeCellInfoMultiscale: + def test_target_is_coarsest_matches_eager(self): + labels, _ = _make_brick_labels(gap=10) + tree = _make_multiscale_tree(labels, n_scales=3) + # scale2 is coarsest. Target it -> use that scale directly. + info_ms = compute_cell_info_multiscale(tree, target_scale="scale2") + info_eager = compute_cell_info(tree["scale2"].ds["image"].values) + assert set(info_ms.keys()) == set(info_eager.keys()) + for lid in info_ms: + assert info_ms[lid].centroid_y == pytest.approx(info_eager[lid].centroid_y, abs=0.5) + assert info_ms[lid].centroid_x == pytest.approx(info_eager[lid].centroid_x, abs=0.5) + + def test_rescale_to_finer(self): + labels, _ = _make_brick_labels(gap=10) + tree = _make_multiscale_tree(labels, n_scales=3) + info_ms = compute_cell_info_multiscale(tree, target_scale="scale0") + info_eager = compute_cell_info(labels) + # Centroids should be close (within ~1 px due to coarse-scale quantization) + assert set(info_ms.keys()) == set(info_eager.keys()) + for lid in info_ms: + assert info_ms[lid].centroid_y == pytest.approx(info_eager[lid].centroid_y, abs=4.0) + assert info_ms[lid].centroid_x == pytest.approx(info_eager[lid].centroid_x, abs=4.0) + + +class TestComputeCellInfoTiled: + def test_matches_eager_no_cell_spans_tiles(self): + labels, _ = _make_brick_labels(gap=10) # cells are 20x30, well below chunk + labels_da = xr.DataArray(labels, dims=("y", "x")) + info_tiled = compute_cell_info_tiled(labels_da, chunk_size=128) + info_eager = compute_cell_info(labels) + assert set(info_tiled.keys()) == set(info_eager.keys()) + for lid in info_eager: + assert info_tiled[lid].centroid_y == pytest.approx(info_eager[lid].centroid_y, abs=1e-6) + assert info_tiled[lid].centroid_x == pytest.approx(info_eager[lid].centroid_x, abs=1e-6) + assert info_tiled[lid].bbox_h == info_eager[lid].bbox_h + assert info_tiled[lid].bbox_w == info_eager[lid].bbox_w + + def test_matches_eager_cells_span_tile_boundary(self): + # A 100x100 cell crossing chunk boundary at 50. + labels = np.zeros((200, 200), dtype=np.int32) + labels[30:130, 30:130] = 1 + labels_da = xr.DataArray(labels, dims=("y", "x")) + info_tiled = compute_cell_info_tiled(labels_da, chunk_size=50) + info_eager = compute_cell_info(labels) + assert set(info_tiled.keys()) == set(info_eager.keys()) + for lid in info_eager: + assert info_tiled[lid].centroid_y == pytest.approx(info_eager[lid].centroid_y, abs=1e-6) + assert info_tiled[lid].centroid_x == pytest.approx(info_eager[lid].centroid_x, abs=1e-6) + assert info_tiled[lid].bbox_h == info_eager[lid].bbox_h + assert info_tiled[lid].bbox_w == info_eager[lid].bbox_w + + def test_empty_labels(self): + labels = np.zeros((100, 100), dtype=np.int32) + labels_da = xr.DataArray(labels, dims=("y", "x")) + assert compute_cell_info_tiled(labels_da, chunk_size=32) == {} + + +class TestExtractTileLazy: + def test_matches_eager(self, brick_labels, brick_image): + labels, _, _ = brick_labels + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + labels_da = xr.DataArray(labels, dims=("y", "x")) + image_da = xr.DataArray(brick_image, dims=("c", "y", "x")) + for spec in specs: + img_e, lbl_e = extract_tile(brick_image, labels, spec) + img_l, lbl_l = extract_tile_lazy(image_da, labels_da, spec) + np.testing.assert_array_equal(img_e, img_l) + np.testing.assert_array_equal(lbl_e, lbl_l) + + +class TestVerifyCoverage: + def test_detects_duplicate(self): + spec_a = build_tile_specs((100, 100), {1: _make_ci(1, 25, 25)}, tile_size=50) + spec_b = build_tile_specs((100, 100), {1: _make_ci(1, 25, 25)}, tile_size=50) + with pytest.raises(ValueError, match="multiple tiles"): + verify_coverage({1}, spec_a + spec_b) + + def test_detects_missing(self): + specs = build_tile_specs((100, 100), {}, tile_size=50) + with pytest.raises(ValueError, match="not assigned"): + verify_coverage({42}, specs) + + def test_detects_extra(self): + specs = build_tile_specs((100, 100), {1: _make_ci(1, 25, 25)}, tile_size=50, overlap_margin=0) + with pytest.raises(ValueError, match="non-existent"): + verify_coverage(set(), specs) + + +def _make_ci(label: int, cy: float, cx: float, h: int = 4, w: int = 4): + from squidpy.experimental.im._tiling import CellInfo + + return CellInfo(label=label, centroid_y=cy, centroid_x=cx, bbox_h=h, bbox_w=w) + + +class TestTilingVisual(PlotTester, metaclass=PlotTesterMeta): + def test_plot_tile_assignment_gap(self): + """Visual: brick pattern (gap=10), cells colored by tile.""" + labels, _ = _make_brick_labels(gap=10) + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + _plot_tile_assignment(labels, specs, title="Tile assignment (gap=10)") + + def test_plot_tile_assignment_touching(self): + """Visual: brick pattern (gap=0, touching), cells colored by tile.""" + labels, _ = _make_brick_labels(gap=0) + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) + _plot_tile_assignment(labels, specs, title="Tile assignment (gap=0, touching)") From 91e2f3e3f746fc9f1e083ca606b9a940c8d2c509 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 28 May 2026 00:30:51 +0200 Subject: [PATCH 2/2] Apply simplify-review fixes to PR-1 - Replace unicode (multiplication sign, em-dash, arrow) with ASCII per the no-unicode repo rule - Assert test_boundary_cells_exist's boundary_cells list is non-empty so the test cannot pass vacuously if the fixture stops producing boundary-straddling cells - Read channel count from the fixture in test_image_crop_shape instead of hardcoding 3 - Hoist CellInfo into the module-level import and move _make_ci to the top of the file, removing the forward reference - Pass a non-empty cell_info to test_invalid_tile_size so the test pins the tile_size guard, not an empty-dict short-circuit - Drop the dead `gap` element from the brick_labels fixture return; no consumer used it past the comment - Drop the redundant TestEndToEnd.test_touching_cells_no_merge and test_nontouching_cells_same_result; the parametrised test_roundtrip_no_cells_lost already covers both gap values with a stronger invariant --- tests/experimental/test_tiling.py | 110 +++++++++++++----------------- 1 file changed, 46 insertions(+), 64 deletions(-) diff --git a/tests/experimental/test_tiling.py b/tests/experimental/test_tiling.py index d54973bdf..71884d7bd 100644 --- a/tests/experimental/test_tiling.py +++ b/tests/experimental/test_tiling.py @@ -1,9 +1,9 @@ """Tests for cell-aware tiling logic. Uses a deterministic "brick-pattern" grid of rectangular cells on a -500×500 image. Even rows are aligned; odd rows are shifted right by +500x500 image. Even rows are aligned; odd rows are shifted right by half a cell width, like bricks in a wall. The image divides into 4 -tiles of 250×250. Because cell positions are predictable we can check +tiles of 250x250. Because cell positions are predictable we can check *exactly* which cell lands in which tile. """ @@ -15,6 +15,7 @@ import xarray as xr from squidpy.experimental.im._tiling import ( + CellInfo, build_tile_specs, compute_cell_info, compute_cell_info_multiscale, @@ -56,7 +57,7 @@ def _make_brick_labels( labels ``(image_size, image_size)`` int32 array. centroids - Mapping from label ID → ``(centroid_y, centroid_x)``. + Mapping from label ID -> ``(centroid_y, centroid_x)``. """ labels = np.zeros((image_size, image_size), dtype=np.int32) centroids: dict[int, tuple[float, float]] = {} @@ -99,7 +100,7 @@ def _expected_tile_key(cy: float, cx: float, tile_size: int, image_size: int) -> return (row, col) -_TILE_SIZE = 250 # 500 / 250 = 2×2 = 4 tiles +_TILE_SIZE = 250 # 500 / 250 = 2x2 = 4 tiles def _specs_from_labels(labels, tile_size=_TILE_SIZE, overlap_margin="auto"): @@ -115,6 +116,11 @@ def _label_ids(labels): return ids +def _make_ci(label: int, cy: float, cx: float, h: int = 4, w: int = 4) -> CellInfo: + """Build a CellInfo for tests that need a minimal hand-constructed cell.""" + return CellInfo(label=label, centroid_y=cy, centroid_x=cx, bbox_h=h, bbox_w=w) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -123,9 +129,7 @@ def _label_ids(labels): @pytest.fixture(params=[10, 0], ids=["gap=10", "gap=0"]) def brick_labels(request): """Brick-pattern labels with gap (non-touching) or without (touching).""" - gap = request.param - labels, centroids = _make_brick_labels(gap=gap) - return labels, centroids, gap + return _make_brick_labels(gap=request.param) @pytest.fixture() @@ -134,29 +138,29 @@ def brick_image(): # --------------------------------------------------------------------------- -# build_tile_specs — deterministic checks +# build_tile_specs - deterministic checks # --------------------------------------------------------------------------- class TestBuildTileSpecs: def test_four_tiles(self, brick_labels): - """500×500 with tile_size=250 produces at most 4 tiles.""" - labels, _, _ = brick_labels + """500x500 with tile_size=250 produces at most 4 tiles.""" + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) assert len(specs) <= 4 def test_full_coverage(self, brick_labels): """Every cell is assigned to exactly one tile.""" - labels, _, _ = brick_labels + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) verify_coverage(_label_ids(labels), specs) def test_cell_assigned_to_centroid_tile(self, brick_labels): """Each cell's tile matches the tile we predict from its centroid.""" - labels, centroids, _ = brick_labels + labels, centroids = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) - # Build actual mapping: cell_id → tile base origin + # Build actual mapping: cell_id -> tile base origin actual: dict[int, tuple[int, int]] = {} for spec in specs: for lid in spec.owned_ids: @@ -171,7 +175,7 @@ def test_cell_assigned_to_centroid_tile(self, brick_labels): def test_no_duplicates(self, brick_labels): """No cell ID appears in more than one tile.""" - labels, _, _ = brick_labels + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) seen: set[int] = set() @@ -182,9 +186,9 @@ def test_no_duplicates(self, brick_labels): def test_boundary_cells_exist(self, brick_labels): """With the brick offset, some cells straddle the y=250 or x=250 boundary.""" - labels, centroids, gap = brick_labels + labels, centroids = brick_labels # A cell straddles a boundary if its rectangle crosses y=250 or x=250 - # but its centroid is on one side + # but its centroid is on one side. boundary_cells = [] for lid, (cy, cx) in centroids.items(): half_h = _CELL_H / 2.0 @@ -196,9 +200,10 @@ def test_boundary_cells_exist(self, brick_labels): if crosses_y or crosses_x: boundary_cells.append(lid) - # With cell_h=20 and various gaps, we expect some boundary cells - # (the brick offset makes this likely for odd rows near y=250) - # Just verify they're all assigned somewhere + # Fail loudly if the fixture stops producing boundary cells: this + # test is otherwise a no-op and silently misses regressions. + assert boundary_cells, "Fixture produced no tile-boundary cells; test would pass vacuously." + specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) all_owned = set() for s in specs: @@ -208,7 +213,7 @@ def test_boundary_cells_exist(self, brick_labels): def test_crop_contains_owned_cells_fully(self, brick_labels): """Every owned cell's rectangle fits inside its tile's crop region.""" - labels, centroids, _ = brick_labels + labels, centroids = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE, overlap_margin="auto") for spec in specs: @@ -245,11 +250,14 @@ def test_single_cell_whole_image(self): assert len(specs) == 1 # centroid is at ~(250,250), lands in one tile def test_invalid_tile_size(self): + # Pass a non-empty cell_info so the test exercises the tile_size guard + # rather than an empty-dict short-circuit if validation order ever shifts. + ci = {1: CellInfo(label=1, centroid_y=50, centroid_x=50, bbox_h=4, bbox_w=4)} with pytest.raises(ValueError, match="tile_size must be positive"): - build_tile_specs((100, 100), {}, tile_size=0) + build_tile_specs((100, 100), ci, tile_size=0) def test_tile_size_larger_than_image(self): - """tile_size > image → single tile.""" + """tile_size > image -> single tile.""" labels, _ = _make_brick_labels(image_size=100, gap=5) specs = _specs_from_labels(labels, tile_size=1000) verify_coverage(_label_ids(labels), specs) @@ -264,7 +272,7 @@ def test_tile_size_larger_than_image(self): class TestExtractTile: def test_non_owned_cells_zeroed(self, brick_labels, brick_image): """Only owned cells survive in the extracted tile mask.""" - labels, _, _ = brick_labels + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) for spec in specs: @@ -275,7 +283,7 @@ def test_non_owned_cells_zeroed(self, brick_labels, brick_image): def test_owned_cell_pixels_preserved(self, brick_labels, brick_image): """Pixel values for owned cells match the original labels.""" - labels, _, _ = brick_labels + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) for spec in specs: @@ -287,7 +295,7 @@ def test_owned_cell_pixels_preserved(self, brick_labels, brick_image): np.testing.assert_array_equal(orig_in_crop, tile_matches) def test_original_labels_not_mutated(self, brick_labels, brick_image): - labels, _, _ = brick_labels + labels, _ = brick_labels labels_copy = labels.copy() specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) for spec in specs: @@ -296,12 +304,13 @@ def test_original_labels_not_mutated(self, brick_labels, brick_image): def test_image_crop_shape(self, brick_labels, brick_image): """Extracted image has shape (C, crop_h, crop_w).""" - labels, _, _ = brick_labels + labels, _ = brick_labels + n_channels = brick_image.shape[0] specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) for spec in specs: tile_img, tile_lbl = extract_tile(brick_image, labels, spec) cy0, cx0, cy1, cx1 = spec.crop - assert tile_img.shape == (3, cy1 - cy0, cx1 - cx0) + assert tile_img.shape == (n_channels, cy1 - cy0, cx1 - cx0) assert tile_lbl.shape == (cy1 - cy0, cx1 - cx0) @@ -312,8 +321,8 @@ def test_image_crop_shape(self, brick_labels, brick_image): class TestEndToEnd: def test_roundtrip_no_cells_lost(self, brick_labels, brick_image): - """Build specs → extract tiles → union of labels == all cells.""" - labels, centroids, _ = brick_labels + """Build specs -> extract tiles -> union of labels == all cells.""" + labels, centroids = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) verify_coverage(_label_ids(labels), specs) @@ -327,42 +336,21 @@ def test_roundtrip_no_cells_lost(self, brick_labels, brick_image): assert recovered == set(centroids.keys()) - def test_touching_cells_no_merge(self): - """With gap=0, adjacent cells still get distinct labels and assignments.""" - labels, centroids = _make_brick_labels(gap=0) - n_cells = len(centroids) - assert n_cells > 0 - - specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) - verify_coverage(_label_ids(labels), specs) - - # Total owned cells across all tiles == total cells - total_owned = sum(len(s.owned_ids) for s in specs) - assert total_owned == n_cells - def test_nontouching_cells_same_result(self): - """With gap=10, same coverage guarantees hold.""" - labels, centroids = _make_brick_labels(gap=10) - n_cells = len(centroids) - assert n_cells > 0 - - specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) - verify_coverage(_label_ids(labels), specs) - - total_owned = sum(len(s.owned_ids) for s in specs) - assert total_owned == n_cells +# Note: gap=0 (touching) and gap=10 (non-touching) are both covered by +# test_roundtrip_no_cells_lost via the brick_labels fixture's parametrisation. # --------------------------------------------------------------------------- -# Visual test — tile assignment plot +# Visual test - tile assignment plot # --------------------------------------------------------------------------- # Tile colors: one distinct color per tile quadrant _TILE_COLORS = [ - (0.12, 0.47, 0.71), # blue — top-left - (1.00, 0.50, 0.05), # orange — top-right - (0.17, 0.63, 0.17), # green — bottom-left - (0.84, 0.15, 0.16), # red — bottom-right + (0.12, 0.47, 0.71), # blue - top-left + (1.00, 0.50, 0.05), # orange - top-right + (0.17, 0.63, 0.17), # green - bottom-left + (0.84, 0.15, 0.16), # red - bottom-right ] @@ -475,7 +463,7 @@ def test_empty_labels(self): class TestExtractTileLazy: def test_matches_eager(self, brick_labels, brick_image): - labels, _, _ = brick_labels + labels, _ = brick_labels specs = _specs_from_labels(labels, tile_size=_TILE_SIZE) labels_da = xr.DataArray(labels, dims=("y", "x")) image_da = xr.DataArray(brick_image, dims=("c", "y", "x")) @@ -504,12 +492,6 @@ def test_detects_extra(self): verify_coverage(set(), specs) -def _make_ci(label: int, cy: float, cx: float, h: int = 4, w: int = 4): - from squidpy.experimental.im._tiling import CellInfo - - return CellInfo(label=label, centroid_y=cy, centroid_x=cx, bbox_h=h, bbox_w=w) - - class TestTilingVisual(PlotTester, metaclass=PlotTesterMeta): def test_plot_tile_assignment_gap(self): """Visual: brick pattern (gap=10), cells colored by tile."""