import functools
import os
from collections import namedtuple
from typing import cast
import numpy as np
from PIL import ImageDraw, ImageFont
from nav.config import DEFAULT_CONFIG
from nav.support.image import draw_line_arrow
from nav.support.types import NDArrayBoolType, NDArrayIntType
TEXTINFO_LEFT = 'left'
TEXTINFO_LEFT_ARROW = 'left_arrow'
TEXTINFO_RIGHT = 'right'
TEXTINFO_RIGHT_ARROW = 'right_arrow'
TEXTINFO_TOP = 'top'
TEXTINFO_TOP_ARROW = 'top_arrow'
TEXTINFO_BOTTOM = 'bottom'
TEXTINFO_BOTTOM_ARROW = 'bottom_arrow'
TEXTINFO_CENTER = 'center'
TEXTINFO_TOP_LEFT = 'top_left'
TEXTINFO_TOP_RIGHT = 'top_right'
TEXTINFO_BOTTOM_LEFT = 'bottom_left'
TEXTINFO_BOTTOM_RIGHT = 'bottom_right'
TextLocInfo = namedtuple('TextLocInfo', ['label', 'label_v', 'label_u'])
@functools.cache
def _load_font(path: str, size: int) -> ImageFont.FreeTypeFont:
"""Loads and caches a font for text rendering.
Parameters:
path: Path to the font file.
size: Font size in points.
Returns:
A FreeTypeFont object for the specified font and size.
"""
# TODO Add error handling
return ImageFont.truetype(path, size)
[docs]
class AnnotationTextInfo:
def __init__(
self,
text: str,
text_loc: list[TextLocInfo],
ref_vu: tuple[int, int] | None,
*,
color: tuple[int, ...],
font: str,
font_size: int,
):
"""Initializes text annotation information.
Parameters:
text: The text to display.
text_loc: List of possible text locations with positioning information.
ref_vu: Optional reference point (v, u) that the text is associated with.
color: RGB or RGBA color tuple for the text.
font: Font filename to use for rendering.
font_size: Font size in points.
"""
self._config = DEFAULT_CONFIG
self._text = text
self._text_loc = text_loc
self._ref_vu = ref_vu
self._color = tuple(color)
self._font = font
self._font_size = font_size
@property
def text(self) -> str:
"""Returns the annotation text."""
return self._text
@property
def text_loc(self) -> list[TextLocInfo]:
"""Returns the list of possible text locations."""
return self._text_loc
@property
def ref_vu(self) -> tuple[int, int] | None:
"""Returns the reference point (v, u) that the text is associated with."""
return self._ref_vu
@property
def color(self) -> tuple[int, ...]:
"""Returns the RGB or RGBA color tuple for the text."""
return self._color
@property
def font(self) -> str:
"""Returns the font filename used for rendering the text."""
return self._font
@property
def font_size(self) -> int:
"""Returns the font size in points."""
return self._font_size
[docs]
def __str__(self) -> str:
"""Returns a string representation of the text annotation information."""
ret = (
'AnnotationTextInfo\n'
f'Text: {self.text}\n'
f'Ref vu: {self.ref_vu}, Color: {self.color}, Font: {self.font}, '
f'Font size: {self.font_size}\n'
'Text loc: '
)
for loc in self.text_loc[:10]:
ret += f'{loc} '
if len(self.text_loc) > 10:
ret += '...'
return ret
[docs]
def __repr__(self) -> str:
"""Returns a string representation of the text annotation information."""
return self.__str__()
def _draw_text(
self,
*,
ann_num: int,
extfov: tuple[int, int],
offset: tuple[float, float],
avoid_mask: NDArrayBoolType | None,
text_layer: NDArrayIntType,
graphic_layer: NDArrayIntType,
ann_num_mask: NDArrayIntType | None,
text_draw: ImageDraw.ImageDraw,
tt_dir: str,
show_all_positions: bool,
) -> bool:
"""Try to place the text in a location that doesn't conflict with other elements.
Parameters:
ann_num: Annotation number for identification.
extfov: Extended field of view margins (v, u).
offset: Offset to apply to coordinates (dv, du).
avoid_mask: Mask of areas to avoid when placing text.
text_layer: Image layer for rendering text.
graphic_layer: Image layer for rendering arrows.
ann_num_mask: Mask tracking where annotations have been placed.
text_draw: ImageDraw object for rendering text.
tt_dir: Directory containing TrueType fonts.
show_all_positions: Whether to try all positions or stop after finding the
first valid one.
Returns:
True if the text was successfully placed, False otherwise.
"""
# ref_vu is in the actual extended FOV coordinate system (never negative)
ext_offset_v = int(np.round(offset[0])) - extfov[0]
ext_offset_u = int(np.round(offset[1])) - extfov[1]
if self.ref_vu is not None and (
self.ref_vu[0] + ext_offset_v < 0
or self.ref_vu[0] + ext_offset_v >= text_layer.shape[0]
or self.ref_vu[1] + ext_offset_u < 0
or self.ref_vu[1] + ext_offset_u >= text_layer.shape[1]
):
# The thing we're labeling isn't in the FOV, so don't bother labeling it
return True
font = _load_font(os.path.join(tt_dir, self.font), self.font_size)
text_size = cast(
tuple[int, int, int, int],
text_draw.textbbox((0, 0), self.text, anchor='la', font=font),
)
text_offset_u = text_size[0]
text_offset_v = text_size[1]
text_width_u = text_size[2] - text_size[0]
text_width_v = text_size[3] - text_size[1]
horiz_arrow_gap = 2
horiz_arrow_len = 15
vert_arrow_gap = 2
vert_arrow_len = 15
arrow_thickness = 1.5
arrow_head_length = 6
arrow_head_angle = 30
edge_margin = 3 # Margin at edge of FOV
text_margin = 2 # Margin around text and arrow so text doesn't get too close
v_margin_min = edge_margin
v_margin_max = text_layer.shape[0] - edge_margin - 1
u_margin_min = edge_margin
u_margin_max = text_layer.shape[1] - edge_margin - 1
# Run through the possible positions in order. For each, figure out where the
# text (and optionally, arrow) goes. Then see if that location would conflict
# with existing text or the avoid mask. If it doesn't conflict, put the text
# (and optionally, arrow) and quit. This gives priority to the positions
# earliest in the list.
for text_pos, text_v, text_u in self.text_loc:
text_v = text_v + ext_offset_v
text_u = text_u + ext_offset_u
arrow_u0 = arrow_u1 = arrow_v0 = arrow_v1 = None
if text_pos == TEXTINFO_LEFT:
v = text_v - text_width_v // 2
u = text_u - text_width_u
elif text_pos == TEXTINFO_LEFT_ARROW:
v = text_v - text_width_v // 2
u = text_u - text_width_u - horiz_arrow_len - horiz_arrow_gap
arrow_v0 = arrow_v1 = text_v
arrow_u0 = text_u - horiz_arrow_len
arrow_u1 = text_u
elif text_pos == TEXTINFO_RIGHT:
v = text_v - text_width_v // 2
u = text_u
elif text_pos == TEXTINFO_RIGHT_ARROW:
v = text_v - text_width_v // 2
u = text_u + horiz_arrow_len + horiz_arrow_gap
arrow_v0 = arrow_v1 = text_v
arrow_u0 = text_u + horiz_arrow_len
arrow_u1 = text_u
elif text_pos == TEXTINFO_TOP:
v = text_v - text_width_v
u = text_u - text_width_u // 2
elif text_pos == TEXTINFO_TOP_ARROW:
v = text_v - text_width_v - vert_arrow_len - vert_arrow_gap
u = text_u - text_width_u // 2
arrow_u0 = arrow_u1 = text_u
arrow_v0 = text_v - vert_arrow_len
arrow_v1 = text_v
elif text_pos == TEXTINFO_BOTTOM:
v = text_v
u = text_u - text_width_u // 2
elif text_pos == TEXTINFO_BOTTOM_ARROW:
v = text_v + vert_arrow_len + vert_arrow_gap
u = text_u - text_width_u // 2
arrow_u0 = arrow_u1 = text_u
arrow_v0 = text_v + vert_arrow_len
arrow_v1 = text_v
elif text_pos == TEXTINFO_CENTER:
v = text_v - text_width_v // 2
u = text_u - text_width_u // 2
elif text_pos == TEXTINFO_TOP_LEFT:
v = text_v - text_width_v
u = text_u - text_width_u
elif text_pos == TEXTINFO_TOP_RIGHT:
v = text_v - text_width_v
u = text_u
elif text_pos == TEXTINFO_BOTTOM_LEFT:
v = text_v
u = text_u - text_width_u
elif text_pos == TEXTINFO_BOTTOM_RIGHT:
v = text_v
u = text_u
else:
raise ValueError(f'Unknown text position: {text_pos}')
# TODO This does not handle the case of the thing we're pointing at being off
# the offset image while the text is still visible. For example,
# inst_id = 'coiss'; URL = URL_CASSINI_ISS_STARS_02; offset = (-9, 28)
# This mess with text_offset_u and text_offset_v is because textbbox and text
# don't support the "lt" anchor for multi-line text, so we have to use "la",
# which has additional left and top margin, and then subtract it off
# ourselves.
# See: https://github.com/python-pillow/Pillow/issues/5080
v0_margin = v - text_offset_v - text_margin
v1_margin = v - text_offset_v + text_width_v + text_margin
u0_margin = u - text_offset_u - text_margin
u1_margin = u - text_offset_u + text_width_u + text_margin
if (
v0_margin < v_margin_min
or v1_margin > v_margin_max
or u0_margin < u_margin_min
or u1_margin > u_margin_max
):
# Text would run off edge
continue
if avoid_mask is not None and np.any(
avoid_mask[v0_margin:v1_margin, u0_margin:u1_margin]
):
# Conflicts with something the program doesn't want us to overwrite
continue
if ann_num_mask is not None and np.any(
ann_num_mask[v0_margin:v1_margin, u0_margin:u1_margin]
):
# Conflicts with text or arrows we've already drawn
continue
if arrow_u0 is not None:
assert arrow_u1 is not None
assert arrow_v0 is not None
assert arrow_v1 is not None
# Calculate head width from angle and add a little margin of error
head_width = (
int(np.ceil(arrow_head_length * np.sin(np.deg2rad(arrow_head_angle)) * 2)) + 2
)
if arrow_v0 == arrow_v1: # Horizontal arrow
arrow_box_v0 = arrow_v0 - head_width // 2
arrow_box_v1 = arrow_v0 + head_width // 2
arrow_box_u0 = arrow_u0
arrow_box_u1 = arrow_u1
else: # Vertical arrow
arrow_box_u0 = arrow_u0 - head_width // 2
arrow_box_u1 = arrow_u0 + head_width // 2
arrow_box_v0 = arrow_v0
arrow_box_v1 = arrow_v1
arrow_box_v0, arrow_box_v1 = (
min(arrow_box_v0, arrow_box_v1),
max(arrow_box_v0, arrow_box_v1),
)
arrow_box_u0, arrow_box_u1 = (
min(arrow_box_u0, arrow_box_u1),
max(arrow_box_u0, arrow_box_u1),
)
if (
not v_margin_min <= arrow_box_v0 <= v_margin_max
or not v_margin_min <= arrow_box_v1 <= v_margin_max
or not u_margin_min <= arrow_box_u0 <= u_margin_max
or not u_margin_min <= arrow_box_u1 <= u_margin_max
):
# Arrow would run off edge
continue
if ann_num_mask is not None:
if np.any(
ann_num_mask[
arrow_box_v0 : arrow_box_v1 + 1,
arrow_box_u0 : arrow_box_u1 + 1,
]
):
# Conflicts with text or arrows we've already drawn
continue
ann_num_mask[
arrow_box_v0 : arrow_box_v1 + 1, arrow_box_u0 : arrow_box_u1 + 1
] = ann_num + 1
draw_line_arrow(
graphic_layer,
self.color,
arrow_u0,
arrow_v0,
arrow_u1,
arrow_v1,
thickness=arrow_thickness,
arrow_head_length=arrow_head_length,
arrow_head_angle=arrow_head_angle,
)
text_draw.text(
(u - text_offset_u, v - text_offset_v),
self.text,
anchor='la',
fill=self.color,
font=font,
)
if ann_num_mask is not None:
ann_num_mask[v0_margin:v1_margin, u0_margin:u1_margin] = ann_num + 1
if not show_all_positions:
break
else:
if not show_all_positions:
return False
return True