@@ -24,6 +24,7 @@ def _validate_arr_for_gif(
2424 cmap : str | matplotlib .colors .Colormap | None ,
2525 date_format : str | None ,
2626 date_position : Literal ["ul" , "ur" , "ll" , "lr" ],
27+ date_size : int | float ,
2728) -> tuple [xr .DataArray , matplotlib .colors .Colormap | None ]:
2829 if arr .ndim not in (3 , 4 ):
2930 raise ValueError (
@@ -60,16 +61,71 @@ def _validate_arr_for_gif(
6061 f"Coordinates for the { time_coord .name } dimension are not datetimes, or don't support `strftime`. "
6162 "Set `date_format=False`"
6263 )
63- assert date_position in (
64- "ul" ,
65- "ur" ,
66- "ll" ,
67- "lr" ,
64+ assert (
65+ date_position
66+ in (
67+ "ul" ,
68+ "ur" ,
69+ "ll" ,
70+ "lr" ,
71+ )
6872 ), f"date_position must be one of ('ul', 'ur', 'll', 'lr'), not { date_position } ."
6973
74+ if isinstance (date_size , int ):
75+ assert date_size > 0 , f"date_size must be >0, not { date_size } "
76+ elif isinstance (date_size , float ):
77+ assert (
78+ 0 < date_size < 1
79+ ), f"date_size must be greater than 0 and less than 1, not { date_size } "
80+ else :
81+ raise TypeError (
82+ f"date_size must be int or float, not { type (date_size )} : { date_size } "
83+ )
84+
7085 return (arr , cmap )
7186
7287
88+ def _get_font (
89+ date_size : int | float , labels : list [str ], image_width : int
90+ ) -> ImageFont .ImageFont | ImageFont .FreeTypeFont :
91+ """
92+ Get an appropriately-sized font for the requested ``date_size``
93+
94+ When ``date_size`` is a float, figure out a font size that will make the width of the
95+ text that fraction of the width of the image.
96+ """
97+ if isinstance (date_size , int ):
98+ # absolute font size
99+ return ImageFont .load_default (size = date_size )
100+
101+ target_width = date_size * image_width
102+ test_label = max (labels , key = len )
103+
104+ fnt = ImageFont .load_default (size = 5 )
105+ if isinstance (fnt , ImageFont .FreeTypeFont ):
106+ # NOTE: if Pillow doesn't have FreeType support, we won't get a font
107+ # with adjustable size. So trying to increase the size would just
108+ # loop infinitely.
109+
110+ def text_width (font ) -> int :
111+ bbox = font .getbbox (test_label )
112+ return bbox [2 ] - bbox [0 ]
113+
114+ if text_width (fnt ) > 0 :
115+ # check for zero-width so we don't loop forever.
116+ # (could happen if `test_label` is an empty string or non-printable character)
117+
118+ # increment font until you get large enough text
119+ while text_width (fnt ) <= target_width :
120+ try :
121+ size = fnt .size + 1
122+ fnt = ImageFont .load_default (size )
123+ except OSError as e :
124+ raise RuntimeError (f"Invalid font size: { size } " ) from e
125+
126+ return fnt
127+
128+
73129def gif (
74130 arr : xr .DataArray ,
75131 * ,
@@ -83,6 +139,7 @@ def gif(
83139 date_position : Literal ["ul" , "ur" , "ll" , "lr" ] = "ul" ,
84140 date_color : tuple [int , int , int ] = (255 , 255 , 255 ),
85141 date_bg : tuple [int , int , int ] | None = (0 , 0 , 0 ),
142+ date_size : int | float = 0.15 ,
86143) -> IPython .display .Image | None :
87144 """
88145 Render a `~xarray.DataArray` timestack (``time``, ``band``, ``y``, ``x``) into a GIF.
@@ -140,6 +197,14 @@ def gif(
140197 date_bg:
141198 Fill color to draw behind the timestamp (for legibility), as an RGB 3-tuple.
142199 Default: ``(0, 0, 0)`` (black). Set to None to disable.
200+ date_size:
201+ If a float, make the label this fraction of the width of the image.
202+ If an int, use this absolute font size for the label.
203+ Default: 0.15 (so the label is 15% of the image width).
204+
205+ Note that if Pillow does not have FreeType support, the font size
206+ cannot be adjusted, and the text will be whatever size Pillow's
207+ default basic font is (usually rather small).
143208
144209 Returns
145210 -------
@@ -167,7 +232,7 @@ def gif(
167232 if isinstance (arr .data , da .Array ):
168233 raise TypeError ("DataArray contains delayed data; use `dgif` instead." )
169234
170- arr , cmap = _validate_arr_for_gif (arr , cmap , date_format , date_position )
235+ arr , cmap = _validate_arr_for_gif (arr , cmap , date_format , date_position , date_size )
171236
172237 # Rescale
173238 if arr .dtype .kind == "b" :
@@ -207,7 +272,7 @@ def gif(
207272 time_coord = arr [arr .dims [0 ]]
208273 labels = time_coord .dt .strftime (date_format ).data
209274
210- fnt = ImageFont . load_default ( )
275+ fnt = _get_font ( date_size , labels , imgs [ 0 ]. size [ 0 ] )
211276 for label , img in zip (labels , imgs ):
212277 # get a drawing context
213278 d = ImageDraw .Draw (img )
@@ -230,9 +295,18 @@ def gif(
230295 x = width - t_width - offset
231296
232297 if date_bg :
233- d .rectangle ((x , y , x + t_width , y + t_height ), fill = date_bg )
234- # draw text
235- d .multiline_text ((x , y ), label , font = fnt , fill = date_color )
298+ pad = 0.1 * t_height # looks nicer
299+ d .rectangle (
300+ (x - pad , y - pad , x + t_width + pad , y + t_height + pad ),
301+ fill = date_bg ,
302+ )
303+
304+ # NOTE: sometimes the text seems to incorporate its own internal offset.
305+ # This will show up in the first two coordinates of `t_bbox`, so we
306+ # "de-offset" by these to make the rectangle and text align.
307+ d .multiline_text (
308+ (x - t_bbox [0 ], y - t_bbox [1 ]), label , font = fnt , fill = date_color
309+ )
236310
237311 out = to if to is not None else io .BytesIO ()
238312 imgs [0 ].save (
@@ -244,7 +318,7 @@ def gif(
244318 loop = False ,
245319 )
246320 if to is None and isinstance (out , io .BytesIO ):
247- # second `isinstace ` is just for the typechecker
321+ # second `isinstance ` is just for the typechecker
248322 try :
249323 import IPython .display
250324 except ImportError :
@@ -274,7 +348,7 @@ def dgif(
274348 arr : xr .DataArray ,
275349 * ,
276350 bytes = False ,
277- fps : int = 10 ,
351+ fps : int = 16 ,
278352 robust : bool = True ,
279353 vmin : float | None = None ,
280354 vmax : float | None = None ,
@@ -283,6 +357,7 @@ def dgif(
283357 date_position : Literal ["ul" , "ur" , "ll" , "lr" ] = "ul" ,
284358 date_color : tuple [int , int , int ] = (255 , 255 , 255 ),
285359 date_bg : tuple [int , int , int ] | None = (0 , 0 , 0 ),
360+ date_size : int | float = 0.15 ,
286361) -> Delayed :
287362 """
288363 Turn a dask-backed `~xarray.DataArray` timestack into a GIF, as a `~dask.delayed.Delayed` object.
@@ -350,6 +425,14 @@ def dgif(
350425 date_bg:
351426 Fill color to draw behind the timestamp (for legibility), as an RGB 3-tuple.
352427 Default: ``(0, 0, 0)`` (black). Set to None to disable.
428+ date_size:
429+ If a float, make the label this fraction of the width of the image.
430+ If an int, use this absolute font size for the label.
431+ Default: 0.15 (so the label is 15% of the image width).
432+
433+ Note that if Pillow does not have FreeType support, the font size
434+ cannot be adjusted, and the text will be whatever size Pillow's
435+ default basic font is (usually rather small).
353436
354437 Returns
355438 -------
@@ -381,7 +464,7 @@ def dgif(
381464 )
382465
383466 # Do some quick sanity checks to save you a lot of compute
384- _validate_arr_for_gif (arr , cmap , date_format , date_position )
467+ _validate_arr_for_gif (arr , cmap , date_format , date_position , date_size )
385468
386469 if not bytes :
387470 try :
@@ -414,4 +497,5 @@ def dgif(
414497 date_position = date_position ,
415498 date_color = date_color ,
416499 date_bg = date_bg ,
500+ date_size = date_size ,
417501 )
0 commit comments