693 lines
25 KiB
Python
693 lines
25 KiB
Python
# flightmonitor/map/map_drawing.py
|
|
|
|
import logging
|
|
import math
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
PIL_LIB_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
Image = None
|
|
ImageDraw = None
|
|
ImageFont = None
|
|
PIL_LIB_AVAILABLE_DRAWING = False
|
|
import logging
|
|
|
|
logging.error("MapDrawing: Pillow not found. Drawing disabled.")
|
|
|
|
try:
|
|
import cv2
|
|
import numpy as np
|
|
|
|
CV2_NUMPY_LIBS_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
cv2 = None
|
|
np = None
|
|
CV2_NUMPY_LIBS_AVAILABLE_DRAWING = False
|
|
import logging
|
|
|
|
logging.warning("MapDrawing: OpenCV or NumPy not found. Some drawing ops disabled.")
|
|
|
|
try:
|
|
import mercantile
|
|
|
|
MERCANTILE_LIB_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
mercantile = None
|
|
MERCANTILE_LIB_AVAILABLE_DRAWING = False
|
|
import logging
|
|
|
|
logging.error("MapDrawing: 'mercantile' not found. Conversions fail.")
|
|
|
|
from .map_utils import get_hgt_tile_geographic_bounds
|
|
from .map_utils import MapCalculationError
|
|
from .map_utils import MERCANTILE_MODULE_LOCALLY_AVAILABLE
|
|
|
|
try:
|
|
from ..data.common_models import CanonicalFlightState
|
|
|
|
CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING = False
|
|
|
|
class CanonicalFlightState:
|
|
pass # type: ignore
|
|
|
|
import logging
|
|
|
|
logging.warning("MapDrawing: CanonicalFlightState not found.")
|
|
|
|
try:
|
|
from ..utils.logger import get_logger
|
|
except ImportError:
|
|
logger = logging.getLogger(__name__)
|
|
logging.warning("MapDrawing: App logger not available.")
|
|
|
|
try:
|
|
from . import map_constants
|
|
|
|
MAP_CONSTANTS_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
MAP_CONSTANTS_AVAILABLE_DRAWING = False
|
|
logger = logging.getLogger(__name__) # Ensure logger exists
|
|
logger.warning(
|
|
"MapDrawing: map_constants not available. Using hardcoded drawing defaults."
|
|
)
|
|
|
|
class MockMapConstantsDrawing:
|
|
DEM_BOUNDARY_COLOR = "red"
|
|
DEM_BOUNDARY_THICKNESS_PX = 3
|
|
AREA_BOUNDARY_COLOR = "blue"
|
|
AREA_BOUNDARY_THICKNESS_PX = 2
|
|
TILE_TEXT_COLOR = "white"
|
|
TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)"
|
|
DEM_TILE_LABEL_BASE_FONT_SIZE = 12
|
|
DEM_TILE_LABEL_BASE_ZOOM = 10
|
|
DEFAULT_LABEL_FONT_PATH = None
|
|
COORDINATE_DECIMAL_PLACES = 4
|
|
|
|
map_constants = MockMapConstantsDrawing()
|
|
|
|
|
|
try:
|
|
logger = get_logger(__name__)
|
|
except NameError: # Should not happen with the above try/except, but defensive
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --- Helper for Geo to Pixel Conversion on Unscaled Stitched Image ---
|
|
|
|
|
|
def _geo_to_pixel_on_unscaled_map(
|
|
latitude_deg: float,
|
|
longitude_deg: float,
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height)
|
|
) -> Optional[Tuple[int, int]]:
|
|
"""Convert geographic to pixel on unscaled stitched image. Requires mercantile."""
|
|
logger.debug(
|
|
f"Attempting to convert geo ({latitude_deg:.5f}, {longitude_deg:.5f}) to pixel."
|
|
)
|
|
|
|
if not MERCANTILE_LIB_AVAILABLE_DRAWING or mercantile is None:
|
|
logger.error(
|
|
"_geo_to_pixel_on_unscaled_map: Mercantile missing or not available."
|
|
)
|
|
return None
|
|
if current_map_geo_bounds is None:
|
|
logger.warning("_geo_to_pixel_on_unscaled_map: Map geo bounds missing.")
|
|
return None
|
|
if current_stitched_map_pixel_shape is None:
|
|
logger.warning("_geo_to_pixel_on_unscaled_map: Map pixel shape missing.")
|
|
return None
|
|
|
|
if not (-90.0 <= latitude_deg <= 90.0 and -180.0 <= longitude_deg <= 180.0):
|
|
logger.debug(
|
|
f"Input geo ({latitude_deg:.4f}, {longitude_deg:.4f}) out of range. Clamping."
|
|
)
|
|
latitude_deg = max(-85.05112878, min(85.05112878, latitude_deg))
|
|
longitude_deg = (longitude_deg + 180.0) % 360.0 - 180.0
|
|
logger.debug(f"Clamped geo: ({latitude_deg:.4f}, {longitude_deg:.4f})")
|
|
|
|
img_w, img_h = current_stitched_map_pixel_shape
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds
|
|
|
|
logger.debug(
|
|
f"Map geo bounds used: W={map_west_lon}, S={map_south_lat}, E={map_east_lon}, N={map_north_lat}"
|
|
)
|
|
logger.debug(f"Stitched image pixel shape: {img_w}x{img_h}")
|
|
|
|
if img_w <= 0 or img_h <= 0:
|
|
logger.warning(f"Map pixel dims invalid ({img_w}x{img_h}). Cannot convert.")
|
|
return None
|
|
|
|
try:
|
|
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat)
|
|
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat)
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
total_map_height_merc = abs(
|
|
map_ul_merc_y - map_lr_merc_y
|
|
) # Mercator Y decreases with increasing latitude
|
|
|
|
logger.debug(
|
|
f"Map Mercator extent: UL=({map_ul_merc_x:.2f}, {map_ul_merc_y:.2f}), LR=({map_lr_merc_x:.2f}, {map_lr_merc_y:.2f})"
|
|
)
|
|
logger.debug(
|
|
f"Total Mercator size: W={total_map_width_merc:.2f}, H={total_map_height_merc:.2f}"
|
|
)
|
|
|
|
if total_map_width_merc <= 1e-9 or total_map_height_merc <= 1e-9:
|
|
logger.warning(
|
|
f"Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e}). Cannot convert."
|
|
)
|
|
return None
|
|
|
|
target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg)
|
|
logger.debug(
|
|
f"Target Geo ({longitude_deg:.5f}, {latitude_deg:.5f}) in Mercator: ({target_merc_x:.2f}, {target_merc_y:.2f})"
|
|
)
|
|
|
|
# Calculate relative position within the map's mercator extent (0.0 to 1.0)
|
|
# X: 0.0 at map_west_lon, 1.0 at map_east_lon
|
|
# Y: 0.0 at map_north_lat, 1.0 at map_south_lat (because pixel Y increases downwards)
|
|
relative_x_on_mercator = (
|
|
(target_merc_x - map_ul_merc_x) / total_map_width_merc
|
|
if total_map_width_merc > 0
|
|
else 0.0
|
|
)
|
|
relative_y_on_mercator = (
|
|
(map_ul_merc_y - target_merc_y) / total_map_height_merc
|
|
if total_map_height_merc > 0
|
|
else 0.0
|
|
)
|
|
|
|
# Convert relative mercator position to pixel position on the stitched image
|
|
pixel_x_on_unscaled_raw = relative_x_on_mercator * img_w
|
|
pixel_y_on_unscaled_raw = relative_y_on_mercator * img_h
|
|
|
|
logger.debug(
|
|
f"Relative Mercator ({relative_x_on_mercator:.4f}, {relative_y_on_mercator:.4f}) -> Raw Pixel ({pixel_x_on_unscaled_raw:.2f}, {pixel_y_on_unscaled_raw:.2f})"
|
|
)
|
|
|
|
rounded_pixel_x = int(round(pixel_x_on_unscaled_raw))
|
|
rounded_pixel_y = int(round(pixel_y_on_unscaled_raw))
|
|
|
|
# Clamp pixels within a margin of the image bounds for drawing
|
|
clamp_margin = 10
|
|
px_clamped = max(-clamp_margin, min(rounded_pixel_x, img_w + clamp_margin))
|
|
py_clamped = max(-clamp_margin, min(rounded_pixel_y, img_h + clamp_margin))
|
|
|
|
logger.debug(
|
|
f"Rounded Pixel ({rounded_pixel_x}, {rounded_pixel_y}) -> Clamped Pixel ({px_clamped}, {py_clamped})"
|
|
)
|
|
logger.debug(
|
|
f"Geo ({latitude_deg:.5f}, {longitude_deg:.5f}) -> Pixel ({px_clamped}, {py_clamped})"
|
|
)
|
|
|
|
return (px_clamped, py_clamped)
|
|
|
|
except Exception as e_geo_to_px_unscaled:
|
|
logger.error(
|
|
f"Error during geo-to-pixel conversion for geo ({latitude_deg:.5f}, {longitude_deg:.5f}): {e_geo_to_px_unscaled}",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
# --- Drawing Functions ---
|
|
|
|
|
|
def draw_point_marker(
|
|
pil_image_to_draw_on: Image.Image,
|
|
latitude_deg: float,
|
|
longitude_deg: float,
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]],
|
|
) -> Image.Image:
|
|
"""Draw point marker on PIL Image. Requires OpenCV/NumPy."""
|
|
if (
|
|
not PIL_LIB_AVAILABLE_DRAWING
|
|
or not CV2_NUMPY_LIBS_AVAILABLE_DRAWING
|
|
or cv2 is None
|
|
or np is None
|
|
or pil_image_to_draw_on is None
|
|
or current_map_geo_bounds is None
|
|
or current_stitched_map_pixel_shape is None
|
|
):
|
|
logger.warning("Cannot draw marker: Libs/context missing.")
|
|
return pil_image_to_draw_on
|
|
|
|
pixel_coords_on_unscaled = _geo_to_pixel_on_unscaled_map(
|
|
latitude_deg,
|
|
longitude_deg,
|
|
current_map_geo_bounds,
|
|
current_stitched_map_pixel_shape,
|
|
)
|
|
|
|
if pixel_coords_on_unscaled:
|
|
px_clamped, py_clamped = pixel_coords_on_unscaled
|
|
|
|
img_w, img_h = current_stitched_map_pixel_shape
|
|
px_strict = max(0, min(px_clamped, img_w - 1))
|
|
py_strict = max(0, min(py_clamped, img_h - 1))
|
|
|
|
if cv2 and np:
|
|
try:
|
|
if pil_image_to_draw_on.mode != "RGBA":
|
|
if Image:
|
|
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA")
|
|
else:
|
|
return pil_image_to_draw_on
|
|
|
|
map_cv_rgba = np.array(pil_image_to_draw_on)
|
|
map_cv_bgr = cv2.cvtColor(map_cv_rgba, cv2.COLOR_RGBA2BGR)
|
|
|
|
cv2.drawMarker(
|
|
map_cv_bgr,
|
|
(px_strict, py_strict),
|
|
(0, 0, 255),
|
|
cv2.MARKER_CROSS,
|
|
markerSize=15,
|
|
thickness=2,
|
|
)
|
|
|
|
if Image:
|
|
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB))
|
|
else:
|
|
return pil_image_to_draw_on
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error drawing marker with OpenCV: {e}")
|
|
return pil_image_to_draw_on
|
|
else:
|
|
logger.warning("OpenCV or NumPy not available for drawing point marker.")
|
|
return pil_image_to_draw_on
|
|
else:
|
|
logger.debug(
|
|
f"Geo-to-pixel failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw marker."
|
|
)
|
|
return pil_image_to_draw_on
|
|
|
|
|
|
def draw_area_bounding_box(
|
|
pil_image_to_draw_on: Image.Image,
|
|
area_geo_bbox: Tuple[float, float, float, float],
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height)
|
|
color: str = map_constants.AREA_BOUNDARY_COLOR,
|
|
thickness: int = map_constants.AREA_BOUNDARY_THICKNESS_PX,
|
|
) -> Image.Image:
|
|
"""Draw BBox on PIL Image. Requires PIL/ImageDraw/Mercantile."""
|
|
logger.debug(f"Attempting to draw area bounding box {area_geo_bbox}.")
|
|
|
|
if (
|
|
not PIL_LIB_AVAILABLE_DRAWING
|
|
or ImageDraw is None
|
|
or pil_image_to_draw_on is None
|
|
or current_map_geo_bounds is None
|
|
or current_stitched_map_pixel_shape is None
|
|
):
|
|
logger.warning("Cannot draw BBox: Libs/context missing.")
|
|
return pil_image_to_draw_on
|
|
if not MERCANTILE_LIB_AVAILABLE_DRAWING or mercantile is None:
|
|
logger.error("Mercantile missing or not available for BBox drawing.")
|
|
return pil_image_to_draw_on
|
|
|
|
west, south, east, north = area_geo_bbox
|
|
corners_geo = [(west, north), (east, north), (east, south), (west, south)]
|
|
pixel_corners: List[Tuple[int, int]] = []
|
|
|
|
try:
|
|
img_w, img_h = current_stitched_map_pixel_shape
|
|
|
|
for lon, lat in corners_geo:
|
|
pixel_coords = _geo_to_pixel_on_unscaled_map(
|
|
lat, lon, current_map_geo_bounds, current_stitched_map_pixel_shape
|
|
)
|
|
logger.debug(
|
|
f"Converted BBox corner geo ({lat:.5f}, {lon:.5f}) to pixel {pixel_coords}."
|
|
)
|
|
|
|
if pixel_coords:
|
|
margin = max(img_w, img_h)
|
|
px, py = pixel_coords
|
|
if not (
|
|
-margin <= px < img_w + margin and -margin <= py < img_h + margin
|
|
):
|
|
logger.debug(
|
|
f"BBox corner pixel ({px},{py}) outside generous drawing margin [{ -margin }:{ img_w + margin }, { -margin }:{ img_h + margin }]. Skipping BBox draw."
|
|
)
|
|
return pil_image_to_draw_on
|
|
|
|
pixel_corners.append(pixel_coords)
|
|
else:
|
|
logger.warning(
|
|
f"Failed to convert corner geo ({lat:.4f}, {lon:.4f}). Aborting BBox draw."
|
|
)
|
|
return pil_image_to_draw_on
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error geo-to-pixel for BBox corners: {e}")
|
|
return pil_image_to_draw_on
|
|
|
|
if len(pixel_corners) == 4 and ImageDraw is not None:
|
|
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
|
|
if Image:
|
|
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA")
|
|
else:
|
|
logger.error("Pillow Image missing for RGBA conversion for BBox.")
|
|
return pil_image_to_draw_on
|
|
|
|
draw = ImageDraw.Draw(pil_image_to_draw_on)
|
|
try:
|
|
draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness)
|
|
draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness)
|
|
draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness)
|
|
draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness)
|
|
logger.debug(f"Drew lines for BBox corners: {pixel_corners}")
|
|
except Exception as e:
|
|
logger.exception(f"Error drawing BBox lines: {e}")
|
|
|
|
return pil_image_to_draw_on
|
|
else:
|
|
logger.warning(
|
|
f"Not 4 pixel corners ({len(pixel_corners)}) or ImageDraw missing. Cannot draw BBox."
|
|
)
|
|
return pil_image_to_draw_on
|
|
|
|
|
|
def draw_dem_tile_boundary(
|
|
pil_image_to_draw_on: Image.Image,
|
|
dem_tile_geo_bbox: Tuple[float, float, float, float],
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_map_render_zoom: Optional[int],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height)
|
|
) -> Image.Image:
|
|
"""Draw DEM tile boundary on PIL Image. Uses map_constants."""
|
|
if (
|
|
not PIL_LIB_AVAILABLE_DRAWING
|
|
or pil_image_to_draw_on is None
|
|
or current_map_geo_bounds is None
|
|
or current_map_render_zoom is None
|
|
or current_stitched_map_pixel_shape is None
|
|
):
|
|
logger.warning("Cannot draw DEM boundary: PIL/context missing.")
|
|
return pil_image_to_draw_on
|
|
|
|
return draw_area_bounding_box(
|
|
pil_image_to_draw_on,
|
|
dem_tile_geo_bbox,
|
|
current_map_geo_bounds,
|
|
current_stitched_map_pixel_shape,
|
|
color=map_constants.DEM_BOUNDARY_COLOR,
|
|
thickness=map_constants.DEM_BOUNDARY_THICKNESS_PX,
|
|
)
|
|
|
|
|
|
def _load_label_font(
|
|
scaled_font_size: int,
|
|
) -> Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont]:
|
|
"""Load TrueType font or default PIL font."""
|
|
if not PIL_LIB_AVAILABLE_DRAWING or ImageFont is None:
|
|
logger.warning("_load_label_font: Pillow/ImageFont missing.")
|
|
return None
|
|
|
|
font_to_use: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont] = None
|
|
scaled_font_size = max(1, scaled_font_size)
|
|
|
|
if (
|
|
hasattr(map_constants, "DEFAULT_LABEL_FONT_PATH")
|
|
and map_constants.DEFAULT_LABEL_FONT_PATH
|
|
):
|
|
try:
|
|
if hasattr(ImageFont, "truetype"):
|
|
font_to_use = ImageFont.truetype(
|
|
map_constants.DEFAULT_LABEL_FONT_PATH, scaled_font_size
|
|
)
|
|
return font_to_use
|
|
else:
|
|
logger.warning("ImageFont.truetype not available.")
|
|
except FileNotFoundError:
|
|
logger.warning(
|
|
f"Label font file not found: {map_constants.DEFAULT_LABEL_FONT_PATH}. Falling back."
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error loading TrueType font: {e}. Falling back.", exc_info=False
|
|
)
|
|
|
|
try:
|
|
if hasattr(ImageFont, "load_default"):
|
|
font_to_use = ImageFont.load_default()
|
|
else:
|
|
logger.warning("ImageFont.load_default not available.")
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Error loading default PIL font: {e}.")
|
|
font_to_use = None
|
|
|
|
if font_to_use is None:
|
|
logger.error("Failed to load any font for labels.")
|
|
return font_to_use
|
|
|
|
|
|
def _draw_text_on_placeholder(
|
|
draw: ImageDraw.ImageDraw, image_size: Tuple[int, int], text_to_draw: str
|
|
):
|
|
"""Draw text on placeholder image."""
|
|
img_width, img_height = image_size
|
|
if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or ImageFont is None:
|
|
logger.warning(
|
|
"Pillow/ImageDraw/ImageFont missing, cannot draw text on placeholder."
|
|
)
|
|
return
|
|
|
|
font_to_use: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont] = None
|
|
try:
|
|
if hasattr(ImageFont, "load_default"):
|
|
font_to_use = ImageFont.load_default()
|
|
else:
|
|
logger.warning("ImageFont.load_default not available for placeholder.")
|
|
except Exception as e:
|
|
logger.warning(f"Error loading default PIL font for placeholder: {e}.")
|
|
|
|
if font_to_use is None:
|
|
logger.warning("No font available for drawing placeholder text. Drawing basic.")
|
|
try:
|
|
draw.text((10, 10), text_to_draw, fill="black")
|
|
except Exception as e:
|
|
logger.error(f"Error drawing basic placeholder text: {e}", exc_info=False)
|
|
return
|
|
|
|
try:
|
|
text_width, text_height = 0, 0
|
|
if hasattr(draw, "textbbox"):
|
|
try:
|
|
text_bbox = draw.textbbox(
|
|
(0, 0), text_to_draw, font=font_to_use, spacing=2
|
|
)
|
|
text_width, text_height = (
|
|
text_bbox[2] - text_bbox[0],
|
|
text_bbox[3] - text_bbox[1],
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error textbbox placeholder: {e}. Fallback textsize.",
|
|
exc_info=False,
|
|
)
|
|
if (text_width == 0 or text_height == 0) and hasattr(draw, "textsize"):
|
|
try:
|
|
text_width, text_height = draw.textsize(text_to_draw, font=font_to_use)
|
|
except Exception as e:
|
|
logger.warning(f"Error textsize placeholder: {e}.", exc_info=False)
|
|
text_width, text_height = 0, 0
|
|
|
|
if text_width > 0 and text_height > 0:
|
|
text_x = (img_width - text_width) // 2
|
|
text_y = (img_height - text_height) // 2
|
|
|
|
bg_padding = 1
|
|
bg_coords = [
|
|
text_x - bg_padding,
|
|
text_y - bg_padding,
|
|
text_x + text_width + bg_padding,
|
|
text_y + text_height + bg_padding,
|
|
]
|
|
draw.rectangle(bg_coords, fill=map_constants.TILE_TEXT_BG_COLOR)
|
|
|
|
draw.text(
|
|
(text_x, text_y),
|
|
text_to_draw,
|
|
fill=map_constants.TILE_TEXT_COLOR,
|
|
font=font_to_use,
|
|
anchor="lt",
|
|
)
|
|
logger.debug(f"Drew text on placeholder at pixel ({text_x}, {text_y}).")
|
|
|
|
else:
|
|
logger.warning(
|
|
"Zero text size determined for placeholder text, skipping text drawing."
|
|
)
|
|
|
|
except Exception as e_draw_text:
|
|
logger.error(
|
|
f"Error drawing text on placeholder image: {e_draw_text}", exc_info=True
|
|
)
|
|
try:
|
|
draw.text((10, 10), text_to_draw, fill="black")
|
|
logger.debug("Drew basic text on placeholder (error during font draw).")
|
|
except Exception as e_fallback_draw:
|
|
logger.error(
|
|
f"Error drawing basic text on placeholder after font error: {e_fallback_draw}",
|
|
exc_info=False,
|
|
)
|
|
|
|
|
|
def _draw_single_flight(
|
|
draw: ImageDraw.ImageDraw,
|
|
pixel_coords: Tuple[int, int],
|
|
flight_state: CanonicalFlightState,
|
|
label_font: Optional[ImageFont.FreeTypeFont | ImageFont.ImageFont],
|
|
):
|
|
"""Draw single flight marker and label on PIL ImageDraw."""
|
|
if not CANONICAL_FLIGHT_STATE_AVAILABLE_DRAWING:
|
|
logger.warning(
|
|
"_draw_single_flight: CanonicalFlightState not available. Cannot draw flights."
|
|
)
|
|
return
|
|
|
|
if (
|
|
not PIL_LIB_AVAILABLE_DRAWING
|
|
or draw is None
|
|
or pixel_coords is None
|
|
or flight_state is None
|
|
):
|
|
logger.warning(
|
|
"_draw_single_flight called with missing arguments or PIL not available."
|
|
)
|
|
return
|
|
|
|
px, py = pixel_coords
|
|
radius = 5
|
|
img_w, img_h = draw.im.size
|
|
label_text = (
|
|
flight_state.callsign
|
|
if flight_state.callsign and flight_state.callsign.strip()
|
|
else flight_state.icao24
|
|
)
|
|
if not label_text.strip():
|
|
label_text = "N/A"
|
|
|
|
logger.debug(
|
|
f"Attempting to draw flight '{label_text}' (ICAO: {flight_state.icao24}) at pixel ({px},{py}). Image size: {img_w}x{img_h}."
|
|
)
|
|
|
|
margin = 100
|
|
if not (-margin <= px < img_w + margin and -margin <= py < img_h + margin):
|
|
logger.debug(
|
|
f"Flight pixel coords ({px},{py}) outside drawing margin [{ -margin }:{ img_w + margin }, { -margin }:{ img_h + margin }]. Skipping draw for '{label_text}'."
|
|
)
|
|
return
|
|
|
|
try:
|
|
bbox_ellipse = (px - radius, py - radius, px + radius, py + radius)
|
|
draw.ellipse(bbox_ellipse, outline="black", fill="red")
|
|
logger.debug(f"Drew marker for flight '{label_text}' at ({px},{py}).")
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error drawing marker for flight '{label_text}' at ({px},{py}): {e}",
|
|
exc_info=False,
|
|
)
|
|
|
|
if label_font is not None:
|
|
logger.debug(
|
|
f"Attempting to draw label '{label_text}' for flight at ({px},{py})."
|
|
)
|
|
try:
|
|
text_width, text_height = 0, 0
|
|
if hasattr(draw, "textbbox"):
|
|
try:
|
|
text_bbox = draw.textbbox(
|
|
(0, 0), label_text, font=label_font, spacing=2
|
|
)
|
|
text_width, text_height = (
|
|
text_bbox[2] - text_bbox[0],
|
|
text_bbox[3] - text_bbox[1],
|
|
)
|
|
logger.debug(
|
|
f"Calculated text size using textbbox: {text_width}x{text_height}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error textbbox flight label '{label_text}': {e}. Fallback textsize if available.",
|
|
exc_info=False,
|
|
)
|
|
if (text_width == 0 or text_height == 0) and hasattr(draw, "textsize"):
|
|
try:
|
|
text_width, text_height = draw.textsize(label_text, font=label_font)
|
|
logger.debug(
|
|
f"Calculated text size using textsize fallback: {text_width}x{text_height}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error textsize flight label '{label_text}': {e}. Cannot get size.",
|
|
exc_info=False,
|
|
)
|
|
text_width, text_height = 0, 0
|
|
|
|
if text_width > 0 and text_height > 0:
|
|
text_anchor_x = px + radius + 3
|
|
text_anchor_y = py - (text_height // 2)
|
|
|
|
clamp_margin_text = 5
|
|
label_text_x = max(
|
|
-clamp_margin_text,
|
|
min(text_anchor_x, img_w - text_width + clamp_margin_text),
|
|
)
|
|
label_text_y = max(
|
|
-clamp_margin_text,
|
|
min(text_anchor_y, img_h - text_height + clamp_margin_text),
|
|
)
|
|
|
|
logger.debug(
|
|
f"Text anchor pixel: ({text_anchor_x}, {text_anchor_y}). Clamped text pixel: ({label_text_x}, {label_text_y})."
|
|
)
|
|
|
|
bg_padding = 1
|
|
bg_coords = [
|
|
label_text_x - bg_padding,
|
|
label_text_y - bg_padding,
|
|
label_text_x + text_width + bg_padding,
|
|
label_text_y + text_height + bg_padding,
|
|
]
|
|
draw.rectangle(bg_coords, fill=map_constants.TILE_TEXT_BG_COLOR)
|
|
logger.debug(f"Drew text background at {bg_coords}.")
|
|
|
|
draw.text(
|
|
(label_text_x, label_text_y),
|
|
label_text,
|
|
fill=map_constants.TILE_TEXT_COLOR,
|
|
font=label_font,
|
|
anchor="lt",
|
|
)
|
|
logger.debug(f"Drew text label '{label_text}'.")
|
|
|
|
else:
|
|
logger.warning(
|
|
f"Zero text size determined for label '{label_text}' ({text_width}x{text_height}), skipping text drawing."
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error drawing flight label for '{label_text}' (ICAO: {flight_state.icao24}): {e}",
|
|
exc_info=False,
|
|
)
|
|
|
|
else:
|
|
logger.debug(
|
|
f"No label font available for flight '{label_text}', skipping label drawing."
|
|
)
|