File size: 12,201 Bytes
b9e2109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
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 ""