322 lines
10 KiB
Python
322 lines
10 KiB
Python
# flightmonitor/map/map_drawing.py
|
|
|
|
import logging
|
|
import math
|
|
from typing import Optional, Tuple, List, Dict, Any
|
|
from collections import deque
|
|
import os
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont, ImageColor
|
|
|
|
PIL_LIB_AVAILABLE_DRAWING = True
|
|
except ImportError:
|
|
Image, ImageDraw, ImageFont, ImageColor = None, None, None, None
|
|
PIL_LIB_AVAILABLE_DRAWING = False
|
|
import logging
|
|
|
|
logging.error("MapDrawing: Pillow not found. Drawing disabled.")
|
|
|
|
try:
|
|
from flightmonitor.map.map_utils import (
|
|
geo_to_pixel_on_unscaled_map,
|
|
calculate_graticule_interval,
|
|
)
|
|
from flightmonitor.data.common_models import CanonicalFlightState
|
|
from flightmonitor.map import map_constants
|
|
from flightmonitor.data import config as app_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
TRACK_LINE_WIDTH = 2
|
|
|
|
except ImportError as e:
|
|
logger = logging.getLogger(__name__)
|
|
logger.error(f"MapDrawing failed to import dependencies: {e}. Using fallbacks.")
|
|
|
|
class CanonicalFlightState:
|
|
pass
|
|
|
|
class MockMapConstants:
|
|
AREA_BOUNDARY_COLOR, TILE_TEXT_BG_COLOR, TILE_TEXT_COLOR = (
|
|
"blue",
|
|
"rgba(0, 0, 0, 150)",
|
|
"white",
|
|
)
|
|
AREA_BOUNDARY_THICKNESS_PX, DEFAULT_LABEL_FONT_PATH, TRACK_COLOR_PALETTE = (
|
|
2,
|
|
None,
|
|
["#FF0000"],
|
|
)
|
|
(
|
|
GRATICULE_ENABLED,
|
|
GRATICULE_LINE_COLOR,
|
|
GRATICULE_LINE_WIDTH,
|
|
GRATICULE_LABEL_FONT_SIZE,
|
|
) = (True, "gray", 1, 10)
|
|
|
|
map_constants = MockMapConstants()
|
|
TRACK_LINE_WIDTH = 2
|
|
|
|
def geo_to_pixel_on_unscaled_map(*args, **kwargs):
|
|
return None
|
|
|
|
def calculate_graticule_interval(span, pixels):
|
|
return 1.0
|
|
|
|
|
|
def draw_area_bounding_box(
|
|
pil_image_to_draw_on: Any,
|
|
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]],
|
|
color: str = "blue",
|
|
thickness: int = 2,
|
|
) -> Any:
|
|
if not (
|
|
PIL_LIB_AVAILABLE_DRAWING
|
|
and ImageDraw
|
|
and pil_image_to_draw_on
|
|
and current_map_geo_bounds
|
|
and current_stitched_map_pixel_shape
|
|
):
|
|
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]] = []
|
|
|
|
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
|
|
)
|
|
if pixel_coords:
|
|
pixel_corners.append(pixel_coords)
|
|
else:
|
|
logger.warning(
|
|
f"Could not convert BBox corner {lat},{lon} to pixel. Aborting draw."
|
|
)
|
|
return pil_image_to_draw_on
|
|
|
|
if len(pixel_corners) == 4:
|
|
draw = ImageDraw.Draw(pil_image_to_draw_on)
|
|
draw.line(pixel_corners + [pixel_corners[0]], fill=color, width=thickness)
|
|
|
|
return pil_image_to_draw_on
|
|
|
|
|
|
def draw_graticules(
|
|
draw: ImageDraw.ImageDraw,
|
|
map_geo_bounds: Tuple[float, float, float, float],
|
|
image_size: Tuple[int, int],
|
|
font: Optional[ImageFont.FreeTypeFont],
|
|
):
|
|
if not (PIL_LIB_AVAILABLE_DRAWING and draw and map_geo_bounds):
|
|
return
|
|
|
|
west, south, east, north = map_geo_bounds
|
|
lon_span, lat_span = east - west, north - south
|
|
img_width, img_height = image_size
|
|
|
|
# MODIFICATO: Passaggio dei parametri corretti alla funzione
|
|
lon_interval = calculate_graticule_interval(lon_span, img_width)
|
|
lat_interval = calculate_graticule_interval(lat_span, img_height)
|
|
|
|
line_color, line_width = (
|
|
map_constants.GRATICULE_LINE_COLOR,
|
|
map_constants.GRATICULE_LINE_WIDTH,
|
|
)
|
|
epsilon = 1e-9
|
|
|
|
start_lon = math.floor(west / lon_interval) * lon_interval
|
|
if start_lon < west - epsilon:
|
|
start_lon += lon_interval
|
|
|
|
lon = start_lon
|
|
while lon < east + epsilon:
|
|
p1_pixel = geo_to_pixel_on_unscaled_map(north, lon, map_geo_bounds, image_size)
|
|
p2_pixel = geo_to_pixel_on_unscaled_map(south, lon, map_geo_bounds, image_size)
|
|
|
|
if p1_pixel and p2_pixel:
|
|
draw.line([p1_pixel, p2_pixel], fill=line_color, width=line_width)
|
|
label = f"{abs(round(lon, 4)):g}°{'E' if lon >= 0 else 'W'}"
|
|
label_pos = (p1_pixel[0] + 5, 5)
|
|
if font:
|
|
draw.text(label_pos, label, font=font, fill=line_color, anchor="lt")
|
|
|
|
lon += lon_interval
|
|
|
|
start_lat = math.floor(south / lat_interval) * lat_interval
|
|
if start_lat < south - epsilon:
|
|
start_lat += lat_interval
|
|
|
|
lat = start_lat
|
|
while lat < north + epsilon:
|
|
p1_pixel = geo_to_pixel_on_unscaled_map(lat, west, map_geo_bounds, image_size)
|
|
p2_pixel = geo_to_pixel_on_unscaled_map(lat, east, map_geo_bounds, image_size)
|
|
|
|
if p1_pixel and p2_pixel:
|
|
draw.line([p1_pixel, p2_pixel], fill=line_color, width=line_width)
|
|
label = f"{abs(round(lat, 4)):g}°{'N' if lat >= 0 else 'S'}"
|
|
label_pos = (5, p1_pixel[1] - 5)
|
|
if font:
|
|
draw.text(label_pos, label, font=font, fill=line_color, anchor="lb")
|
|
|
|
lat += lat_interval
|
|
|
|
|
|
def _load_label_font(
|
|
scaled_font_size: int, is_graticule: bool = False
|
|
) -> Optional[Any]:
|
|
if not (PIL_LIB_AVAILABLE_DRAWING and ImageFont):
|
|
return None
|
|
|
|
font_size = (
|
|
map_constants.GRATICULE_LABEL_FONT_SIZE
|
|
if is_graticule
|
|
else max(1, scaled_font_size)
|
|
)
|
|
font_path = getattr(map_constants, "DEFAULT_LABEL_FONT_PATH", None)
|
|
|
|
try:
|
|
if font_path and os.path.exists(font_path):
|
|
return ImageFont.truetype(font_path, font_size)
|
|
else:
|
|
return ImageFont.load_default(size=font_size)
|
|
except (IOError, OSError):
|
|
logger.warning(f"Font file not found at {font_path}. Falling back.")
|
|
except AttributeError:
|
|
return ImageFont.load_default()
|
|
except Exception as e:
|
|
logger.error(f"Error loading font: {e}")
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _calculate_faded_color(
|
|
base_color_hex: str,
|
|
index_from_end: int,
|
|
total_visible_points: int,
|
|
min_intensity: float = 0.1,
|
|
) -> Optional[Tuple[int, int, int, int]]:
|
|
if not (0 <= index_from_end < total_visible_points and ImageColor):
|
|
return None
|
|
try:
|
|
r, g, b = ImageColor.getrgb(base_color_hex)
|
|
intensity = (
|
|
min_intensity
|
|
+ (1.0 - min_intensity)
|
|
* ((total_visible_points - 1 - index_from_end) / (total_visible_points - 1))
|
|
if total_visible_points > 1
|
|
else 1.0
|
|
)
|
|
return (r, g, b, int(max(0, min(255, intensity * 255))))
|
|
except Exception:
|
|
return (128, 128, 128, 255)
|
|
|
|
|
|
def _draw_single_flight(
|
|
draw: Any,
|
|
pixel_coords: Tuple[int, int],
|
|
flight_state: CanonicalFlightState,
|
|
label_font: Optional[Any],
|
|
track_deque: Optional[deque],
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]],
|
|
):
|
|
if not (PIL_LIB_AVAILABLE_DRAWING and draw and pixel_coords and flight_state):
|
|
return
|
|
|
|
px, py = pixel_coords
|
|
base_length, side_length = 8, 12
|
|
label_text = (
|
|
flight_state.callsign.strip()
|
|
if flight_state.callsign and flight_state.callsign.strip()
|
|
else flight_state.icao24
|
|
)
|
|
|
|
color_index = hash(flight_state.icao24) % len(map_constants.TRACK_COLOR_PALETTE)
|
|
base_color = map_constants.TRACK_COLOR_PALETTE[color_index]
|
|
|
|
if track_deque and len(track_deque) > 1:
|
|
pixel_points = [
|
|
p
|
|
for p in [
|
|
geo_to_pixel_on_unscaled_map(
|
|
pt[0],
|
|
pt[1],
|
|
current_map_geo_bounds,
|
|
current_stitched_map_pixel_shape,
|
|
)
|
|
for pt in list(track_deque)
|
|
]
|
|
if p
|
|
]
|
|
if len(pixel_points) > 1:
|
|
for i in range(len(pixel_points) - 1):
|
|
faded_color = _calculate_faded_color(
|
|
base_color, len(pixel_points) - 2 - i, len(pixel_points)
|
|
)
|
|
if faded_color:
|
|
draw.line(
|
|
[pixel_points[i], pixel_points[i + 1]],
|
|
fill=faded_color,
|
|
width=TRACK_LINE_WIDTH,
|
|
)
|
|
|
|
angle_rad = math.radians(90 - (flight_state.true_track_deg or 0.0))
|
|
tip = (
|
|
px + side_length * math.cos(angle_rad),
|
|
py - side_length * math.sin(angle_rad),
|
|
)
|
|
base1 = (
|
|
px + (base_length / 2) * math.cos(angle_rad + math.pi / 2),
|
|
py - (base_length / 2) * math.sin(angle_rad + math.pi / 2),
|
|
)
|
|
base2 = (
|
|
px + (base_length / 2) * math.cos(angle_rad - math.pi / 2),
|
|
py - (base_length / 2) * math.sin(angle_rad - math.pi / 2),
|
|
)
|
|
draw.polygon([tip, base1, base2], fill=base_color, outline="black")
|
|
|
|
if label_font:
|
|
try:
|
|
anchor_x, anchor_y = px + 15, py
|
|
bbox = draw.textbbox(
|
|
(anchor_x, anchor_y), label_text, font=label_font, anchor="lm"
|
|
)
|
|
padding = 2
|
|
bg_bbox = (
|
|
bbox[0] - padding,
|
|
bbox[1] - padding,
|
|
bbox[2] + padding,
|
|
bbox[3] + padding,
|
|
)
|
|
draw.rectangle(bg_bbox, fill=map_constants.TILE_TEXT_BG_COLOR)
|
|
draw.text(
|
|
(anchor_x, anchor_y),
|
|
label_text,
|
|
fill=map_constants.TILE_TEXT_COLOR,
|
|
font=label_font,
|
|
anchor="lm",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error drawing label for '{label_text}': {e}", exc_info=False)
|
|
|
|
|
|
def _draw_text_on_placeholder(draw: Any, image_size: Tuple[int, int], text: str):
|
|
if not (PIL_LIB_AVAILABLE_DRAWING and draw):
|
|
return
|
|
img_w, img_h = image_size
|
|
font = _load_label_font(12, is_graticule=True)
|
|
if not font:
|
|
draw.text((10, 10), text, fill="black")
|
|
return
|
|
try:
|
|
bbox = draw.textbbox((0, 0), text, font=font, anchor="lt", spacing=4)
|
|
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
pos_x, pos_y = (img_w - text_w) / 2, (img_h - text_h) / 2
|
|
draw.text((pos_x, pos_y), text, fill="black", font=font, spacing=4)
|
|
except Exception as e:
|
|
logger.warning(f"Error drawing placeholder text: {e}. Falling back.")
|
|
draw.text((10, 10), text, fill="black")
|