From dee6cf773db73cfb6718f103a80ac2fb61fc661b Mon Sep 17 00:00:00 2001 From: matei jordache Date: Wed, 1 Apr 2026 20:01:25 -0700 Subject: [PATCH] remove matplotlib --- backend/baked_colormaps.py | 207 +++++++++++++++++++++++++++++++++++++ backend/data_types.py | 16 +-- backend/execution.py | 41 +++++--- backend/nodes/save.py | 93 ++++++++++++++--- pyproject.toml | 1 - 5 files changed, 322 insertions(+), 36 deletions(-) create mode 100644 backend/baked_colormaps.py diff --git a/backend/baked_colormaps.py b/backend/baked_colormaps.py new file mode 100644 index 0000000..dc878a1 --- /dev/null +++ b/backend/baked_colormaps.py @@ -0,0 +1,207 @@ +""" +Baked colormap look-up tables (256 x 3, uint8). +Generated from matplotlib colormaps so the runtime has no matplotlib dependency. +""" +import base64 +import numpy as np + + +def _d(s: str) -> np.ndarray: + return np.frombuffer(base64.b85decode(s), dtype=np.uint8).reshape(256, 3) + +_VIRIDIS = _d( + 'L;+Mp0#-!?S49O`Mh99(2wX-AT}BIDM-5;{4q-L54v>he1S#Kt+f^MuZ' + '1WR5arjxlDAF=vi3XpS&xjxcJDFKdo3Y>qB%jxKJFE^m%4aE>i;jx2GGEOL%4bB-%?jw^MJDt3-4' + 'caAD}jwyMLDSD16dyXi4jwpSOCw`76e~u@BjwXSQCW4M8f{rDFjwOVSC54V8hK?kMjw6VUBZ-b9i' + 'j5+RjUtSVB8`n9j*TIYjUkYYA(4zAl8hjdj3ASYAC!zAm5U#iiyxPZADD|DnTj8piXNMa9-N6Dor' + 'xcwi65VcAE1aIp@<)$haaMcAftvLq=q4-g(0SeBBzBSsDvY_gCwehC98rZtb!)3fhVnjD6W7huYW' + '4Ae=MddnaYVmxM!;`K!EQ>' + 'yZA`*!PQzo&o$7Nc`WL(H&Uddu$%3x#5UuDZ)XUtq_&0B2ET5ir*aL-qB&{lQO' + 'RCm!+deKpQ(olcXPJz=+gw#uh)k%rfNQ>4+j@Ctx*F%%nL6z7*nAkp>**u-uJD}P*qS`m5+cv1%G' + '^*S(t=urM-7m7;Ew$b&xZWwc-YC7_Ccxh%!rvps;33H1Aj;t%&EXx;;T+Q98`a_(*y0)5;~CxK7~' + 'kU<;^Y|R6' + 'ciK{6%`g178e&67#J8C85tTH8XFrM92^`S9UUGX9v>ecARr(iAt53nA|oRsBqSsyB_$>%CMPE+C@' + '3f?DJd!{Dl021EG#T7EiEoCE-x=HFfcGNF)=bSGBYzXG&D3dH8nOiHa9mnI5;>tIXOByIy*Z%JUl' + '!-Jv}}?K0iM{KtMo2K|w-7LPJACL_|bIMMXwNMn^|SNJvOYNl8jdN=r*iOiWBoO-)WtPESuyP*6}' + '&QBhJ-Qd3h?R8&+|RaI72R##V7SXfwDSy@_IT3cINTwGjTU0q&YUSD5dU|?WjVPRroVq;@tWMpJz' + 'Wo2e&W@l$-XlQ6@X=!R|YHMq2Y;0_8ZEbFDZf|dIaBy&OadC2Ta&vQYbaZreb#-=jc6WDoczAeud' + '3kzzdV70&e0+R;eSLm@et&;|fPjF3fq{a8f`fyDgoK2Jg@uNOhKGlTh=_=ZiHVAeii?YjjEszpjg' + '5|uj*pLzkdTm(k&%*;l9Q8@l$4Z}m6ev3mY0{8n3$NEnVFiJnwy)OoSdAUot>VZo}ZteprD|kp`o' + 'IpqNAguq@<*!rKP5(rl+T;sHmu^si~@}s;jH3tgNi9t*x%EuCK4Ju&}VPv9YqUva_?Zw6wIfwY9d' + 'kwzs#pxVX5vxw*Q!y1To(yu7@dCU$jHda$;ryf' + '%FD~k%*@Qq&CSlv&d<-!(9qD)(b3Y<($mw^)YR0~)z#M4*4Nk9*x1lt)=I7_<=;-L_>FMg~>g((4?Ck9A?d|UF?(gsK@bK{Q@$vHV^7' + 'Hfa^z`)g_4W4l_V@Sq`1ttw`T6?#`uqF){QUg={r&#_{{R2~' +) + +_HOT = _d( + '3jhEO000mG01^NI6#xJj000^Q0384TAOHX&001Qb04D$dDgXd2001ul05SjoH2?rN0024w06YKyK' + 'L7wi002Y)07n1-N&o;%002(_08sz{Q~&^1003D409*h7UjP7M003kF0A>IHX#fCh003?P0C4~SbN' + '~Q$004Oa0DAxcegFW0004sk0EPeni2wkL0052v0FVFxlK=pg005W(0Gj{+o&W%#005%^0Hpu`r~m' + '+~006B30I&c6vj70K006iE0J;DGy#N5f006=O0L1_R$N&J!007MZ0M7sb(f|O}007qj0NMZm-2ed' + 'J0080u0OSAw=Kuie008U&0Pg?*@&Ewz008#@0Qmp_`~U#|009320RII5{|Es83IP8N0RIpG{}KTI' + '6#)Mi0RI{Q{~ZAT9{~R%0RJTb|0e+dDggg10RJxl|1tpoGywlM0RK7w|2zQyKLGzh0RKb)|3?7-N' + 'dW&$0RK+_|4{({Q~>{00RLG4|6Bn7UI71L0RLnF|7HOHX#oFg0RL_P|8W5Sa{&K#0RMRa|9b%ceg' + 'OY~0RMvk|AqknhyeeK0RN5v|BwLxlK}sf0RNZ(|C<2+odEx!0RN%@|D^!`r~v<}0ROE3|F8i6vH<' + '_J0ROiD|GEJGy#W8e0RO@O|HT0R#{mDz0RPMY|IYyb(g6R|0RPtj|Jnfm+yMXI0RQ0t|KtGw=K%l' + 'd0RQX&|L*|*@c{qy0RQ#?|M>v_`~d&{0RR63|NjU7{|o>B5C8uY|Nj^N{~Q1RAOHU%|Nkcc|0@6g' + 'F8}{B|Nl1s|2qHwKL7th|Nln+|4RS=PXGT>|NmD1|62e5UjP4L|NmwG|7rjKZU6sq|NnLW|9Suae' + 'gFS~|Nn*m|B3(qjsO3V|NoW$|C#^)o&W!!|No@_|ET}}tpES8|NpfA|G5AEy#N2e|Nq4Q|H%LU%>' + 'Vz;|Nqqg|JeWk-2eaI|NrCv|L6bz>;M1n|Nry<|M&m@`~Uy{' +) + +_JET = _d( + '004jh0E7Sli2wkN005Ez0G0p%n*acx005)_0H^=}tpEVB006cC0J{JHzW@Nl0077U0L=gZ(EtF}0' + '07zm0N(%r;{X8Y008X(0Pz3-_5c9-009300RI30{{R6000I911pfdD{{RjD01^KH6#oDj{{S8T03' + 'rVXB>w;@{{Suj05SgnH2(lO{{TJz073r%ME?Lu{{T(@08#${RQ~{3{{UV80Ac?CWd8tZ{{U_O0CE' + '2SbpHT({{Vge0D=Dih5rDE{{W5u0FnOymHz;l{{Wr;0HOZ?rT+k^{{XH30I~l7wf_LQ{{X%J0Kxw' + 'N#s2`v{{YSZ0MY*d)&Bt5{{Y?o0O9)p<@f^V^akzm3Gwa>_3ID$=M(+p7XRTH|K1$`+aLeeBmdMU' + '|IsP`&Mg1RF#pFh|HL-`!8!lFJpZ~t|F=W`v_}82N&l@)|Ef^`r&Ir=R{x+{|D9d`nqdEzWB-(9|' + 'B!0`jcxymaQ}yM|ATk`fO`LZegArZ|96A`bBF(MivMkm|7wu`W|aS9m;Ydz|6QH`TA=?{r2kW=|4' + '^#`O|Ab)vHwQ2|3kO`K)U}tzW+JF|2D+`Gspih%Kt3R|0&V`Ce;5U*Z&{e{~X@`8R7pHO({_Oz&>Hz=d0RQ3u|K0%q+W`O90RPhf|Ih&c%mDw$0RO}Q|G@zNy#W8' + 'Y0ROfC|FQu8t^oh50RN`||Dyo^p8)@y0RNZ(|C9j#kpTaV0RM>q|Aqknf&l-10RMUb|91fYa{&Ku' + '0RLSHYyhZk0H|*OsBr+OasjAx0jPBWsCNOVcmb' + '$-0;qcesC@°mk01gL=or-KEjgaxOD2BwDxricipiV3BQ3Z;z;rH%}wj}4@e4x^F}qm&S$l@Xz' + 'q6QP(CpqUk)niij&7oMFMot_z;pcjJ7a~w=s&iGKsn~h`Th1yfuftH-^4Ag}*t3z&eA$JA%SIfx|t3#6Ew;K' + 'z_zSeaAw4$U}R{M0(0addo(6%tv_4Nq5akcFs$6&rEdCO>@ysa?($6(@}BMQgGE%Z`D+8)>UoSR&' + '3Z;YuQ+8+FEMbTWQ-|Xxv?A-CkziUuE85WZz' + 'PXTIg_D>2X-;a#!kfSL=0F>vmP_cU0|pQ|)?E?t4-1d{OUyQ1E|G@PJP7flcv)O!9BLim(H_?1BUmp}QKKKYtH`kOuaoILuTJNutH`=L4eqB#7c' + 'IQ*qI{H8Yis5ShlH2teH{j4+nt}^|vG5xVH{jx9pv@iX&F8;SI{yz$yL0D' + 'gDDJ{lzE!#wY#AC;iGM{mUl&%_aQKCH&AO{L&@+(UGS`q(7;+9dhhB>CMX_}?Y?;U)LtCH' + 'Led_U0z`=qB~)CiUwl^zA3}?kDr`C-U+q@$@F~^(FB4B=Gqm' +) + +_INFERNO = _d( + '000C500jX71_1#G0s#sF0t^BI4g&%Z1OpNT0~7@W7X}0w2L&4l1sw?n9|{K|3kM_&2qp~(DGmuM5' + 'D6_23NR81G879m6$>{O3pp4JJQ)l=8w^1l3_~6aMIQ`EAq+|*3``^pPbLgeC<{|53sox%S1k)!FA' + '7^R3S2S@UNj0{HVI)k31T@3V>=0CJqcw#31&bFXF>{SL<(p{3Ta0RYDo)fN(^gE3~NmdY)=hrQ4V' + 'ZU4sBEqZB-9#R}gMl5N=u#Zd?*>T@r3z6K-J>ZekQ}WEE~@6>eu1ZfO>7Y8P&77;bGCZf_ZGaT#u' + 'M8g6tNZgm@OcN}ea9Bq0XZG0VVeI9Lp9&CUgY=R(cgdl5$A!~;rYltFhiXv)^BWaBzX^$jnkR@o6' + 'C1{f-XO$*rmnUYJCuW)`Wt%Bvohf9WDr2B3W1=f!qby;iEMTTBV5lx%sV-itFJ7%MU9K=(urXV)G' + 'Fr1TTC_7+wlr9`HCMScR=PG-yf;+6I8(nlQouS%tA=cLr' + '2dRG~-z_t-nIXD01wCGKh@?`$LQZXxh*An|b@@pB#WbR6?`8}oP>^m!Qddl&V' + '67507<_J9-jf)e+H5cq`;_=gVoi46IR3;B!+`Hl(tkO=yc2l|u;`<4d#mj?Tp2K$=^`<(~;p9lP*' + '2>hc7{G|%~rVRY34g9JP{HziDt`hvP6#TLl{InSRwi^4m9s9Z;`@A9hz9ag;Ci=oC`ot>w#x42BF' + 'Zszb`O7u<%{TbZI``2%_tQZ3)kF5zM)lcA_1jGJ-B0x2Q}p3h^W$3c}~Pwaq' + ';hU@$q@`@_q93g7fu;^Y)7L_>T4YlJ@$R`1_jq{GIy!q5S`(' +) + +_TERRAIN = _d( + 'Gc%boHk>dxo-aG0Em6LZ-TcH9tn-Vb}>4u0eefaVK>=?aGI2#D?ni}41I^aPOh1C#jzmiz#i{s5c%0G' + '#yzp6>vm=>VhS0Hxgkrq=+e(EzK<0IkIUufG7Xxd5}X0JW_Ewxbk?Dh#' + '>V%o=g`Mk#q3njG?T4xEh^_C5vG0ks@QS(di@oxU!Sjv9^Nq>$j?MLt(e{wl_mSE6k=^)`;rWy0`' + 'jqMWmF@eL@%)zc{g?UvnEn5m{{EQ!`5c3+Z' + 'nUXXEJj&59xY+H(HT8L*^hGbZTVpoD+R)AbofnHXFU|EG@T!&>|iD+SqYh;aWW{+@bk#cO4b#9e+' + 'ahG~@nS6Gee|epPe4m7Vp@)H^iG-z$hNq5+sgaDUla8&Hk*}GQv745&o|(0voVTN&xuv1IsHDBCr' + '@yVK!LY2uvaiLpvB$Tx$-1}8yt&Q4ywAeF(Zs>i$Hdji$Jfls+0M+{(a+t}(cjk8;n~;Y+u7yb+~' + '?rm>Eq(-<>c+?=kM$4@$KyM@bC5V^7r-h`SF>nAeaRD-M1v7C7GjI$va1S+b5;brXHgFaaBZYDi$Cq' + '8Z|KW-{NZ7V=+EkSKAL2WNWZ81Y_GDB@ML~S%hY&At}Hb!hXM{GGrY&uA6J4tLjN^Ct!Y(7hDKTK' + '>uOl(0-Y(h?KLr!c&Pi#d{Y(`ORM^S7@Qfx_6Y)VsXOH^!3RcuXFY))2ePgiVES8P#OZBkinQ(A3' + 'QT5VNZZB<-tR$OgYU2RxiZCPG!T3>EkU~XJtZe3w+USe)uV{cz$Z(w9^VP$V(Wp86nOFa;~FsucUFXrE#&' + 'PaI>axw5M;isBgBZZ?~y#xT4E3-pN?s%2(jaR^iN5;>}d#&Qs;jQs&T6=h0B;(ogBrPU_Q4>(oo_)k^NxN$=N4@YqK2*+uf&MD' + 'yE1_1r=B-9Puq7-RVNZyDG' + '^#K5nL=0Uo8<~FA-xg5oI$FXEYFLHV|t#5NtUQZaWWfJr8j|4{|^cbV3hxL=SdF5O+rqcu5d=N)U' + 'NW5qeG$druO3Q4)Mo6MR$?eO45GR~3F)6@FS4eq0uRT^D~}7=K_GfMOYdWEy~F8h~dTfM^_nY8-)' + 'U9f55ffo>jwa36tkAAxfqfpsB)b|HayB7u1#f_o!^d?SK>B!Yk?f`KK0gC>E5CV_@0frux8i70`K' + 'DS?bBfsQJHkSc+ZD}j?MfRrqNmMnmnEr6LVfSWFVoGyQ!FMpsff1xmbqcDD@F@B~oeW)^hsxy77G' + 'kmQye6BQmur+(KHG8u*dbKusw>NpXH+i}^c)U4yy*YQkI(NW2cEUS##5{GyJaorBbI3k(%06<;KX' + 'J`KanC?-&_Qp~LT}SUZq-C>)EONoe0nXy8m|;!S4bPG#gzWaUs~=Tc+nQ)1' + '~sMgxSYYj1U+!C8?_FN-UtaNGUGidG@?>1|WnA=UT=Z#N^=n-AY+UwkT=#HX_i|kLbX@p' + '$T={ri`FUOWdtLf{UiyDt`hj2jgJ1iFVEcz*{E1=wiemhXWBiU}{E%h+l4kvsXZ@CF{g`R}nri)<' + 'YyO>V{-16Bp>F=8Z~mom{-<*OsdN6SbpEV%{;qfauz3Emdj7O~{#)|&PjQ+}v{>+d5&5-`jlK#<@{?nEI)tCO)nf}#Y3juKez>{P42;^0fT)w*2anb8W1WQ5i1)JEF2Op9TG1d6EGeVF&' + '`8&AQUtq6*VFiHX;@{BNjO%7dj;uJ0%!BCKx^^89yi)Kq(qQDH=m68$>G`MJyafEgVQK9Z4=7N-r' + 'KuFdj`XA5JkJPck4-GaynlAyYIVR5c=1HX>FxBUm>hSvVwGIV4*;C0#ouUOOgVJSJd0Ct^M)V?HQ' + 'kKPY8DDQ7_`XhJG!LMm!QD{Dk6ZAC0@Ml5eeEpSIIa!4+7NiK9rFLg>UcS|sMOfY#(F?vlgd`>cb' + 'PcnW`Gk;JsfKfDpQZ$28HH1_(g;X|&RW^uLH;Gp_idQ&{SU8PYIgVO6k6SvCTRM_lJCj{IlwLfQU' + 'p$vzJ(yrUnPEPfVm_Q=Kb>Pgo@79uWk8{3L84|sqh~^-XhNoGL#Js&sA@#1YecGSMXYT_t!+lGZb' + 'q+fN3n26vT#VVaY(dsNw#xIw{%LlbV|8(OS^VUymw5!cuc-{O}}|f!Fo=@drrf9PsDsr#(hx7eo)' + 'ANQOSQ%%YahMfl|$aQ_g}@(1TRbgjCXnRnvx5)P`2phgR2!SJ;VH*@;-&idfu>S>23T-i=z|jauQ' + '3TjGyfY4_nc(-on-o+' + 'Wc#0G{GVn0pl1G|X8)pR|DtFAqiFx6X#b^Y|E6jGrfUDEYX7Kf|EX*Ls%-zNZ2zlm|Ez8Qt#1FVZ' + 'vU=t|F3WVuyFseaR0G!|FUuavvU8ma{sh*|Fv`fwsilubpN<@|G0Jkxpx1$cK^F~|GRhpym' + 'lh6|G#IHXaE3e003+N0B!&PZ~y>u0049V0CoTXcmM' + '!;004Xd0Db@ffB*o30sw>r0EPzuhzJ0R3IL1^0FDm;kPrZp5&)DG0G1a3m>2+>8UUOf0G=NJpdkR' + 'FA^@Z$0H!AZs3`!dDgdl40In|purUC#G61wR0Jb*(xH$m2Ism*q0KPu}z(D}QLIA`>0LDiE$VmXo' + 'N&w7F0M1VU&`|)=QUKIc0M=Ik*jWJDS^(T#0N!5!;9&sbVgTf10On@^=xG4zY5?qQ0Pb%9@Noe0a' + 'sc#n0QPqP_;~>OdI0==0RDdf|A7Jjg9HDB1^wP5;kN|ItzZ(^LP{RsYsk|Jhmp+gtzLUH{)-|KVZ(<75BiW&h@9|LAG|>udk' + '*ZU66Y|M7AE^K<|7b^rEv|M_|U`+NWWegFS||Nnvi|AYVkh5!GD|Nn{q|BL_sjsO3T|NoKy|C9g!' + 'mH+>j|Noi)|C|5+o&W!z|No)?|D*r^rT_n@|Np7~|EvH1t^fb8|NpW7|Fi%9wg3OO|NpuF|GWSHz' + '5oBe|Np`N|HJ?P#sB}u|NqJV|I7dX&Hw+;|Nqhd|I`2f)&Kw3|Nq(l|J(on-T(jJ|Nr6t|KtDv<^' + 'TWZ|NrU#|Lgz%?f?Jp|Nrs-|MUO<_5c6(|Nr^_|NH;{{r~^}' +) + + +COLORMAP_LUTS: dict[str, np.ndarray] = { + "viridis": _VIRIDIS, + "gray": _GRAY, + "hot": _HOT, + "jet": _JET, + "plasma": _PLASMA, + "inferno": _INFERNO, + "terrain": _TERRAIN, + "cividis": _CIVIDIS, + "magma": _MAGMA, + "copper": _COPPER, + "afmhot": _AFMHOT, +} + + +def get_colormap_lut(name: str) -> np.ndarray: + """Return a (256, 3) uint8 LUT for the named colormap.""" + return COLORMAP_LUTS[name] + diff --git a/backend/data_types.py b/backend/data_types.py index 4d2bb6e..c8746c0 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -240,9 +240,7 @@ def colormap_to_uint8(normalized: np.ndarray, colormap: Any = "gray") -> np.ndar return rgb.reshape(normalized.shape + (3,)) cmap_name = spec["preset"] if isinstance(spec, dict) else spec - cmap = _get_colormap(cmap_name) - rgba = cmap(normalized) - return (rgba[:, :, :3] * 255).astype(np.uint8) + return _apply_named_colormap(normalized, cmap_name) @dataclass class DataField: @@ -359,7 +357,7 @@ def normalize_for_colormap( def datafield_to_uint8(df: DataField, colormap: Any = "gray") -> np.ndarray: """ - Normalize a DataField to a uint8 (H, W, 3) RGB array using matplotlib colormap. + Normalize a DataField to a uint8 (H, W, 3) RGB array using a colormap. Returns shape (H, W, 3) uint8. """ normalized = normalize_for_colormap( @@ -1117,7 +1115,9 @@ def encode_preview(arr: np.ndarray) -> str: return f"data:image/png;base64,{b64}" -@lru_cache(maxsize=len(COLORMAPS)) -def _get_colormap(colormap: str): - from matplotlib import colormaps - return colormaps[colormap] +def _apply_named_colormap(normalized: np.ndarray, name: str) -> np.ndarray: + """Map a [0, 1] float array to (H, W, 3) uint8 via a baked 256-entry LUT.""" + from backend.baked_colormaps import get_colormap_lut + lut = get_colormap_lut(name) # (256, 3) uint8 + indices = np.rint(np.clip(normalized, 0.0, 1.0) * 255.0).astype(np.uint8) + return lut[indices.ravel()].reshape(normalized.shape + (3,)) diff --git a/backend/execution.py b/backend/execution.py index 935a138..519de62 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -575,9 +575,7 @@ class ExecutionEngine: try: import base64 import io as _io - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt + from PIL import Image, ImageDraw y_meta = y if isinstance(y, LineData) else None y = np.asarray(y, dtype=np.float64).ravel() @@ -588,19 +586,32 @@ class ExecutionEngine: else: x = np.asarray(x, dtype=np.float64).ravel()[:len(y)] - fig, ax = plt.subplots(figsize=(3.2, 1.8), dpi=100) - fig.patch.set_facecolor("#1e293b") - ax.set_facecolor("#0f172a") - ax.plot(x, y, color="#ff9800", linewidth=1.2) - ax.tick_params(colors="#94a3b8", labelsize=7) - for spine in ax.spines.values(): - spine.set_color("#334155") - ax.grid(True, color="#334155", linewidth=0.3, alpha=0.5) - fig.tight_layout(pad=0.4) - + # Render a small fallback thumbnail with Pillow + w, h = 320, 180 + pad = 12 + img = Image.new("RGB", (w, h), (15, 23, 42)) # #0f172a + draw = ImageDraw.Draw(img) + n = len(y) + if n > 1: + ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y)) + xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x)) + if ymax == ymin: + ymin, ymax = ymin - 1, ymax + 1 + if xmax == xmin: + xmax = xmin + 1 + pw, ph = w - 2 * pad, h - 2 * pad + # Downsample if more points than pixels + step = max(1, n // pw) + xs = x[::step] + ys = y[::step] + pts = [ + (pad + (float(xs[i]) - xmin) / (xmax - xmin) * pw, + pad + (1.0 - (float(ys[i]) - ymin) / (ymax - ymin)) * ph) + for i in range(len(xs)) + ] + draw.line(pts, fill=(255, 152, 0), width=2) # #ff9800 buf = _io.BytesIO() - fig.savefig(buf, format="png", facecolor=fig.get_facecolor()) - plt.close(fig) + img.save(buf, format="PNG") fallback_image = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}" result_dict = { diff --git a/backend/nodes/save.py b/backend/nodes/save.py index bb6aa0b..a5f8969 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -218,22 +218,91 @@ class Save: title: str, format_name: str, ): - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt + from PIL import Image, ImageDraw, ImageFont - fig, ax = plt.subplots(figsize=(8, 5), dpi=150) - ax.plot(x, y, linewidth=1.2, color="#4f8ef7") - ax.set_xlabel(x_unit if x_unit else "x") - ax.set_ylabel(y_unit if y_unit else "y") + w, h = 1200, 750 + bg = (255, 255, 255) + line_color = (79, 142, 247) # #4f8ef7 + grid_color = (200, 200, 200) + text_color = (60, 60, 60) + margin = {"left": 80, "right": 30, "top": 50, "bottom": 60} + + img = Image.new("RGB", (w, h), bg) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("DejaVuSans.ttf", 14) + font_small = ImageFont.truetype("DejaVuSans.ttf", 11) + font_title = ImageFont.truetype("DejaVuSans.ttf", 16) + except (OSError, IOError): + font = ImageFont.load_default() + font_small = font + font_title = font + + pw = w - margin["left"] - margin["right"] + ph = h - margin["top"] - margin["bottom"] + + xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x)) + ymin, ymax = float(np.nanmin(y)), float(np.nanmax(y)) + if ymax == ymin: + ymin, ymax = ymin - 1, ymax + 1 + if xmax == xmin: + xmax = xmin + 1 + # Add 5% padding to y range + ypad = (ymax - ymin) * 0.05 + ymin -= ypad + ymax += ypad + + def to_px(xv: float, yv: float) -> tuple[float, float]: + px = margin["left"] + (xv - xmin) / (xmax - xmin) * pw + py = margin["top"] + (1.0 - (yv - ymin) / (ymax - ymin)) * ph + return px, py + + # Grid lines (5 horizontal, 5 vertical) + for i in range(6): + gy = ymin + (ymax - ymin) * i / 5 + _, py = to_px(xmin, gy) + draw.line([(margin["left"], py), (margin["left"] + pw, py)], fill=grid_color, width=1) + label = f"{gy:.4g}" + draw.text((margin["left"] - 8, py - 6), label, fill=text_color, font=font_small, anchor="rm") + + gx = xmin + (xmax - xmin) * i / 5 + px, _ = to_px(gx, ymin) + draw.line([(px, margin["top"]), (px, margin["top"] + ph)], fill=grid_color, width=1) + label = f"{gx:.4g}" + draw.text((px, margin["top"] + ph + 6), label, fill=text_color, font=font_small, anchor="mt") + + # Plot line + n = len(y) + step = max(1, n // pw) + xs, ys = x[::step], y[::step] + pts = [to_px(float(xs[i]), float(ys[i])) for i in range(len(xs))] + if len(pts) > 1: + draw.line(pts, fill=line_color, width=2) + + # Border + draw.rectangle( + [margin["left"], margin["top"], margin["left"] + pw, margin["top"] + ph], + outline=(100, 100, 100), width=1, + ) + + # Axis labels + x_label = x_unit if x_unit else "x" + y_label = y_unit if y_unit else "y" + draw.text((margin["left"] + pw // 2, h - 10), x_label, fill=text_color, font=font, anchor="mb") + # Vertical y label — draw rotated + y_label_img = Image.new("RGBA", (200, 20), (0, 0, 0, 0)) + y_draw = ImageDraw.Draw(y_label_img) + y_draw.text((100, 10), y_label, fill=text_color, font=font, anchor="mm") + y_label_img = y_label_img.rotate(90, expand=True) + img.paste(y_label_img, (2, margin["top"] + ph // 2 - y_label_img.height // 2), y_label_img) + + # Title if title and title.strip(): - ax.set_title(title.strip()) - ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5) - fig.tight_layout() + draw.text((w // 2, 10), title.strip(), fill=text_color, font=font_title, anchor="mt") ext = ".png" if format_name == "PNG" else ".tiff" - fig.savefig(str(path.with_suffix(ext)), format=format_name.lower(), dpi=150) - plt.close(fig) + img.save(str(path.with_suffix(ext))) def _save_table(self, path: Path, rows: list, format_name: str): if format_name == "JSON": diff --git a/pyproject.toml b/pyproject.toml index c91e30e..ef83353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "gwyfile>=0.2", "h5py>=3.10,<4", "igor>=0.3", - "matplotlib>=3.8,<4", "nanonispy>=1.1", "numpy>=1.26,<3", "pillow>=10,<12",