oculus-ui-detector / modules /element_annotation.py
codebanesr
Initial commit for HuggingFace Spaces deployment
b9e2109
Raw
History Blame Contribute Delete
12.2 kB
import os
import logging
from typing import List, Dict, Tuple
from PIL import Image, ImageDraw, ImageFont
from modules.config import ANNOTATION_DIR, FONT_SIZE, JPEG_QUALITY, ANNOTATE_TEXT, DRAW_TEXT_BOXES, DRAW_ARROWS, ARROW_WIDTH, LABEL_BORDER_WIDTH
logger = logging.getLogger(__name__)
def get_text_color(image: Image.Image, x: int, y: int, size: int) -> tuple:
"""Determine the best text color (black or white) based on background brightness."""
# Get a small region around the point to analyze brightness
region = image.crop((
max(0, x - size//2),
max(0, y - size//2),
min(image.width, x + size//2),
min(image.height, y + size//2)
))
# Convert to grayscale and calculate average brightness
gray_region = region.convert("L")
brightness = sum(gray_region.getdata()) / len(gray_region.getdata())
# Return white for dark backgrounds, black for light backgrounds
return (255, 255, 255) if brightness < 128 else (0, 0, 0)
def check_label_collision(rect1, all_rects):
"""Check if a rectangle collides with any other rectangles."""
x1, y1, x2, y2 = rect1
for r in all_rects:
rx1, ry1, rx2, ry2 = r
# Check if rectangles overlap
if not (x2 < rx1 or x1 > rx2 or y2 < ry1 or y1 > ry2):
return True
return False
def find_closest_point_on_bbox(bbox: Tuple[int, int, int, int], point: Tuple[int, int]) -> Tuple[int, int]:
"""Find the closest point on the bounding box to the given point."""
x_min, y_min, x_max, y_max = bbox
px, py = point
# Check if point is inside the bbox
if x_min <= px <= x_max and y_min <= py <= y_max:
# Find the closest edge
distances = [
(abs(px - x_min), (x_min, py)), # left edge
(abs(px - x_max), (x_max, py)), # right edge
(abs(py - y_min), (px, y_min)), # top edge
(abs(py - y_max), (px, y_max)) # bottom edge
]
return min(distances, key=lambda d: d[0])[1]
# If point is outside the bbox, find the closest point
x = max(x_min, min(px, x_max))
y = max(y_min, min(py, y_max))
return (x, y)
def find_closest_point_on_label(label_box: Tuple[int, int, int, int], point: Tuple[int, int]) -> Tuple[int, int]:
"""Find the closest point on the label box to the given point."""
# Same logic as find_closest_point_on_bbox, but for label boxes
return find_closest_point_on_bbox(label_box, point)
def find_non_colliding_position(text_x, text_y, text_width, text_height, bbox, elements, image_width, image_height) -> Tuple[int, int]:
"""Find a position for the code label that doesn't collide with other elements."""
label_padding = 5 # Increased padding
padding = label_padding + LABEL_BORDER_WIDTH
# Original position (above the bbox)
positions = [
# Top (default)
(text_x, max(0, text_y)),
# Bottom
(text_x, min(image_height - text_height - padding * 2, bbox[3] + padding)),
# Left
(max(0, bbox[0] - text_width - padding * 2), (bbox[1] + bbox[3]) // 2 - text_height // 2),
# Right
(min(image_width - text_width - padding * 2, bbox[2] + padding), (bbox[1] + bbox[3]) // 2 - text_height // 2),
# Top-left corner
(max(0, bbox[0] - text_width - padding), max(0, bbox[1] - text_height - padding)),
# Top-right corner
(min(image_width - text_width - padding, bbox[2] + padding), max(0, bbox[1] - text_height - padding)),
# Bottom-left corner
(max(0, bbox[0] - text_width - padding), min(image_height - text_height - padding, bbox[3] + padding)),
# Bottom-right corner
(min(image_width - text_width - padding, bbox[2] + padding), min(image_height - text_height - padding, bbox[3] + padding))
]
# Try standard positions first
for pos_x, pos_y in positions:
rect = (pos_x - label_padding, pos_y - label_padding,
pos_x + text_width + label_padding, pos_y + text_height + label_padding)
if not check_label_collision(rect, elements):
return pos_x, pos_y
# If all standard positions collide, find the first non-colliding position
# by increasing distance from the bbox
for distance in range(10, 100, 10):
for angle in range(0, 360, 45):
import math
rad = math.radians(angle)
center_x = (bbox[0] + bbox[2]) // 2
center_y = (bbox[1] + bbox[3]) // 2
pos_x = int(center_x + distance * math.cos(rad))
pos_y = int(center_y + distance * math.sin(rad))
# Ensure within image bounds
pos_x = max(0, min(image_width - text_width - padding * 2, pos_x))
pos_y = max(0, min(image_height - text_height - padding * 2, pos_y))
rect = (pos_x - label_padding, pos_y - label_padding,
pos_x + text_width + label_padding, pos_y + text_height + label_padding)
if not check_label_collision(rect, elements):
return pos_x, pos_y
# If all positions collide, return the original position
return text_x, text_y
async def annotate_image(image: Image.Image, elements: List[Dict], filename: str) -> str:
"""Annotate the image with element codes."""
try:
# Create a copy of the image for annotation
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
image_width, image_height = image.size
# Try to load a font, or use default
try:
font = ImageFont.truetype("Arial.ttf", FONT_SIZE)
except IOError:
font = ImageFont.load_default()
# First, collect all text element bboxes to avoid placing labels over text
text_element_bboxes = []
for element in elements:
if element.get("type") == "text" and "bbox_pixels" in element and len(element["bbox_pixels"]) == 4:
x_min, y_min, x_max, y_max = element["bbox_pixels"]
# Add some padding around text elements
text_element_bboxes.append((x_min - 4, y_min - 4, x_max + 4, y_max + 4))
# Keep track of label positions to avoid overlaps
label_positions = text_element_bboxes.copy()
# Draw each element code
for element in elements:
# Skip elements without required fields
if "code" not in element or "bbox_pixels" not in element:
continue
is_text_element = element.get("type") == "text"
# Skip text elements if annotation for text is disabled
if is_text_element and not ANNOTATE_TEXT:
# Still collect bounding box for drawing if enabled
if DRAW_TEXT_BOXES and len(element["bbox_pixels"]) == 4:
x_min, y_min, x_max, y_max = element["bbox_pixels"]
text_color = get_text_color(image, x_min, y_min, FONT_SIZE * 2)
draw.rectangle([x_min, y_min, x_max, y_max], outline=text_color, width=2)
continue
code = element["code"]
bbox = element["bbox_pixels"]
if len(bbox) != 4:
continue
x_min, y_min, x_max, y_max = bbox
# Calculate dimensions for the code label
text_width = FONT_SIZE * len(code) * 0.6
text_height = FONT_SIZE
# Default position (top-left of the bounding box)
text_x = x_min
text_y = max(0, y_min - text_height - 4)
# Find a non-colliding position for the label
text_x, text_y = find_non_colliding_position(
text_x, text_y, text_width, text_height,
bbox, label_positions, image_width, image_height
)
# Label position already added above
# Get appropriate text color
text_color = get_text_color(image, text_x, text_y, FONT_SIZE * 2)
outline_color = (0, 0, 0) if text_color[0] > 128 else (255, 255, 255)
# Create label box with padding
label_padding = 5 # Increased padding
label_box = (
text_x - label_padding,
text_y - label_padding,
text_x + text_width + label_padding,
text_y + text_height + label_padding
)
# Store label box for collision avoidance (moved up before usage in arrow drawing)
label_positions.append(label_box)
# Draw text background
draw.rectangle(
label_box,
fill=(*outline_color, 180)
)
# Draw border around the label
draw.rectangle(
label_box,
outline=text_color,
width=LABEL_BORDER_WIDTH
)
# Draw text
draw.text((text_x, text_y), code, font=font, fill=text_color)
# Draw bounding box
draw.rectangle([x_min, y_min, x_max, y_max], outline=text_color, width=2)
# Draw arrow from label to bounding box if enabled
if DRAW_ARROWS:
# Calculate center points of element bbox
element_center_x = (x_min + x_max) / 2
element_center_y = (y_min + y_max) / 2
# Find closest point on the label box (where arrow starts)
arrow_start = find_closest_point_on_label(
label_box, (int(element_center_x), int(element_center_y))
)
# Find closest point on the element bbox (where arrow ends)
arrow_end = find_closest_point_on_bbox(
bbox, arrow_start
)
# Draw the arrow line (from label box to element)
draw.line(
[arrow_start, arrow_end],
fill=text_color,
width=ARROW_WIDTH
)
# Calculate direction vector (now for arrow going TO element, not FROM label)
dx = arrow_end[0] - arrow_start[0]
dy = arrow_end[1] - arrow_start[1]
# Normalize the direction vector
length = (dx**2 + dy**2)**0.5
if length > 0:
dx, dy = dx/length, dy/length
# Calculate arrowhead points
arrow_size = max(ARROW_WIDTH * 3, 8) # Scale with arrow width
# Calculate perpendicular vectors for the arrowhead
perpx, perpy = -dy, dx
# Calculate the three points of the arrowhead
point1 = arrow_end
point2 = (
int(arrow_end[0] - dx * arrow_size - perpx * arrow_size/2),
int(arrow_end[1] - dy * arrow_size - perpy * arrow_size/2)
)
point3 = (
int(arrow_end[0] - dx * arrow_size + perpx * arrow_size/2),
int(arrow_end[1] - dy * arrow_size + perpy * arrow_size/2)
)
# Draw the filled arrowhead triangle
draw.polygon([point1, point2, point3], fill=text_color)
# Save the annotated image
output_path = os.path.join(ANNOTATION_DIR, filename)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
annotated.save(output_path, "JPEG", quality=JPEG_QUALITY)
logger.info(f"Annotated image saved to {output_path}")
return output_path
except Exception as e:
logger.error(f"Error annotating image: {str(e)}")
return ""