|
| 1 | +"""Place Set-of-Marks labels so they don't overlap, with readable label colours. |
| 2 | +
|
| 3 | +Set-of-Marks overlays a numbered label on every element so a vision model can |
| 4 | +say "click 7". ``set_of_marks`` draws each label at a fixed offset, so on dense |
| 5 | +UIs the numbers pile on top of each other (unreadable) and a dark label on a |
| 6 | +dark element vanishes. ``marks_layout`` fixes both with pure geometry: |
| 7 | +
|
| 8 | +* :func:`place_labels` — greedy non-overlap placement: for each mark, try a ring |
| 9 | + of candidate positions around its box and take the first that stays in bounds |
| 10 | + and clears every already-placed label. |
| 11 | +* :func:`label_color` — pick the label text colour (black or white) with the |
| 12 | + better WCAG contrast against the element's background. |
| 13 | +
|
| 14 | +Pure standard library; reuses :func:`a11y_audit.contrast_ratio`. Fully testable |
| 15 | +without rendering. Imports no ``PySide6``. |
| 16 | +""" |
| 17 | +from typing import Any, Dict, List, Optional, Sequence, Tuple |
| 18 | + |
| 19 | +Rect = Tuple[int, int, int, int] |
| 20 | + |
| 21 | +_BLACK = (0, 0, 0) |
| 22 | +_WHITE = (255, 255, 255) |
| 23 | + |
| 24 | + |
| 25 | +def _overlap(first: Rect, second: Rect) -> bool: |
| 26 | + """Whether two ``(x, y, w, h)`` rectangles overlap (pure).""" |
| 27 | + ax, ay, aw, ah = first |
| 28 | + bx, by, bw, bh = second |
| 29 | + return not (ax + aw <= bx or bx + bw <= ax |
| 30 | + or ay + ah <= by or by + bh <= ay) |
| 31 | + |
| 32 | + |
| 33 | +def _in_bounds(rect: Rect, bounds: Tuple[int, int]) -> bool: |
| 34 | + """Whether ``rect`` fits inside ``(width, height)`` (pure).""" |
| 35 | + x, y, w, h = rect |
| 36 | + return x >= 0 and y >= 0 and x + w <= int(bounds[0]) \ |
| 37 | + and y + h <= int(bounds[1]) |
| 38 | + |
| 39 | + |
| 40 | +def _candidates(bbox: Sequence[int], label_w: int, |
| 41 | + label_h: int) -> List[Tuple[int, int]]: |
| 42 | + """Candidate label top-left positions around an anchor box (pure).""" |
| 43 | + bx, by, bw, bh = (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])) |
| 44 | + right = bx + bw - label_w |
| 45 | + below = by + bh |
| 46 | + return [ |
| 47 | + (bx, by - label_h), # above, left-aligned (default SoM spot) |
| 48 | + (right, by - label_h), # above, right-aligned |
| 49 | + (bx, below), # below, left-aligned |
| 50 | + (right, below), # below, right-aligned |
| 51 | + (bx, by), # inside, top-left |
| 52 | + (right, by), # inside, top-right |
| 53 | + ] |
| 54 | + |
| 55 | + |
| 56 | +def _clamp_to_bounds(rect: Rect, bounds: Tuple[int, int]) -> Rect: |
| 57 | + """Shift ``rect`` to fit inside ``(width, height)`` (pure fallback).""" |
| 58 | + x, y, w, h = rect |
| 59 | + x = max(0, min(int(bounds[0]) - w, x)) |
| 60 | + y = max(0, min(int(bounds[1]) - h, y)) |
| 61 | + return (x, y, w, h) |
| 62 | + |
| 63 | + |
| 64 | +def _pick_position(bbox: Sequence[int], label_w: int, label_h: int, |
| 65 | + bounds: Optional[Tuple[int, int]], |
| 66 | + placed: List[Rect]) -> Rect: |
| 67 | + """Pick the first candidate that is in bounds and clears placed labels.""" |
| 68 | + fallback: Optional[Rect] = None |
| 69 | + for cx, cy in _candidates(bbox, label_w, label_h): |
| 70 | + rect = (cx, cy, label_w, label_h) |
| 71 | + if fallback is None: |
| 72 | + fallback = rect |
| 73 | + if bounds is not None and not _in_bounds(rect, bounds): |
| 74 | + continue |
| 75 | + if any(_overlap(rect, other) for other in placed): |
| 76 | + continue |
| 77 | + return rect |
| 78 | + if bounds is not None and fallback is not None: |
| 79 | + return _clamp_to_bounds(fallback, bounds) |
| 80 | + return fallback if fallback is not None else (0, 0, label_w, label_h) |
| 81 | + |
| 82 | + |
| 83 | +def place_labels(marks: Sequence[Dict[str, Any]], *, label_width: int = 22, |
| 84 | + label_height: int = 16, |
| 85 | + bounds: Optional[Sequence[int]] = None |
| 86 | + ) -> List[Dict[str, Any]]: |
| 87 | + """Lay out non-overlapping label boxes for ``marks`` (pure). |
| 88 | +
|
| 89 | + ``marks`` is the :func:`set_of_marks.mark_elements` output (each has an |
| 90 | + ``id`` and ``bbox`` ``[x, y, w, h]``). ``bounds`` is the ``(width, height)`` |
| 91 | + the labels must stay within. Returns ``[{id, label, anchor}]`` where |
| 92 | + ``label`` is the placed ``[x, y, w, h]`` box. |
| 93 | + """ |
| 94 | + size = (int(label_width), int(label_height)) |
| 95 | + limit = (int(bounds[0]), int(bounds[1])) if bounds else None |
| 96 | + placed: List[Rect] = [] |
| 97 | + results: List[Dict[str, Any]] = [] |
| 98 | + for mark in marks: |
| 99 | + bbox = [int(value) for value in mark["bbox"][:4]] |
| 100 | + rect = _pick_position(bbox, size[0], size[1], limit, placed) |
| 101 | + placed.append(rect) |
| 102 | + results.append({"id": mark.get("id"), "label": list(rect), |
| 103 | + "anchor": [bbox[0], bbox[1]]}) |
| 104 | + return results |
| 105 | + |
| 106 | + |
| 107 | +def label_color(background: Sequence[float]) -> Dict[str, Any]: |
| 108 | + """Pick the higher-contrast label text colour for ``background`` (pure). |
| 109 | +
|
| 110 | + Returns ``{rgb, contrast}`` — black or white, whichever has the better WCAG |
| 111 | + contrast ratio against the element background colour. |
| 112 | + """ |
| 113 | + from je_auto_control.utils.a11y_audit import contrast_ratio |
| 114 | + black_contrast = contrast_ratio(background, _BLACK) |
| 115 | + white_contrast = contrast_ratio(background, _WHITE) |
| 116 | + if white_contrast >= black_contrast: |
| 117 | + return {"rgb": list(_WHITE), "contrast": round(white_contrast, 3)} |
| 118 | + return {"rgb": list(_BLACK), "contrast": round(black_contrast, 3)} |
0 commit comments