[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6ADPXCJGBG8F5W0QJ9TY71Y":3,"skill-same:01M6ADPXCJGBG8F5W0QJ9TY71Y":73},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":38,"linkOnly":39,"markdownContent":40,"markdownBody":41,"frontMatter":42,"refName":43,"commitSha":44,"skillDirectory":45,"forksCount":46,"repositoryHtmlUrl":47,"repositoryHomepageUrl":48,"ownerHtmlUrl":49,"ownerBlogUrl":50,"ownerDescription":51,"updatedAt":52,"install":53,"resources":66},"01M6ADPXCJGBG8F5W0QJ9TY71Y","image-annotations",null,"Annotate screenshots, diagrams, and images with callout rectangles, arrows, labels, and color-coded highlights using PIL. Includes rules for animated GIF annotations with timing and pacing.","github\u002Fawesome-copilot","github","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F9919?v=4","01M6AC83DS133TPXFSP1DHNCC6","GitHub","01M6AD3PBDYPK7NVFJNJ66Y8RS",41081,"MIT","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fimage-annotations\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37],"image","image annotations","annotations","screenshots","Rules","images","diagrams","annotate","color","timing","with","gif","for","labels","2026-11-10 10:12:30",false,"---\nname: image-annotations\ndescription: 'Annotate screenshots, diagrams, and images with callout rectangles, arrows, labels, and color-coded highlights using PIL. Includes rules for animated GIF annotations with timing and pacing.'\n---\n\n# Image Annotations\n\nAdd visual callouts to any image — screenshots, diagrams, architecture docs, demo frames — using PIL\u002FPillow. Highlights what changed or what to look at, so reviewers don't have to guess.\n\n## When to Use This Skill\n\nUse this skill when you need to:\n\n- Highlight a specific area in a screenshot for a PR description\n- Annotate before\u002Fafter images to show what changed\n- Add labels and callouts to diagrams or architecture images\n- Create annotated frames for animated GIF demos\n\n## Prerequisites\n\n```bash\npip install Pillow -q\n```\n\n## Color Rules\n\n- **Red (`#E63946`)** — only for \"bad\" \u002F \"removed\" things (e.g., circling a bug being fixed)\n- **Yellowish-orange (`#FF9F1C`)** — for neutral highlights (\"look here\", \"new feature\", etc.)\n- Never use red just because it's eye-catching — red = bad\u002Fremoved\n\n## Font\n\n- Use **Ink Free** (`C:\u002FWindows\u002FFonts\u002FInkfree.ttf`) for a handwritten look on Windows\n- On Linux\u002FmacOS, fall back to `ImageFont.load_default()`\n- Size **36** for annotations on ~1400px-wide images\n- `stroke_width=1` with `stroke_fill=\u003Csame color as fill>` — gives body without being too thick\n- Do NOT use white stroke — looks like a bad glow effect\n\n## Shapes\n\n- Prefer **rounded rectangles** over circles\u002Fellipses — less pixelation at edges\n- `draw.rounded_rectangle([x1, y1, x2, y2], radius=14, outline=color, width=5)`\n- **Padding 18px** around the target content\n\n## Reference Snippet\n\n```python\nfrom PIL import Image, ImageDraw, ImageFont\n\n# Setup\nfont = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002FInkfree.ttf', 36)  # or load_default()\ncolor = '#FF9F1C'  # orange for highlights\nstroke = 5\npad = 18\n\nimg = Image.open('screenshot.png')\ndraw = ImageDraw.Draw(img)\n\n# Rounded rect with padding\ndraw.rounded_rectangle(\n    [x1 - pad, y1 - pad, x2 + pad, y2 + pad],\n    radius=14, outline=color, width=stroke\n)\n\n# Leader line (same thickness as rect)\ndraw.line([x2 + pad, cy, x2 + pad + 40, cy - 30], fill=color, width=stroke)\n\n# Label — same-color stroke for body, NO white stroke\ndraw.text(\n    (x2 + pad + 45, cy - 60), 'label text',\n    fill=color, font=font, stroke_width=1, stroke_fill=color\n)\n\nimg.save('annotated.png')\n```\n\n## Algorithmic Annotation — `annotate.py`\n\nFor images with multiple elements to annotate, use the `annotate.py` module below. Save it next to your script and import from it. It handles automatic label placement without overlapping.\n\n### Quick start\n\n```python\nfrom annotate import annotate_image\n\nresult = annotate_image(\n    'screenshot.png',\n    [\n        {'elem': (560, 275, 635, 390), 'label': 'button', 'draw_box': True},\n        {'elem': (105, 453, 236, 470), 'label': 'status text'},\n    ],\n    debug=True,\n)\nresult.save('annotated.png')\n```\n\n- `elem`: `(x1, y1, x2, y2)` tight bounding box — must be exact pixel coordinates\n- `label`: text label (supports `\\n` for multi-line)\n- `draw_box`: if `True`, draws a rounded rectangle around the element. If `False` (default), draws a V-arrowhead pointing at the element\n- `debug`: shows targeting rectangles and candidate heatmap for placement validation\n\n### Coordinate grid helper\n\n**Always use `grid_image()` before annotating an unfamiliar image.** Scaled-down previews display images smaller than actual pixel dimensions — the error compounds as you move away from (0,0).\n\n```python\nfrom annotate import grid_image\n\ngrid = grid_image('screenshot.png', step=100)\ngrid.save('grid.png')\n```\n\nThen verify with small crops:\n\n```python\nfrom PIL import Image\nimg = Image.open('screenshot.png')\ncrop = img.crop((x1 - 20, y1 - 20, x2 + 20, y2 + 20))\ncrop.save('verify.png')\n```\n\n### Algorithm overview\n\n1. **Ring search**: candidates between MIN_ARROW (25px) and MAX_ARROW (120px) from element edge\n2. **Contrast scoring**: prefers placements where label text is readable — `abs(avg_brightness - 147) - std * 0.3 - dist * 0.02`\n3. **Joint resolution**: candidates computed independently, placed greedily (best score first)\n4. **Hard blocks**: labels cannot overlap any other annotation's element or breathing box\n5. **Proximity penalty**: labels within 40px of other placed boxes get a score penalty\n6. **Arrow crossing penalty**: -50 for arrows crossing already-placed arrows\n\n### Debug mode colors\n\n| Color | Meaning |\n|-------|---------|\n| Cyan | Target element box (elem + padding) |\n| Gray | Exclusion zone (MIN_ARROW buffer) |\n| Red→Green | Candidate heatmap (red=bad, green=good) |\n| Magenta | Chosen label position |\n| Orange | Final rendered annotation |\n\n### Arrow styles\n\n- **`draw_box=True`**: rounded rectangle + straight line to label, no arrowhead\n- **`draw_box=False`**: V-shaped arrowhead with rounded line caps\n\n### `annotate.py` — full module\n\nSave this as `annotate.py` and import from it:\n\n```python\n\"\"\"\nAlgorithmic screenshot annotation with automatic label placement.\n\npip install Pillow numpy\nOptional for diff_images: pip install scipy\n\"\"\"\nimport math\nimport numpy as np\nfrom PIL import Image, ImageDraw, ImageFont\n\n# --- Defaults ---\nDEFAULT_FONT = 'C:\u002FWindows\u002FFonts\u002FInkfree.ttf'\nDEFAULT_FONT_SIZE = 32\nDEFAULT_COLOR = '#FF9F1C'\nDEFAULT_STROKE = 5\nMIN_ARROW = 25\nMAX_ARROW = 120\nTEXT_PAD = 6\nBREATH = 18\nCROSSING_PENALTY = 50\nPROXIMITY_MARGIN = 40\nPROXIMITY_PENALTY = 50\n\n\ndef _rect_intersects(a, b):\n    return a[0] \u003C b[2] and a[2] > b[0] and a[1] \u003C b[3] and a[3] > b[1]\n\n\ndef _segments_intersect(p1, p2, p3, p4):\n    def cross(o, a, b):\n        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])\n    d1, d2 = cross(p3, p4, p1), cross(p3, p4, p2)\n    d3, d4 = cross(p1, p2, p3), cross(p1, p2, p4)\n    return ((d1 > 0 and d2 \u003C 0) or (d1 \u003C 0 and d2 > 0)) and \\\n           ((d3 > 0 and d4 \u003C 0) or (d3 \u003C 0 and d4 > 0))\n\n\ndef _line_rect_exit(cx, cy, tx, ty, rect):\n    x1, y1, x2, y2 = rect\n    dx, dy = tx - cx, ty - cy\n    tmin, tmax = 0.0, 1.0\n    for lo, hi, p, d in [(x1, x2, cx, dx), (y1, y2, cy, dy)]:\n        if abs(d) \u003C 1e-9:\n            continue\n        t0, t1 = (lo - p) \u002F d, (hi - p) \u002F d\n        if t0 > t1:\n            t0, t1 = t1, t0\n        tmin, tmax = max(tmin, t0), min(tmax, t1)\n    return (cx + dx * tmax, cy + dy * tmax)\n\n\ndef _rect_gap(a, b):\n    dx = max(a[0] - b[2], b[0] - a[2], 0)\n    dy = max(a[1] - b[3], b[1] - a[3], 0)\n    if dx == 0 and dy == 0:\n        return 0\n    return math.sqrt(dx**2 + dy**2)\n\n\ndef _find_candidates(pixels, W, H, cyan, pw, ph, font):\n    cx, cy = (cyan[0] + cyan[2]) \u002F 2, (cyan[1] + cyan[3]) \u002F 2\n    excl_zone = (cyan[0] - MIN_ARROW, cyan[1] - MIN_ARROW,\n                 cyan[2] + MIN_ARROW, cyan[3] + MIN_ARROW)\n    sx1 = max(0, cyan[0] - MAX_ARROW - pw)\n    sy1 = max(0, cyan[1] - MAX_ARROW - ph)\n    sx2 = min(W - pw, cyan[2] + MAX_ARROW)\n    sy2 = min(H - ph, cyan[3] + MAX_ARROW)\n    step_x = max(8, min(pw \u002F\u002F 2, MAX_ARROW \u002F\u002F 3))\n    step_y = max(8, min(ph \u002F\u002F 2, MAX_ARROW \u002F\u002F 3))\n    cands = []\n    for px in range(sx1, sx2, step_x):\n        for py in range(sy1, sy2, step_y):\n            pink = (px, py, px + pw, py + ph)\n            if _rect_intersects(pink, excl_zone):\n                continue\n            gl, gr = cyan[0] - pink[2], pink[0] - cyan[2]\n            gt, gb = cyan[1] - pink[3], pink[1] - cyan[3]\n            hd, vd = max(gl, gr, 0), max(gt, gb, 0)\n            ed = math.sqrt(hd**2 + vd**2) if (hd > 0 and vd > 0) else max(hd, vd)\n            if ed > MAX_ARROW:\n                continue\n            region = pixels[py:py + ph, px:px + pw, :3].astype(float)\n            score = abs(np.mean(region) - 147) - np.std(region) * 0.3\n            dist = math.sqrt((px + pw\u002F2 - cx)**2 + (py + ph\u002F2 - cy)**2)\n            score -= dist * 0.02\n            cands.append(((px, py), score))\n    return cands\n\n\ndef _resolve_placements(annots, font):\n    placed = []\n    all_elem_zones = []\n    for ann in annots:\n        all_elem_zones.append(ann['cyan'])\n        if ann.get('draw_box', False):\n            c = ann['cyan']\n            all_elem_zones.append((c[0]-BREATH, c[1]-BREATH, c[2]+BREATH, c[3]+BREATH))\n    for ann in sorted(annots, key=lambda a: -a['best_score']):\n        pw, ph = ann['pw'], ann['ph']\n        cyan = ann['cyan']\n        cx, cy = ann['cyan_center']\n        draw_box = ann.get('draw_box', False)\n        best_pos, best_score = None, -999\n        valid = []\n        for (px, py), score in ann['candidates']:\n            pink = (px, py, px + pw, py + ph)\n            ok = True\n            for ez in all_elem_zones:\n                if ez == cyan:\n                    continue\n                if ann.get('draw_box', False):\n                    own_viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n                    if ez == own_viz:\n                        continue\n                if _rect_intersects(pink, ez):\n                    ok = False; break\n            if not ok:\n                continue\n            for p_pink, p_excl, p_viz, _ in placed:\n                if _rect_intersects(pink, p_pink) or _rect_intersects(pink, p_excl):\n                    ok = False; break\n                if p_viz and _rect_intersects(pink, p_viz):\n                    ok = False; break\n            if not ok:\n                continue\n            for p_pink, p_excl, p_viz, _ in placed:\n                for rect in [p_pink, p_excl, p_viz]:\n                    if rect is None:\n                        continue\n                    gap = _rect_gap(pink, rect)\n                    if gap \u003C PROXIMITY_MARGIN:\n                        score -= PROXIMITY_PENALTY * (1 - gap \u002F PROXIMITY_MARGIN)\n            for ez in all_elem_zones:\n                if ez == cyan:\n                    continue\n                gap = _rect_gap(pink, ez)\n                if gap \u003C PROXIMITY_MARGIN:\n                    score -= PROXIMITY_PENALTY * (1 - gap \u002F PROXIMITY_MARGIN)\n            tcx, tcy = px + pw\u002F2, py + ph\u002F2\n            cand_start = _line_rect_exit(tcx, tcy, cx, cy, pink)\n            if draw_box:\n                viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n                cand_end = _line_rect_exit(cx, cy, tcx, tcy, viz)\n            else:\n                cand_end = _line_rect_exit(cx, cy, tcx, tcy, cyan)\n            for _, _, _, pa in placed:\n                if pa and _segments_intersect(cand_start, cand_end, pa[0], pa[1]):\n                    score -= CROSSING_PENALTY; break\n            valid.append(((px, py), score))\n            if score > best_score:\n                best_score, best_pos = score, (px, py)\n        ann['valid_candidates'] = valid\n        if best_pos is None:\n            ann['pink'] = ann['tpos'] = ann['astart'] = ann['aend'] = ann['viz'] = None\n            continue\n        px, py = best_pos\n        pink = (px, py, px + pw, py + ph)\n        ann['pink'] = pink\n        ann['tpos'] = (px + TEXT_PAD, py + TEXT_PAD)\n        tcx, tcy = px + pw\u002F2, py + ph\u002F2\n        ann['astart'] = _line_rect_exit(tcx, tcy, cx, cy, pink)\n        if draw_box:\n            viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n            ann['viz'] = viz\n            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, viz)\n        else:\n            ann['viz'] = None\n            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, cyan)\n        placed.append((pink, ann['excl_zone'], ann['viz'], (ann['astart'], ann['aend'])))\n\n\ndef _draw_debug(img, annots, color):\n    overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))\n    od = ImageDraw.Draw(overlay)\n    for ann in annots:\n        cands = ann.get('valid_candidates', ann['candidates'])\n        if not cands:\n            continue\n        pw, ph = ann['pw'], ann['ph']\n        scores = [s for _, s in cands]\n        smin, smax = min(scores), max(scores)\n        rng = smax - smin if smax > smin else 1\n        for (px, py), score in cands:\n            t = (score - smin) \u002F rng\n            if t \u003C 0.5:\n                r_c, g_c, b_c = 220, int(180 * (t * 2)), 0\n            else:\n                r_c, g_c, b_c = int(220 * (1 - (t-0.5)*2)), 200, 0\n            alpha_fill = int(40 + 70 * t)\n            alpha_out = int(80 + 120 * t)\n            od.rectangle((px, py, px + pw, py + ph),\n                         fill=(r_c, g_c, b_c, alpha_fill), outline=(r_c, g_c, b_c, alpha_out), width=1)\n    for ann in annots:\n        ez = ann['excl_zone']\n        od.rectangle(ez, fill=(120, 120, 120, 50), outline=(160, 160, 160, 160), width=1)\n        od.rectangle(ann['cyan'], fill=(0, 255, 255, 30), outline=(0, 255, 255, 180), width=2)\n        if ann.get('pink'):\n            od.rectangle(ann['pink'], fill=(255, 0, 255, 50),\n                         outline=(255, 0, 255, 180), width=2)\n    return Image.alpha_composite(img, overlay)\n\n\ndef _draw_annotations(img, annots, font, color, stroke_width):\n    draw = ImageDraw.Draw(img)\n    for ann in annots:\n        if ann.get('viz'):\n            draw.rounded_rectangle(ann['viz'], radius=12, outline=color, width=stroke_width)\n        tpos = ann.get('tpos')\n        astart, aend = ann.get('astart'), ann.get('aend')\n        if not (tpos and astart and aend):\n            continue\n        sx, sy = int(astart[0]), int(astart[1])\n        ex, ey = int(aend[0]), int(aend[1])\n        draw.line([(sx, sy), (ex, ey)], fill=color, width=4, joint='curve')\n        r = 2\n        draw.ellipse([(sx-r, sy-r), (sx+r, sy+r)], fill=color)\n        draw.ellipse([(ex-r, ey-r), (ex+r, ey+r)], fill=color)\n        if not ann.get('draw_box', False):\n            angle = math.atan2(ey - sy, ex - sx)\n            al, spread = 18, 0.45\n            ax = ex - al * math.cos(angle - spread)\n            ay = ey - al * math.sin(angle - spread)\n            bx = ex - al * math.cos(angle + spread)\n            by = ey - al * math.sin(angle + spread)\n            draw.line([(int(ax), int(ay)), (ex, ey)], fill=color, width=4)\n            draw.line([(int(bx), int(by)), (ex, ey)], fill=color, width=4)\n            for px_, py_ in [(int(ax), int(ay)), (int(bx), int(by))]:\n                draw.ellipse([(px_-r, py_-r), (px_+r, py_+r)], fill=color)\n        draw.text(tpos, ann['label'], fill=color, font=font,\n                  stroke_width=1, stroke_fill=color)\n    return img\n\n\ndef annotate_image(image_path, annotations, *,\n                   debug=False,\n                   font_path=DEFAULT_FONT,\n                   font_size=DEFAULT_FONT_SIZE,\n                   color=DEFAULT_COLOR,\n                   stroke_width=DEFAULT_STROKE):\n    \"\"\"\n    Annotate a screenshot with automatic label placement.\n\n    Args:\n        image_path: path to the input image\n        annotations: list of dicts with keys:\n            - elem: (x1, y1, x2, y2) tight bounding box of element\n            - label: text label string\n            - draw_box: (optional, default False) draw rounded rect around element\n        debug: if True, draw developer rectangles (cyan\u002Fpink\u002Fgray\u002Fheatmap)\n        font_path: path to TTF font file\n        font_size: font size in pixels\n        color: hex color for annotations (default orange #FF9F1C)\n        stroke_width: width of orange highlight box outline\n\n    Returns:\n        PIL.Image with annotations drawn\n    \"\"\"\n    font = ImageFont.truetype(font_path, font_size)\n    img = Image.open(image_path).convert('RGBA')\n    pixels = np.array(img)\n    W, H = img.size\n    annots = []\n    for i, spec in enumerate(annotations):\n        eb = spec['elem']\n        em_pad = min(20, max(10, (eb[2] - eb[0]) \u002F\u002F 10))\n        cyan = (eb[0] - em_pad, eb[1] - em_pad, eb[2] + em_pad, eb[3] + em_pad)\n        lines = spec['label'].split('\\n')\n        tw = max(font.getbbox(line)[2] - font.getbbox(line)[0] for line in lines)\n        bbox = font.getbbox('Ay')\n        line_h = bbox[3] - bbox[1]\n        th = line_h * len(lines) + 4 * (len(lines) - 1)\n        pw, ph = tw + 2 * TEXT_PAD, th + 2 * TEXT_PAD\n        cands = _find_candidates(pixels, W, H, cyan, pw, ph, font)\n        annots.append({\n            'id': i,\n            'label': spec['label'],\n            'draw_box': spec.get('draw_box', False),\n            'cyan': cyan,\n            'cyan_center': ((cyan[0]+cyan[2])\u002F2, (cyan[1]+cyan[3])\u002F2),\n            'excl_zone': (cyan[0]-MIN_ARROW, cyan[1]-MIN_ARROW,\n                          cyan[2]+MIN_ARROW, cyan[3]+MIN_ARROW),\n            'pw': pw, 'ph': ph,\n            'candidates': cands,\n            'best_score': max((s for _, s in cands), default=-999),\n        })\n    _resolve_placements(annots, font)\n    annots.sort(key=lambda a: a['id'])\n    if debug:\n        img = _draw_debug(img, annots, color)\n    img = _draw_annotations(img, annots, font, color, stroke_width)\n    return img\n\n\ndef diff_images(before_path, after_path, *, threshold=30, min_pixels=300,\n                dilate=5, debug=False):\n    \"\"\"Find changed regions between two screenshots and return cluster boxes.\n\n    Returns (clusters, debug_img_or_None):\n        clusters: list of (x1, y1, x2, y2, pixel_count) sorted largest-first\n        debug_img: if debug=True, PIL Image with heatmap overlay and cluster boxes\n    \"\"\"\n    from scipy import ndimage\n    img_a = Image.open(before_path).convert('RGB')\n    img_b = Image.open(after_path).convert('RGB')\n    if img_a.size != img_b.size:\n        raise ValueError(f\"Image sizes differ: {img_a.size} vs {img_b.size}\")\n    arr_a = np.array(img_a, dtype=np.float32)\n    arr_b = np.array(img_b, dtype=np.float32)\n    W, H = img_a.size\n    diff = np.abs(arr_b - arr_a).max(axis=2)\n    mask = diff > threshold\n    dilated = ndimage.binary_dilation(mask, iterations=dilate)\n    labeled, n_clusters = ndimage.label(dilated)\n    clusters = []\n    for i in range(1, n_clusters + 1):\n        ys, xs = np.where(labeled == i)\n        if len(ys) \u003C min_pixels:\n            continue\n        clusters.append((int(xs.min()), int(ys.min()),\n                          int(xs.max()), int(ys.max()), len(ys)))\n    clusters.sort(key=lambda c: -c[4])\n    debug_img = None\n    if debug:\n        overlay = img_b.copy().convert('RGBA')\n        norm = np.clip(diff \u002F 255.0, 0, 1)\n        show_mask = diff > 10\n        r = np.clip((norm * 2) * 255, 0, 255).astype(np.uint8)\n        g = np.clip((1 - np.abs(norm - 0.5) * 2) * 200, 0, 200).astype(np.uint8)\n        b = np.clip((1 - norm) * 255, 0, 255).astype(np.uint8)\n        a = np.where(show_mask, np.clip(norm * 200 + 40, 40, 220).astype(np.uint8), 0)\n        heat = Image.fromarray(np.stack([r, g, b, a], axis=2), 'RGBA')\n        overlay = Image.alpha_composite(overlay, heat)\n        draw = ImageDraw.Draw(overlay)\n        try:\n            font = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002Fconsola.ttf', 18)\n        except OSError:\n            font = ImageFont.load_default()\n        for idx, (x1, y1, x2, y2, px_count) in enumerate(clusters):\n            draw.rectangle([x1, y1, x2, y2], outline=(0, 255, 255, 200), width=3)\n            label = f\"#{idx+1}  {px_count:,}px\"\n            bbox = font.getbbox(label)\n            tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]\n            lx, ly = x1, max(0, y1 - th - 8)\n            draw.rectangle([lx, ly, lx + tw + 8, ly + th + 4], fill=(0, 0, 0, 180))\n            draw.text((lx + 4, ly + 2), label, fill=(0, 255, 255, 255), font=font)\n        debug_img = overlay\n    return clusters, debug_img\n\n\ndef grid_image(image_path, step=100):\n    \"\"\"Draw a coordinate grid on an image for precise element location.\"\"\"\n    img = Image.open(image_path).convert('RGBA')\n    draw = ImageDraw.Draw(img)\n    W, H = img.size\n    try:\n        font = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002Fconsola.ttf', 14)\n    except OSError:\n        font = ImageFont.load_default()\n    for x in range(0, W, step):\n        draw.line([(x, 0), (x, H)], fill=(255, 0, 0, 120), width=1)\n        draw.text((x + 2, 2), str(x), fill=(255, 0, 0, 200), font=font)\n    for y in range(0, H, step):\n        draw.line([(0, y), (W, y)], fill=(255, 0, 0, 120), width=1)\n        draw.text((2, y + 2), str(y), fill=(255, 0, 0, 200), font=font)\n    return img\n```\n\n## Image Diffing\n\nFind what changed between two screenshots programmatically. Use as a safety net for subtle changes — when the difference is obvious, annotate directly instead.\n\n```python\nfrom annotate import diff_images\n\nclusters, debug_img = diff_images(\n    'before.png', 'after.png',\n    threshold=30,     # pixel difference floor (0-255)\n    min_pixels=300,   # ignore tiny noise clusters\n    dilate=5,         # merge nearby changed pixels\n    debug=True,       # render heatmap overlay\n)\n\n# clusters = [(x1, y1, x2, y2, pixel_count), ...] sorted largest-first\nif debug_img:\n    debug_img.save('diff-debug.png')\n\n# Feed clusters into annotate_image:\nannotations = [\n    {'elem': (x1, y1, x2, y2), 'label': f'Change #{i+1}', 'draw_box': True}\n    for i, (x1, y1, x2, y2, _) in enumerate(clusters[:3])\n]\n```\n\n**Debug heatmap colors:** Blue = small difference, Yellow = medium, Red = large, Cyan boxes = cluster bounding boxes.\n\n**When to use:** subtle opacity changes, dashed lines, minor color shifts, anti-aliasing differences.\n**When NOT to use:** any change you can see by eye — annotate directly for better labels.\n\n## Animated GIF Annotations\n\nDifferent from static images — animations have timing, transitions, and competing visual motion.\n\n### Element highlighting\n\n1. **Rects for big areas, arrows for small elements** — 500x300px area = rect, 200x25px element = arrow\n2. **Labels go RIGHT NEXT to what they describe** — short arrow (30-80px), label adjacent. Viewer's eye shouldn't travel more than ~100px\n3. **Arrow must not cross its own label** — pick the edge closest to the target\n4. **No bottom bar \u002F subtitle approach** — eyes jump between content and bar. Contextual placement only\n5. **Hero message gets a bigger font** — main takeaway 64pt+, detail annotations 38pt\n\n### Timing and pacing\n\n6. **Fade: 2-frame pop-in at 10fps** — 50% → 100% opacity (0.2s total). Easing curves look bad at low FPS\n7. **Type → pause → annotate** — during fast action, show NO annotation. Pause, then add it\n8. **Variable frame duration** — fast during action (100ms), slow during pauses (600-800ms), long hold for hero (500ms)\n9. **Higher FPS for smooth motion** — 10fps minimum for typing\u002Finteraction\n\n### Pop-in fade implementation\n\n```python\n# 2-frame pop-in at 10fps\nFADE_ALPHAS = [0.50, 1.00]\n\nfor frame_idx in range(total_frames):\n    if annotation_just_changed and local_idx \u003C len(FADE_ALPHAS):\n        alpha = FADE_ALPHAS[local_idx]\n    else:\n        alpha = 1.0\n    # Apply alpha to annotation elements:\n    # - pill background: fill=(r, g, b, int(base_alpha * alpha))\n    # - text: fill=(*color, int(255 * alpha))\n    # - rect outline: outline=(*color, int(255 * alpha))\n```\n\n## Guidelines\n\n1. **All elements same thickness** — rect `width`, line `width`, and visual text weight should feel consistent (~5px)\n2. Place labels **close to the rect** — short leader line (25-35px)\n3. Labels can overlap content — the stroke gives enough contrast\n4. **Show locally first** — verify before uploading to a PR\n5. **Take screenshots at native 1x, control display size in HTML** — use `\u003Cimg width=\"300\">` in markdown, never resize with PIL (creates artifacts)\n6. **Always check `Image.open(path).size` first** — HiDPI screenshots are larger than they appear (150% scaling = 1.5x CSS pixel dimensions)\n7. **Short labels work better** — wide labels have fewer valid placements. Use 1-3 words when possible\n8. **Verify with debug=True** — always check the first annotation of a new image with debug mode\n\n## Limitations\n\n- Ink Free font is Windows-only; other platforms need a fallback font\n- PIL text rendering is basic — no rich text, no markdown\n- Animated GIF annotations require frame-by-frame processing which can be slow for long recordings\n- Algorithmic placement works best with 2-6 annotations; more than that may produce crowded results\n","# Image Annotations\n\nAdd visual callouts to any image — screenshots, diagrams, architecture docs, demo frames — using PIL\u002FPillow. Highlights what changed or what to look at, so reviewers don't have to guess.\n\n## When to Use This Skill\n\nUse this skill when you need to:\n\n- Highlight a specific area in a screenshot for a PR description\n- Annotate before\u002Fafter images to show what changed\n- Add labels and callouts to diagrams or architecture images\n- Create annotated frames for animated GIF demos\n\n## Prerequisites\n\n```bash\npip install Pillow -q\n```\n\n## Color Rules\n\n- **Red (`#E63946`)** — only for \"bad\" \u002F \"removed\" things (e.g., circling a bug being fixed)\n- **Yellowish-orange (`#FF9F1C`)** — for neutral highlights (\"look here\", \"new feature\", etc.)\n- Never use red just because it's eye-catching — red = bad\u002Fremoved\n\n## Font\n\n- Use **Ink Free** (`C:\u002FWindows\u002FFonts\u002FInkfree.ttf`) for a handwritten look on Windows\n- On Linux\u002FmacOS, fall back to `ImageFont.load_default()`\n- Size **36** for annotations on ~1400px-wide images\n- `stroke_width=1` with `stroke_fill=\u003Csame color as fill>` — gives body without being too thick\n- Do NOT use white stroke — looks like a bad glow effect\n\n## Shapes\n\n- Prefer **rounded rectangles** over circles\u002Fellipses — less pixelation at edges\n- `draw.rounded_rectangle([x1, y1, x2, y2], radius=14, outline=color, width=5)`\n- **Padding 18px** around the target content\n\n## Reference Snippet\n\n```python\nfrom PIL import Image, ImageDraw, ImageFont\n\n# Setup\nfont = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002FInkfree.ttf', 36)  # or load_default()\ncolor = '#FF9F1C'  # orange for highlights\nstroke = 5\npad = 18\n\nimg = Image.open('screenshot.png')\ndraw = ImageDraw.Draw(img)\n\n# Rounded rect with padding\ndraw.rounded_rectangle(\n    [x1 - pad, y1 - pad, x2 + pad, y2 + pad],\n    radius=14, outline=color, width=stroke\n)\n\n# Leader line (same thickness as rect)\ndraw.line([x2 + pad, cy, x2 + pad + 40, cy - 30], fill=color, width=stroke)\n\n# Label — same-color stroke for body, NO white stroke\ndraw.text(\n    (x2 + pad + 45, cy - 60), 'label text',\n    fill=color, font=font, stroke_width=1, stroke_fill=color\n)\n\nimg.save('annotated.png')\n```\n\n## Algorithmic Annotation — `annotate.py`\n\nFor images with multiple elements to annotate, use the `annotate.py` module below. Save it next to your script and import from it. It handles automatic label placement without overlapping.\n\n### Quick start\n\n```python\nfrom annotate import annotate_image\n\nresult = annotate_image(\n    'screenshot.png',\n    [\n        {'elem': (560, 275, 635, 390), 'label': 'button', 'draw_box': True},\n        {'elem': (105, 453, 236, 470), 'label': 'status text'},\n    ],\n    debug=True,\n)\nresult.save('annotated.png')\n```\n\n- `elem`: `(x1, y1, x2, y2)` tight bounding box — must be exact pixel coordinates\n- `label`: text label (supports `\\n` for multi-line)\n- `draw_box`: if `True`, draws a rounded rectangle around the element. If `False` (default), draws a V-arrowhead pointing at the element\n- `debug`: shows targeting rectangles and candidate heatmap for placement validation\n\n### Coordinate grid helper\n\n**Always use `grid_image()` before annotating an unfamiliar image.** Scaled-down previews display images smaller than actual pixel dimensions — the error compounds as you move away from (0,0).\n\n```python\nfrom annotate import grid_image\n\ngrid = grid_image('screenshot.png', step=100)\ngrid.save('grid.png')\n```\n\nThen verify with small crops:\n\n```python\nfrom PIL import Image\nimg = Image.open('screenshot.png')\ncrop = img.crop((x1 - 20, y1 - 20, x2 + 20, y2 + 20))\ncrop.save('verify.png')\n```\n\n### Algorithm overview\n\n1. **Ring search**: candidates between MIN_ARROW (25px) and MAX_ARROW (120px) from element edge\n2. **Contrast scoring**: prefers placements where label text is readable — `abs(avg_brightness - 147) - std * 0.3 - dist * 0.02`\n3. **Joint resolution**: candidates computed independently, placed greedily (best score first)\n4. **Hard blocks**: labels cannot overlap any other annotation's element or breathing box\n5. **Proximity penalty**: labels within 40px of other placed boxes get a score penalty\n6. **Arrow crossing penalty**: -50 for arrows crossing already-placed arrows\n\n### Debug mode colors\n\n| Color | Meaning |\n|-------|---------|\n| Cyan | Target element box (elem + padding) |\n| Gray | Exclusion zone (MIN_ARROW buffer) |\n| Red→Green | Candidate heatmap (red=bad, green=good) |\n| Magenta | Chosen label position |\n| Orange | Final rendered annotation |\n\n### Arrow styles\n\n- **`draw_box=True`**: rounded rectangle + straight line to label, no arrowhead\n- **`draw_box=False`**: V-shaped arrowhead with rounded line caps\n\n### `annotate.py` — full module\n\nSave this as `annotate.py` and import from it:\n\n```python\n\"\"\"\nAlgorithmic screenshot annotation with automatic label placement.\n\npip install Pillow numpy\nOptional for diff_images: pip install scipy\n\"\"\"\nimport math\nimport numpy as np\nfrom PIL import Image, ImageDraw, ImageFont\n\n# --- Defaults ---\nDEFAULT_FONT = 'C:\u002FWindows\u002FFonts\u002FInkfree.ttf'\nDEFAULT_FONT_SIZE = 32\nDEFAULT_COLOR = '#FF9F1C'\nDEFAULT_STROKE = 5\nMIN_ARROW = 25\nMAX_ARROW = 120\nTEXT_PAD = 6\nBREATH = 18\nCROSSING_PENALTY = 50\nPROXIMITY_MARGIN = 40\nPROXIMITY_PENALTY = 50\n\n\ndef _rect_intersects(a, b):\n    return a[0] \u003C b[2] and a[2] > b[0] and a[1] \u003C b[3] and a[3] > b[1]\n\n\ndef _segments_intersect(p1, p2, p3, p4):\n    def cross(o, a, b):\n        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])\n    d1, d2 = cross(p3, p4, p1), cross(p3, p4, p2)\n    d3, d4 = cross(p1, p2, p3), cross(p1, p2, p4)\n    return ((d1 > 0 and d2 \u003C 0) or (d1 \u003C 0 and d2 > 0)) and \\\n           ((d3 > 0 and d4 \u003C 0) or (d3 \u003C 0 and d4 > 0))\n\n\ndef _line_rect_exit(cx, cy, tx, ty, rect):\n    x1, y1, x2, y2 = rect\n    dx, dy = tx - cx, ty - cy\n    tmin, tmax = 0.0, 1.0\n    for lo, hi, p, d in [(x1, x2, cx, dx), (y1, y2, cy, dy)]:\n        if abs(d) \u003C 1e-9:\n            continue\n        t0, t1 = (lo - p) \u002F d, (hi - p) \u002F d\n        if t0 > t1:\n            t0, t1 = t1, t0\n        tmin, tmax = max(tmin, t0), min(tmax, t1)\n    return (cx + dx * tmax, cy + dy * tmax)\n\n\ndef _rect_gap(a, b):\n    dx = max(a[0] - b[2], b[0] - a[2], 0)\n    dy = max(a[1] - b[3], b[1] - a[3], 0)\n    if dx == 0 and dy == 0:\n        return 0\n    return math.sqrt(dx**2 + dy**2)\n\n\ndef _find_candidates(pixels, W, H, cyan, pw, ph, font):\n    cx, cy = (cyan[0] + cyan[2]) \u002F 2, (cyan[1] + cyan[3]) \u002F 2\n    excl_zone = (cyan[0] - MIN_ARROW, cyan[1] - MIN_ARROW,\n                 cyan[2] + MIN_ARROW, cyan[3] + MIN_ARROW)\n    sx1 = max(0, cyan[0] - MAX_ARROW - pw)\n    sy1 = max(0, cyan[1] - MAX_ARROW - ph)\n    sx2 = min(W - pw, cyan[2] + MAX_ARROW)\n    sy2 = min(H - ph, cyan[3] + MAX_ARROW)\n    step_x = max(8, min(pw \u002F\u002F 2, MAX_ARROW \u002F\u002F 3))\n    step_y = max(8, min(ph \u002F\u002F 2, MAX_ARROW \u002F\u002F 3))\n    cands = []\n    for px in range(sx1, sx2, step_x):\n        for py in range(sy1, sy2, step_y):\n            pink = (px, py, px + pw, py + ph)\n            if _rect_intersects(pink, excl_zone):\n                continue\n            gl, gr = cyan[0] - pink[2], pink[0] - cyan[2]\n            gt, gb = cyan[1] - pink[3], pink[1] - cyan[3]\n            hd, vd = max(gl, gr, 0), max(gt, gb, 0)\n            ed = math.sqrt(hd**2 + vd**2) if (hd > 0 and vd > 0) else max(hd, vd)\n            if ed > MAX_ARROW:\n                continue\n            region = pixels[py:py + ph, px:px + pw, :3].astype(float)\n            score = abs(np.mean(region) - 147) - np.std(region) * 0.3\n            dist = math.sqrt((px + pw\u002F2 - cx)**2 + (py + ph\u002F2 - cy)**2)\n            score -= dist * 0.02\n            cands.append(((px, py), score))\n    return cands\n\n\ndef _resolve_placements(annots, font):\n    placed = []\n    all_elem_zones = []\n    for ann in annots:\n        all_elem_zones.append(ann['cyan'])\n        if ann.get('draw_box', False):\n            c = ann['cyan']\n            all_elem_zones.append((c[0]-BREATH, c[1]-BREATH, c[2]+BREATH, c[3]+BREATH))\n    for ann in sorted(annots, key=lambda a: -a['best_score']):\n        pw, ph = ann['pw'], ann['ph']\n        cyan = ann['cyan']\n        cx, cy = ann['cyan_center']\n        draw_box = ann.get('draw_box', False)\n        best_pos, best_score = None, -999\n        valid = []\n        for (px, py), score in ann['candidates']:\n            pink = (px, py, px + pw, py + ph)\n            ok = True\n            for ez in all_elem_zones:\n                if ez == cyan:\n                    continue\n                if ann.get('draw_box', False):\n                    own_viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n                    if ez == own_viz:\n                        continue\n                if _rect_intersects(pink, ez):\n                    ok = False; break\n            if not ok:\n                continue\n            for p_pink, p_excl, p_viz, _ in placed:\n                if _rect_intersects(pink, p_pink) or _rect_intersects(pink, p_excl):\n                    ok = False; break\n                if p_viz and _rect_intersects(pink, p_viz):\n                    ok = False; break\n            if not ok:\n                continue\n            for p_pink, p_excl, p_viz, _ in placed:\n                for rect in [p_pink, p_excl, p_viz]:\n                    if rect is None:\n                        continue\n                    gap = _rect_gap(pink, rect)\n                    if gap \u003C PROXIMITY_MARGIN:\n                        score -= PROXIMITY_PENALTY * (1 - gap \u002F PROXIMITY_MARGIN)\n            for ez in all_elem_zones:\n                if ez == cyan:\n                    continue\n                gap = _rect_gap(pink, ez)\n                if gap \u003C PROXIMITY_MARGIN:\n                    score -= PROXIMITY_PENALTY * (1 - gap \u002F PROXIMITY_MARGIN)\n            tcx, tcy = px + pw\u002F2, py + ph\u002F2\n            cand_start = _line_rect_exit(tcx, tcy, cx, cy, pink)\n            if draw_box:\n                viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n                cand_end = _line_rect_exit(cx, cy, tcx, tcy, viz)\n            else:\n                cand_end = _line_rect_exit(cx, cy, tcx, tcy, cyan)\n            for _, _, _, pa in placed:\n                if pa and _segments_intersect(cand_start, cand_end, pa[0], pa[1]):\n                    score -= CROSSING_PENALTY; break\n            valid.append(((px, py), score))\n            if score > best_score:\n                best_score, best_pos = score, (px, py)\n        ann['valid_candidates'] = valid\n        if best_pos is None:\n            ann['pink'] = ann['tpos'] = ann['astart'] = ann['aend'] = ann['viz'] = None\n            continue\n        px, py = best_pos\n        pink = (px, py, px + pw, py + ph)\n        ann['pink'] = pink\n        ann['tpos'] = (px + TEXT_PAD, py + TEXT_PAD)\n        tcx, tcy = px + pw\u002F2, py + ph\u002F2\n        ann['astart'] = _line_rect_exit(tcx, tcy, cx, cy, pink)\n        if draw_box:\n            viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)\n            ann['viz'] = viz\n            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, viz)\n        else:\n            ann['viz'] = None\n            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, cyan)\n        placed.append((pink, ann['excl_zone'], ann['viz'], (ann['astart'], ann['aend'])))\n\n\ndef _draw_debug(img, annots, color):\n    overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))\n    od = ImageDraw.Draw(overlay)\n    for ann in annots:\n        cands = ann.get('valid_candidates', ann['candidates'])\n        if not cands:\n            continue\n        pw, ph = ann['pw'], ann['ph']\n        scores = [s for _, s in cands]\n        smin, smax = min(scores), max(scores)\n        rng = smax - smin if smax > smin else 1\n        for (px, py), score in cands:\n            t = (score - smin) \u002F rng\n            if t \u003C 0.5:\n                r_c, g_c, b_c = 220, int(180 * (t * 2)), 0\n            else:\n                r_c, g_c, b_c = int(220 * (1 - (t-0.5)*2)), 200, 0\n            alpha_fill = int(40 + 70 * t)\n            alpha_out = int(80 + 120 * t)\n            od.rectangle((px, py, px + pw, py + ph),\n                         fill=(r_c, g_c, b_c, alpha_fill), outline=(r_c, g_c, b_c, alpha_out), width=1)\n    for ann in annots:\n        ez = ann['excl_zone']\n        od.rectangle(ez, fill=(120, 120, 120, 50), outline=(160, 160, 160, 160), width=1)\n        od.rectangle(ann['cyan'], fill=(0, 255, 255, 30), outline=(0, 255, 255, 180), width=2)\n        if ann.get('pink'):\n            od.rectangle(ann['pink'], fill=(255, 0, 255, 50),\n                         outline=(255, 0, 255, 180), width=2)\n    return Image.alpha_composite(img, overlay)\n\n\ndef _draw_annotations(img, annots, font, color, stroke_width):\n    draw = ImageDraw.Draw(img)\n    for ann in annots:\n        if ann.get('viz'):\n            draw.rounded_rectangle(ann['viz'], radius=12, outline=color, width=stroke_width)\n        tpos = ann.get('tpos')\n        astart, aend = ann.get('astart'), ann.get('aend')\n        if not (tpos and astart and aend):\n            continue\n        sx, sy = int(astart[0]), int(astart[1])\n        ex, ey = int(aend[0]), int(aend[1])\n        draw.line([(sx, sy), (ex, ey)], fill=color, width=4, joint='curve')\n        r = 2\n        draw.ellipse([(sx-r, sy-r), (sx+r, sy+r)], fill=color)\n        draw.ellipse([(ex-r, ey-r), (ex+r, ey+r)], fill=color)\n        if not ann.get('draw_box', False):\n            angle = math.atan2(ey - sy, ex - sx)\n            al, spread = 18, 0.45\n            ax = ex - al * math.cos(angle - spread)\n            ay = ey - al * math.sin(angle - spread)\n            bx = ex - al * math.cos(angle + spread)\n            by = ey - al * math.sin(angle + spread)\n            draw.line([(int(ax), int(ay)), (ex, ey)], fill=color, width=4)\n            draw.line([(int(bx), int(by)), (ex, ey)], fill=color, width=4)\n            for px_, py_ in [(int(ax), int(ay)), (int(bx), int(by))]:\n                draw.ellipse([(px_-r, py_-r), (px_+r, py_+r)], fill=color)\n        draw.text(tpos, ann['label'], fill=color, font=font,\n                  stroke_width=1, stroke_fill=color)\n    return img\n\n\ndef annotate_image(image_path, annotations, *,\n                   debug=False,\n                   font_path=DEFAULT_FONT,\n                   font_size=DEFAULT_FONT_SIZE,\n                   color=DEFAULT_COLOR,\n                   stroke_width=DEFAULT_STROKE):\n    \"\"\"\n    Annotate a screenshot with automatic label placement.\n\n    Args:\n        image_path: path to the input image\n        annotations: list of dicts with keys:\n            - elem: (x1, y1, x2, y2) tight bounding box of element\n            - label: text label string\n            - draw_box: (optional, default False) draw rounded rect around element\n        debug: if True, draw developer rectangles (cyan\u002Fpink\u002Fgray\u002Fheatmap)\n        font_path: path to TTF font file\n        font_size: font size in pixels\n        color: hex color for annotations (default orange #FF9F1C)\n        stroke_width: width of orange highlight box outline\n\n    Returns:\n        PIL.Image with annotations drawn\n    \"\"\"\n    font = ImageFont.truetype(font_path, font_size)\n    img = Image.open(image_path).convert('RGBA')\n    pixels = np.array(img)\n    W, H = img.size\n    annots = []\n    for i, spec in enumerate(annotations):\n        eb = spec['elem']\n        em_pad = min(20, max(10, (eb[2] - eb[0]) \u002F\u002F 10))\n        cyan = (eb[0] - em_pad, eb[1] - em_pad, eb[2] + em_pad, eb[3] + em_pad)\n        lines = spec['label'].split('\\n')\n        tw = max(font.getbbox(line)[2] - font.getbbox(line)[0] for line in lines)\n        bbox = font.getbbox('Ay')\n        line_h = bbox[3] - bbox[1]\n        th = line_h * len(lines) + 4 * (len(lines) - 1)\n        pw, ph = tw + 2 * TEXT_PAD, th + 2 * TEXT_PAD\n        cands = _find_candidates(pixels, W, H, cyan, pw, ph, font)\n        annots.append({\n            'id': i,\n            'label': spec['label'],\n            'draw_box': spec.get('draw_box', False),\n            'cyan': cyan,\n            'cyan_center': ((cyan[0]+cyan[2])\u002F2, (cyan[1]+cyan[3])\u002F2),\n            'excl_zone': (cyan[0]-MIN_ARROW, cyan[1]-MIN_ARROW,\n                          cyan[2]+MIN_ARROW, cyan[3]+MIN_ARROW),\n            'pw': pw, 'ph': ph,\n            'candidates': cands,\n            'best_score': max((s for _, s in cands), default=-999),\n        })\n    _resolve_placements(annots, font)\n    annots.sort(key=lambda a: a['id'])\n    if debug:\n        img = _draw_debug(img, annots, color)\n    img = _draw_annotations(img, annots, font, color, stroke_width)\n    return img\n\n\ndef diff_images(before_path, after_path, *, threshold=30, min_pixels=300,\n                dilate=5, debug=False):\n    \"\"\"Find changed regions between two screenshots and return cluster boxes.\n\n    Returns (clusters, debug_img_or_None):\n        clusters: list of (x1, y1, x2, y2, pixel_count) sorted largest-first\n        debug_img: if debug=True, PIL Image with heatmap overlay and cluster boxes\n    \"\"\"\n    from scipy import ndimage\n    img_a = Image.open(before_path).convert('RGB')\n    img_b = Image.open(after_path).convert('RGB')\n    if img_a.size != img_b.size:\n        raise ValueError(f\"Image sizes differ: {img_a.size} vs {img_b.size}\")\n    arr_a = np.array(img_a, dtype=np.float32)\n    arr_b = np.array(img_b, dtype=np.float32)\n    W, H = img_a.size\n    diff = np.abs(arr_b - arr_a).max(axis=2)\n    mask = diff > threshold\n    dilated = ndimage.binary_dilation(mask, iterations=dilate)\n    labeled, n_clusters = ndimage.label(dilated)\n    clusters = []\n    for i in range(1, n_clusters + 1):\n        ys, xs = np.where(labeled == i)\n        if len(ys) \u003C min_pixels:\n            continue\n        clusters.append((int(xs.min()), int(ys.min()),\n                          int(xs.max()), int(ys.max()), len(ys)))\n    clusters.sort(key=lambda c: -c[4])\n    debug_img = None\n    if debug:\n        overlay = img_b.copy().convert('RGBA')\n        norm = np.clip(diff \u002F 255.0, 0, 1)\n        show_mask = diff > 10\n        r = np.clip((norm * 2) * 255, 0, 255).astype(np.uint8)\n        g = np.clip((1 - np.abs(norm - 0.5) * 2) * 200, 0, 200).astype(np.uint8)\n        b = np.clip((1 - norm) * 255, 0, 255).astype(np.uint8)\n        a = np.where(show_mask, np.clip(norm * 200 + 40, 40, 220).astype(np.uint8), 0)\n        heat = Image.fromarray(np.stack([r, g, b, a], axis=2), 'RGBA')\n        overlay = Image.alpha_composite(overlay, heat)\n        draw = ImageDraw.Draw(overlay)\n        try:\n            font = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002Fconsola.ttf', 18)\n        except OSError:\n            font = ImageFont.load_default()\n        for idx, (x1, y1, x2, y2, px_count) in enumerate(clusters):\n            draw.rectangle([x1, y1, x2, y2], outline=(0, 255, 255, 200), width=3)\n            label = f\"#{idx+1}  {px_count:,}px\"\n            bbox = font.getbbox(label)\n            tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]\n            lx, ly = x1, max(0, y1 - th - 8)\n            draw.rectangle([lx, ly, lx + tw + 8, ly + th + 4], fill=(0, 0, 0, 180))\n            draw.text((lx + 4, ly + 2), label, fill=(0, 255, 255, 255), font=font)\n        debug_img = overlay\n    return clusters, debug_img\n\n\ndef grid_image(image_path, step=100):\n    \"\"\"Draw a coordinate grid on an image for precise element location.\"\"\"\n    img = Image.open(image_path).convert('RGBA')\n    draw = ImageDraw.Draw(img)\n    W, H = img.size\n    try:\n        font = ImageFont.truetype('C:\u002FWindows\u002FFonts\u002Fconsola.ttf', 14)\n    except OSError:\n        font = ImageFont.load_default()\n    for x in range(0, W, step):\n        draw.line([(x, 0), (x, H)], fill=(255, 0, 0, 120), width=1)\n        draw.text((x + 2, 2), str(x), fill=(255, 0, 0, 200), font=font)\n    for y in range(0, H, step):\n        draw.line([(0, y), (W, y)], fill=(255, 0, 0, 120), width=1)\n        draw.text((2, y + 2), str(y), fill=(255, 0, 0, 200), font=font)\n    return img\n```\n\n## Image Diffing\n\nFind what changed between two screenshots programmatically. Use as a safety net for subtle changes — when the difference is obvious, annotate directly instead.\n\n```python\nfrom annotate import diff_images\n\nclusters, debug_img = diff_images(\n    'before.png', 'after.png',\n    threshold=30,     # pixel difference floor (0-255)\n    min_pixels=300,   # ignore tiny noise clusters\n    dilate=5,         # merge nearby changed pixels\n    debug=True,       # render heatmap overlay\n)\n\n# clusters = [(x1, y1, x2, y2, pixel_count), ...] sorted largest-first\nif debug_img:\n    debug_img.save('diff-debug.png')\n\n# Feed clusters into annotate_image:\nannotations = [\n    {'elem': (x1, y1, x2, y2), 'label': f'Change #{i+1}', 'draw_box': True}\n    for i, (x1, y1, x2, y2, _) in enumerate(clusters[:3])\n]\n```\n\n**Debug heatmap colors:** Blue = small difference, Yellow = medium, Red = large, Cyan boxes = cluster bounding boxes.\n\n**When to use:** subtle opacity changes, dashed lines, minor color shifts, anti-aliasing differences.\n**When NOT to use:** any change you can see by eye — annotate directly for better labels.\n\n## Animated GIF Annotations\n\nDifferent from static images — animations have timing, transitions, and competing visual motion.\n\n### Element highlighting\n\n1. **Rects for big areas, arrows for small elements** — 500x300px area = rect, 200x25px element = arrow\n2. **Labels go RIGHT NEXT to what they describe** — short arrow (30-80px), label adjacent. Viewer's eye shouldn't travel more than ~100px\n3. **Arrow must not cross its own label** — pick the edge closest to the target\n4. **No bottom bar \u002F subtitle approach** — eyes jump between content and bar. Contextual placement only\n5. **Hero message gets a bigger font** — main takeaway 64pt+, detail annotations 38pt\n\n### Timing and pacing\n\n6. **Fade: 2-frame pop-in at 10fps** — 50% → 100% opacity (0.2s total). Easing curves look bad at low FPS\n7. **Type → pause → annotate** — during fast action, show NO annotation. Pause, then add it\n8. **Variable frame duration** — fast during action (100ms), slow during pauses (600-800ms), long hold for hero (500ms)\n9. **Higher FPS for smooth motion** — 10fps minimum for typing\u002Finteraction\n\n### Pop-in fade implementation\n\n```python\n# 2-frame pop-in at 10fps\nFADE_ALPHAS = [0.50, 1.00]\n\nfor frame_idx in range(total_frames):\n    if annotation_just_changed and local_idx \u003C len(FADE_ALPHAS):\n        alpha = FADE_ALPHAS[local_idx]\n    else:\n        alpha = 1.0\n    # Apply alpha to annotation elements:\n    # - pill background: fill=(r, g, b, int(base_alpha * alpha))\n    # - text: fill=(*color, int(255 * alpha))\n    # - rect outline: outline=(*color, int(255 * alpha))\n```\n\n## Guidelines\n\n1. **All elements same thickness** — rect `width`, line `width`, and visual text weight should feel consistent (~5px)\n2. Place labels **close to the rect** — short leader line (25-35px)\n3. Labels can overlap content — the stroke gives enough contrast\n4. **Show locally first** — verify before uploading to a PR\n5. **Take screenshots at native 1x, control display size in HTML** — use `\u003Cimg width=\"300\">` in markdown, never resize with PIL (creates artifacts)\n6. **Always check `Image.open(path).size` first** — HiDPI screenshots are larger than they appear (150% scaling = 1.5x CSS pixel dimensions)\n7. **Short labels work better** — wide labels have fewer valid placements. Use 1-3 words when possible\n8. **Verify with debug=True** — always check the first annotation of a new image with debug mode\n\n## Limitations\n\n- Ink Free font is Windows-only; other platforms need a fallback font\n- PIL text rendering is basic — no rich text, no markdown\n- Animated GIF annotations require frame-by-frame processing which can be slow for long recordings\n- Algorithmic placement works best with 2-6 annotations; more than that may produce crowded results\n",{"name":5,"description":7},"main","5269f7ea2d6c3a9bfda5ed878b6726cf509121fd","skills\u002Fimage-annotations",5344,"https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot","https:\u002F\u002Fawesome-copilot.github.com\u002F","https:\u002F\u002Fgithub.com\u002Fgithub","https:\u002F\u002Fgithub.com\u002Fabout","How people build software.","2026-11-13 02:05:36",{"source":8,"skillName":5,"commands":54,"prompt":64,"zipAvailable":65},[55,58,61],{"tool":56,"command":57},"npx","npx skills add github\u002Fawesome-copilot --skill image-annotations",{"tool":59,"command":60},"bunx","bunx skills add github\u002Fawesome-copilot --skill image-annotations",{"tool":62,"command":63},"pnpm","pnpm dlx skills add github\u002Fawesome-copilot --skill image-annotations","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fimage-annotations\u002FSKILL.md",true,[67],{"path":68,"resourceType":69,"resourceTypeDesc":70,"sizeBytes":71,"sourceHtmlUrl":72},"template.sh","SCRIPT","脚本",8941,"https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fwizard\u002Ftemplate.sh",[74,106,133,163,197,212],{"skillNo":75,"name":76,"nameZh":6,"description":77,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":78,"categories":79,"keywords":86,"publishedAt":38},"01M70KXZNX9ES38N2A8M58ZZMZ","supabase-rls-policies","Write, review, and test Supabase Row Level Security (RLS) policies so that one user cannot read or change another user's rows. Use when creating or changing tables, policies, views, or security definer functions in a Supabase project, when reviewing a migration that touches RLS, or when asked to prove user isolation with pgTAP tests that sign in as two users.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fsupabase-rls-policies\u002FSKILL.md",[80,83],{"categoryNo":81,"categoryName":82,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",{"categoryNo":84,"categoryName":85,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,34],"supabase","rls","supabase rls","supabase rls policies","project","when","review","change","read","write","security","isolation","tables","as","tests","prove","use","users","functions",{"skillNo":107,"name":108,"nameZh":6,"description":109,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":110,"categories":111,"keywords":113,"publishedAt":38},"01M70KWA5YWXDV6V8DZ19E3T2Y","skill-distribution-audit","Audit a skill collection before release or investigate installer omissions, archived\u002Ftemplate leakage, duplicate identities, and manifest drift. Reconcile the intended public source set, distribution manifest, and actual discovery output for a specified consumer version. Includes a deterministic set-comparison helper.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fskill-distribution-audit\u002FSKILL.md",[112],{"categoryNo":84,"categoryName":85,"taxonomyType":21,"taxonomyTypeDesc":22},[114,115,116,117,118,119,36,120,121,122,123,124,125,126,127,128,129,130,131,132],"distribution","skill distribution audit","audit","SET","deterministic","installer","discovery","comparison","helper","manifest","version","collection","drift","output","investigate","duplicate","template","release","reconcile",{"skillNo":134,"name":135,"nameZh":6,"description":136,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":137,"sourceHtmlUrl":138,"categories":139,"keywords":145,"publishedAt":38},"01M70KCJPFVW7PPQ49CC5R8JGK","logicapps-custom-code-net10-migration","Migrate Azure Logic Apps Standard local-function custom-code projects from .NET 8 to .NET 10. Use for legacy Logic Apps custom-code workspace upgrades, project\u002Fpackage configuration, custom-worker settings, publication, and integrated VS Code debugging. Not for ordinary .NET applications, standalone Azure Functions, Consumption workflows, or cloud deployment.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Flogicapps-custom-code-net10-migration\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Flogicapps-custom-code-net10-migration\u002FSKILL.md",[140],{"categoryNo":141,"categoryName":142,"taxonomyType":143,"taxonomyTypeDesc":144},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[146,147,148,149,150,151,36,91,152,153,154,155,156,157,158,103,159,160,161,162],"custom","code","logicapps custom code net10 migration","migration","projects","standalone","deployment","applications","local","azure","worker","upgrades","package","apps","Legacy","debugging","workflows",{"skillNo":164,"name":165,"nameZh":6,"description":166,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":167,"sourceHtmlUrl":168,"categories":169,"keywords":174,"publishedAt":38},"01M6AEBN37RQMXJ3298PEY7PG1","winmd-api-search","Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI\u002FML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values).","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fwinmd-api-search\u002FLICENSE.txt","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fwinmd-api-search\u002FSKILL.md",[170,173],{"categoryNo":171,"categoryName":172,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口",{"categoryNo":141,"categoryName":142,"taxonomyType":143,"taxonomyTypeDesc":144},[175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,92,190,191,192,193,194,36,195,196],"winmd api search","search","api","windows","desktop","file access","enumeration","ui","ml","file","networking","capabilities","notifications","explore","access","features","full","camera","events","platform","methods","controls",{"skillNo":198,"name":199,"nameZh":6,"description":200,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":201,"licenseFileUrl":202,"sourceHtmlUrl":203,"categories":204,"keywords":208,"publishedAt":38},"01M6AE571R3SC1ZGE202B3NB5S","resemble-detect","Deepfake detection and media safety — detect AI-generated audio, images, video, and text, trace synthesis sources, apply watermarks, verify speaker identity, and analyze media intelligence using Resemble AI","Apache-2.0","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fresemble-detect\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fresemble-detect\u002FSKILL.md",[205,206,207],{"categoryNo":171,"categoryName":172,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":84,"categoryName":85,"taxonomyType":21,"taxonomyTypeDesc":22},[209,117,184,210,211],"resemble detect","https","identity",{"skillNo":213,"name":214,"nameZh":6,"description":215,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":201,"licenseFileUrl":216,"sourceHtmlUrl":217,"categories":218,"keywords":223,"publishedAt":38},"01M6AE30BSK2HCY4TQQ22Z8JRD","quality-playbook","Run a complete quality engineering audit on any codebase. Derives behavioral requirements from the code, generates spec-traced functional tests, runs a three-pass code review with regression tests, executes a multi-model spec audit (Council of Three), and produces a consolidated bug report with TDD-verified patches. Finds the 35% of real defects that structural code review alone cannot catch. Works with any language. Trigger on 'quality playbook', 'spec audit', 'Council of Three', 'fitness-to-purpose', or 'coverage theater'.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fquality-playbook\u002FLICENSE.txt","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fquality-playbook\u002FSKILL.md",[219,222],{"categoryNo":220,"categoryName":221,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000007","代码审查",{"categoryNo":84,"categoryName":85,"taxonomyType":21,"taxonomyTypeDesc":22},[224,225,226,227,147,93,9,116,228,229,210,230,231,232,233,234,235,236,237,238,239,240,101,241,242,243,244],"playbook","quality playbook","quality","code review","tdd","run","report","spec","bug report","council","engineering","functional","spec audit","bug","verified","multi-model","three","coverage","model","regression","structural"]