From 1d9d5825d6ca6a517372a87f5c525a3286b5a7cf Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 11:04:40 -0400 Subject: [PATCH 1/7] Async all the things that were blocking. --- env_canada/ec_radar.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 0fbe3f0..7656862 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -102,6 +102,17 @@ def compute_bounding_box(distance, latittude, longitude): return lat_min, lon_min, lat_max, lon_max +async def _image_open(bytes, mode): + loop = asyncio.get_running_loop() + image = await loop.run_in_executor(None, Image.open, BytesIO(bytes)) + return image.convert(mode) + + +async def _load_font(font_file): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, ImageFont.load, font_file) + + class ECRadar(object): def __init__(self, **kwargs): """Initialize the radar object.""" @@ -163,10 +174,7 @@ def __init__(self, **kwargs): self.legend_position = None self.show_timestamp = kwargs["timestamp"] - if self.show_timestamp: - self.font = ImageFont.load( - os.path.join(os.path.dirname(__file__), "10x20.pil") - ) + self.font = None @property def precip_type(self): @@ -198,7 +206,7 @@ async def _get_basemap(self): async with ClientSession(raise_for_status=True) as session: response = await session.get(url=basemap_url, params=basemap_params) base_bytes = await response.read() - self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA") + self.map_image = await _image_open(base_bytes, "RGBA") except ClientConnectorError as e: logging.warning("NRCan base map could not be retrieved: %s" % e) @@ -209,7 +217,7 @@ async def _get_basemap(self): url=backup_map_url, params=basemap_params ) base_bytes = await response.read() - self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA") + self.map_image = await _image_open(base_bytes, "RGBA") except ClientConnectorError: logging.warning("Mapbox base map could not be retrieved") @@ -225,7 +233,7 @@ async def _get_legend(self): async with ClientSession(raise_for_status=True) as session: response = await session.get(url=geomet_url, params=legend_params) legend_bytes = await response.read() - self.legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB") + self.legend_image = await _image_open(legend_bytes, "RGB") legend_width = self.legend_image.size[0] self.legend_position = (self.width - legend_width, 0) self.legend_layer = self.layer_key @@ -256,7 +264,7 @@ async def _get_dimensions(self): async def _combine_layers(self, radar_bytes, frame_time): """Add radar overlay to base layer and add timestamp.""" - radar = Image.open(BytesIO(radar_bytes)).convert("RGBA") + radar = await _image_open(radar_bytes, "RGBA") # Add transparency to radar @@ -285,6 +293,10 @@ async def _combine_layers(self, radar_bytes, frame_time): # Add timestamp if self.show_timestamp: + if not self.font: + self.font = await _load_font( + os.path.join(os.path.dirname(__file__), "10x20.pil") + ) timestamp = ( timestamp_label[self.layer_key][self.language] + " @ " From 09fc7746b2da663a71b86eda2ce20fd02eda9e30 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 14:34:16 -0400 Subject: [PATCH 2/7] Refactor --- env_canada/ec_radar.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 7656862..03fba2b 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -108,11 +108,6 @@ async def _image_open(bytes, mode): return image.convert(mode) -async def _load_font(font_file): - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, ImageFont.load, font_file) - - class ECRadar(object): def __init__(self, **kwargs): """Initialize the radar object.""" @@ -294,8 +289,11 @@ async def _combine_layers(self, radar_bytes, frame_time): if self.show_timestamp: if not self.font: - self.font = await _load_font( - os.path.join(os.path.dirname(__file__), "10x20.pil") + loop = asyncio.get_running_loop() + self.font = await loop.run_in_executor( + None, + ImageFont.load, + os.path.join(os.path.dirname(__file__), "10x20.pil"), ) timestamp = ( timestamp_label[self.layer_key][self.language] From cd0ce4ef7484fa19edff25aaa15a59d9a831946f Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 15:14:10 -0400 Subject: [PATCH 3/7] Put all the PIL calls in another thread so that main thread not blocked. --- env_canada/ec_radar.py | 85 ++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 03fba2b..852101f 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -259,61 +259,64 @@ async def _get_dimensions(self): async def _combine_layers(self, radar_bytes, frame_time): """Add radar overlay to base layer and add timestamp.""" - radar = await _image_open(radar_bytes, "RGBA") - - # Add transparency to radar - - if self.radar_opacity < 100: - alpha = round((self.radar_opacity / 100) * 255) - radar_copy = radar.copy() - radar_copy.putalpha(alpha) - radar.paste(radar_copy, radar) - - # Overlay radar on basemap + loop = asyncio.get_event_loop() + radar = await _image_open(radar_bytes, "RGBA") if not self.map_image: await self._get_basemap() - if self.map_image: - frame = Image.alpha_composite(self.map_image, radar) - else: - frame = radar - - # Add legend - if self.show_legend: if not self.legend_image or self.legend_layer != self.layer_key: await self._get_legend() - frame.paste(self.legend_image, self.legend_position) - - # Add timestamp - if self.show_timestamp: if not self.font: - loop = asyncio.get_running_loop() self.font = await loop.run_in_executor( None, ImageFont.load, os.path.join(os.path.dirname(__file__), "10x20.pil"), ) - timestamp = ( - timestamp_label[self.layer_key][self.language] - + " @ " - + frame_time.astimezone().strftime("%H:%M") - ) - text_box = Image.new("RGBA", self.font.getbbox(timestamp)[2:], "white") - box_draw = ImageDraw.Draw(text_box) - box_draw.text(xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self.font) - double_box = text_box.resize((text_box.width * 2, text_box.height * 2)) - frame.paste(double_box) - frame = frame.quantize() - - # Return frame as PNG bytes - img_byte_arr = BytesIO() - frame.save(img_byte_arr, format="PNG") - frame_bytes = img_byte_arr.getvalue() - - return frame_bytes + # All the PIL stuff + def _create_image(): + # Add transparency to radar + if self.radar_opacity < 100: + alpha = round((self.radar_opacity / 100) * 255) + radar_copy = radar.copy() + radar_copy.putalpha(alpha) + radar.paste(radar_copy, radar) + + # Overlay radar on basemap + if self.map_image: + frame = Image.alpha_composite(self.map_image, radar) + else: + frame = radar + + # Add legend + if self.show_legend: + frame.paste(self.legend_image, self.legend_position) + + # Add timestamp + if self.show_timestamp: + timestamp = ( + timestamp_label[self.layer_key][self.language] + + " @ " + + frame_time.astimezone().strftime("%H:%M") + ) + text_box = Image.new("RGBA", self.font.getbbox(timestamp)[2:], "white") + box_draw = ImageDraw.Draw(text_box) + box_draw.text(xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self.font) + double_box = text_box.resize((text_box.width * 2, text_box.height * 2)) + frame.paste(double_box) + frame = frame.quantize() + + # Return frame as PNG bytes + img_byte_arr = BytesIO() + frame.save(img_byte_arr, format="PNG") + frame_bytes = img_byte_arr.getvalue() + + return frame_bytes + + # Since PIL is non-async run all the PIL stuff in another thread + return await loop.run_in_executor(None, _create_image) async def _get_radar_image(self, session, frame_time): params = dict( From 5ee5785d129dc0eef9d5f3d99ea916b89a754c52 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 15:32:34 -0400 Subject: [PATCH 4/7] Refactor existing code; preparing to isolate Image.open --- env_canada/ec_radar.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 852101f..679f7e3 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -201,22 +201,20 @@ async def _get_basemap(self): async with ClientSession(raise_for_status=True) as session: response = await session.get(url=basemap_url, params=basemap_params) base_bytes = await response.read() - self.map_image = await _image_open(base_bytes, "RGBA") except ClientConnectorError as e: logging.warning("NRCan base map could not be retrieved: %s" % e) - try: async with ClientSession(raise_for_status=True) as session: response = await session.get( url=backup_map_url, params=basemap_params ) base_bytes = await response.read() - self.map_image = await _image_open(base_bytes, "RGBA") except ClientConnectorError: logging.warning("Mapbox base map could not be retrieved") + return - return + self.map_image = await _image_open(base_bytes, "RGBA") async def _get_legend(self): """Fetch legend image.""" From 3844bd16d2a913154d0a6075c5b5cc61668e3847 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 16:02:28 -0400 Subject: [PATCH 5/7] Refactor to consolidate executor calls There are also a couple of small changes to harden the code. --- env_canada/ec_radar.py | 53 ++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 679f7e3..0f49b6a 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -102,12 +102,6 @@ def compute_bounding_box(distance, latittude, longitude): return lat_min, lon_min, lat_max, lon_max -async def _image_open(bytes, mode): - loop = asyncio.get_running_loop() - image = await loop.run_in_executor(None, Image.open, BytesIO(bytes)) - return image.convert(mode) - - class ECRadar(object): def __init__(self, **kwargs): """Initialize the radar object.""" @@ -163,10 +157,9 @@ def __init__(self, **kwargs): # Get overlay parameters self.show_legend = kwargs["legend"] - if self.show_legend: - self.legend_layer = None - self.legend_image = None - self.legend_position = None + self.legend_layer = None + self.legend_image = None + self.legend_position = None self.show_timestamp = kwargs["timestamp"] self.font = None @@ -212,9 +205,9 @@ async def _get_basemap(self): base_bytes = await response.read() except ClientConnectorError: logging.warning("Mapbox base map could not be retrieved") - return + return None - self.map_image = await _image_open(base_bytes, "RGBA") + return base_bytes async def _get_legend(self): """Fetch legend image.""" @@ -223,13 +216,13 @@ async def _get_legend(self): layer=precip_layers[self.layer_key], style=legend_style[self.layer_key] ) ) - async with ClientSession(raise_for_status=True) as session: - response = await session.get(url=geomet_url, params=legend_params) - legend_bytes = await response.read() - self.legend_image = await _image_open(legend_bytes, "RGB") - legend_width = self.legend_image.size[0] - self.legend_position = (self.width - legend_width, 0) - self.legend_layer = self.layer_key + try: + async with ClientSession(raise_for_status=True) as session: + response = await session.get(url=geomet_url, params=legend_params) + return await response.read() + except ClientConnectorError: + logging.warning("Legend could not be retrieved") + return None async def _get_dimensions(self): """Get time range of available data.""" @@ -259,12 +252,15 @@ async def _combine_layers(self, radar_bytes, frame_time): loop = asyncio.get_event_loop() - radar = await _image_open(radar_bytes, "RGBA") + base_bytes = None if not self.map_image: - await self._get_basemap() + base_bytes = await self._get_basemap() + + legend_bytes = None if self.show_legend: if not self.legend_image or self.legend_layer != self.layer_key: - await self._get_legend() + legend_bytes = await self._get_legend() + if self.show_timestamp: if not self.font: self.font = await loop.run_in_executor( @@ -275,6 +271,17 @@ async def _combine_layers(self, radar_bytes, frame_time): # All the PIL stuff def _create_image(): + radar = Image.open(BytesIO(radar_bytes)).convert("RGBA") + + if base_bytes: + self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA") + + if legend_bytes: + self.legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB") + legend_width = self.legend_image.size[0] + self.legend_position = (self.width - legend_width, 0) + self.legend_layer = self.layer_key + # Add transparency to radar if self.radar_opacity < 100: alpha = round((self.radar_opacity / 100) * 255) @@ -289,7 +296,7 @@ def _create_image(): frame = radar # Add legend - if self.show_legend: + if self.show_legend and self.legend_image: frame.paste(self.legend_image, self.legend_position) # Add timestamp From a7ba00e42c11684df20e43bf0c43181c72f06364 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 21:35:48 -0400 Subject: [PATCH 6/7] Refactor to simplify font loading. --- env_canada/ec_radar.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 0f49b6a..66f48ff 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -261,14 +261,6 @@ async def _combine_layers(self, radar_bytes, frame_time): if not self.legend_image or self.legend_layer != self.layer_key: legend_bytes = await self._get_legend() - if self.show_timestamp: - if not self.font: - self.font = await loop.run_in_executor( - None, - ImageFont.load, - os.path.join(os.path.dirname(__file__), "10x20.pil"), - ) - # All the PIL stuff def _create_image(): radar = Image.open(BytesIO(radar_bytes)).convert("RGBA") @@ -289,6 +281,11 @@ def _create_image(): radar_copy.putalpha(alpha) radar.paste(radar_copy, radar) + if self.show_timestamp and not self.font: + self.font = ImageFont.load( + os.path.join(os.path.dirname(__file__), "10x20.pil") + ) + # Overlay radar on basemap if self.map_image: frame = Image.alpha_composite(self.map_image, radar) From 4f5f37b0120543da98712665bf26c6aa92821365 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Jun 2024 21:36:56 -0400 Subject: [PATCH 7/7] A bit more cleanup. --- env_canada/ec_radar.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/env_canada/ec_radar.py b/env_canada/ec_radar.py index 66f48ff..33e0a54 100644 --- a/env_canada/ec_radar.py +++ b/env_canada/ec_radar.py @@ -250,8 +250,6 @@ async def _get_dimensions(self): async def _combine_layers(self, radar_bytes, frame_time): """Add radar overlay to base layer and add timestamp.""" - loop = asyncio.get_event_loop() - base_bytes = None if not self.map_image: base_bytes = await self._get_basemap() @@ -261,7 +259,7 @@ async def _combine_layers(self, radar_bytes, frame_time): if not self.legend_image or self.legend_layer != self.layer_key: legend_bytes = await self._get_legend() - # All the PIL stuff + # All the synchronous PIL stuff here def _create_image(): radar = Image.open(BytesIO(radar_bytes)).convert("RGBA") @@ -297,7 +295,7 @@ def _create_image(): frame.paste(self.legend_image, self.legend_position) # Add timestamp - if self.show_timestamp: + if self.show_timestamp and self.font: timestamp = ( timestamp_label[self.layer_key][self.language] + " @ " @@ -317,8 +315,8 @@ def _create_image(): return frame_bytes - # Since PIL is non-async run all the PIL stuff in another thread - return await loop.run_in_executor(None, _create_image) + # Since PIL is synchronous, run it all in another thread + return await asyncio.get_event_loop().run_in_executor(None, _create_image) async def _get_radar_image(self, session, frame_time): params = dict(