"""Разметка скриншотов: рамка вокруг элемента, номерной значок и стрелка к нему.""" import math from pathlib import Path from PIL import Image, ImageDraw, ImageFont RED = (225, 29, 72) FONT = Path(__file__).resolve().parents[2] / "app" / "fonts" / "DejaVuSans-Bold.ttf" def _arrow(d: ImageDraw.ImageDraw, p0, p1, w, head): d.line([p0, p1], fill=RED, width=w) ang = math.atan2(p1[1] - p0[1], p1[0] - p0[0]) tip = p1 a1, a2 = ang + math.radians(150), ang - math.radians(150) d.polygon([tip, (tip[0] + head * math.cos(a1), tip[1] + head * math.sin(a1)), (tip[0] + head * math.cos(a2), tip[1] + head * math.sin(a2))], fill=RED) def annotate(img: Image.Image, marks, scale: float = 1.0) -> Image.Image: """marks: [(номер, (x, y, w, h) в пикселях изображения, направление up|down|left|right|auto)]""" img = img.convert("RGBA") over = Image.new("RGBA", img.size, (0, 0, 0, 0)) d = ImageDraw.Draw(over) W, H = img.size S = max(scale, 1.0) font = ImageFont.truetype(str(FONT), int(17 * S)) pad, r, gap, lw = int(5 * S), int(15 * S), int(44 * S), max(2, int(3 * S)) for n, (x, y, w, h), direction in marks: box = (x - pad, y - pad, x + w + pad, y + h + pad) d.rounded_rectangle(box, radius=int(8 * S), fill=RED + (28,), outline=RED + (255,), width=lw) cx, cy = x + w / 2, y + h / 2 if direction == "auto": # где больше свободного места room = {"up": box[1], "down": H - box[3], "left": box[0], "right": W - box[2]} direction = "up" if room["up"] > 70 * S and room["up"] >= room["left"] * 0.6 else max(room, key=room.get) if direction == "up": bc, a0, a1 = (cx, box[1] - gap), (cx, box[1] - gap + r), (cx, box[1]) elif direction == "down": bc, a0, a1 = (cx, box[3] + gap), (cx, box[3] + gap - r), (cx, box[3]) elif direction == "left": bc, a0, a1 = (box[0] - gap, cy), (box[0] - gap + r, cy), (box[0], cy) else: bc, a0, a1 = (box[2] + gap, cy), (box[2] + gap - r, cy), (box[2], cy) # значок не должен выходить за край кадра nx = min(max(bc[0], r + 4), W - r - 4) ny = min(max(bc[1], r + 4), H - r - 4) if direction in ("up", "down"): a0, bc = (nx, a0[1]), (nx, bc[1]) a1 = (min(max(nx, box[0] + 6), box[2] - 6), a1[1]) else: a0, bc = (a0[0], ny), (bc[0], ny) a1 = (a1[0], min(max(ny, box[1] + 6), box[3] - 6)) _arrow(d, a0, a1, lw, int(11 * S)) bx, by = bc d.ellipse((bx - r - 2, by - r - 2, bx + r + 2, by + r + 2), fill=(255, 255, 255, 255)) d.ellipse((bx - r, by - r, bx + r, by + r), fill=RED + (255,)) tw = d.textlength(str(n), font=font) d.text((bx - tw / 2, by - font.size * 0.62), str(n), font=font, fill=(255, 255, 255, 255)) return Image.alpha_composite(img, over).convert("RGB") def annotate_side(img: Image.Image, marks, margin: int, scale: float = 1.0, bg=(238, 241, 246)) -> Image.Image: """Вариант для узких экранов: скриншот на светлом фоне с полями, номера стоят в полях и стрелками указывают внутрь.""" img = img.convert("RGB") W, H = img.size S = max(scale, 1.0) canvas = Image.new("RGB", (W + 2 * margin, H), bg) canvas.paste(img, (margin, 0)) over = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) d = ImageDraw.Draw(over) d.rectangle((margin - 1, 0, margin + W, H - 1), outline=(190, 196, 208, 255), width=max(2, int(2 * S))) font = ImageFont.truetype(str(FONT), int(17 * S)) pad, r, lw = int(4 * S), int(15 * S), max(2, int(3 * S)) last = {"left": -10**6, "right": -10**6} items = [] for idx, (n, (x, y, w, h), direction) in enumerate(marks): x += margin cx = x + w / 2 if direction in ("left", "right"): side = direction elif abs(w) > W * 0.6: side = "left" if idx % 2 == 0 else "right" else: side = "left" if cx < margin + W / 2 else "right" items.append((y + h / 2, idx, n, (x, y, w, h), side)) for cy, idx, n, (x, y, w, h), side in sorted(items): box = (x - pad, y - pad, x + w + pad, y + h + pad) d.rounded_rectangle(box, radius=int(8 * S), fill=RED + (28,), outline=RED + (255,), width=lw) by = min(max(cy, r + 4), H - r - 4) by = max(by, last[side] + 2 * r + int(10 * S)) last[side] = by bx = margin / 2 if side == "left" else margin + W + margin / 2 a_end_y = min(max(by, box[1] + 6), box[3] - 6) if side == "left": _arrow(d, (bx + r, by), (box[0], a_end_y), lw, int(11 * S)) else: _arrow(d, (bx - r, by), (box[2], a_end_y), lw, int(11 * S)) d.ellipse((bx - r - 2, by - r - 2, bx + r + 2, by + r + 2), fill=(255, 255, 255, 255)) d.ellipse((bx - r, by - r, bx + r, by + r), fill=RED + (255,)) tw = d.textlength(str(n), font=font) d.text((bx - tw / 2, by - font.size * 0.62), str(n), font=font, fill=(255, 255, 255, 255)) return Image.alpha_composite(canvas.convert("RGBA"), over).convert("RGB")