mcpField
I
image-annotations

image-annotations

github.com/githubクリエイティブ・メディア40.5k153 回インストール1 日前に更新
リポジトリ

image-annotations について

image-annotations は、Pillow を使ってスクリーンショットや図、デモフレームに注釈を加えるスキルです。ラベルの自動配置、画像差分の検出、アニメーション GIF の注釈方法を扱います。

主な機能

  1. 01画像への枠とラベルの追加
  2. 02ラベルと矢印の自動配置
  3. 03位置確認用グリッドの生成
  4. 042 枚の画像の差分検出
  5. 05アニメーション GIF 注釈のガイド

活用シーン

  1. 01PR のスクリーンショットで領域を強調
  2. 02変更前後の画像で差分を提示
  3. 03アーキテクチャ図にラベルを追加
  4. 04アニメーションデモのフレームに注釈

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/github/awesome-copilot --skill image-annotations

インストール後、Claude Code で/image-annotationsを入力して読み込まれたか確認します。

詳細

画像への注釈

PIL/Pillow を使って、スクリーンショット、図、アーキテクチャドキュメント、デモのフレームなど、あらゆる画像に視覚的な吹き出しを追加できます。変更箇所や注目すべき点を強調できるため、レビュー担当者が推測する必要はありません。

このスキルを使う場面

次のような場合に、このスキルを使います。

  • PR の説明用に、スクリーンショットの特定の領域を強調する
  • 変更点を示すために、変更前後の画像に注釈を付ける
  • 図やアーキテクチャ画像にラベルや吹き出しを追加する
  • アニメーション GIF のデモ用に、注釈付きフレームを作成する

前提条件

pip install Pillow -q

色のルール

  • 赤(#E63946) — 「悪いもの」や「削除されたもの」にのみ使用します(例:修正対象のバグを囲む)
  • 黄みがかったオレンジ(#FF9F1C) — 中立的な強調(「ここを見てください」「新機能」など)に使用します
  • 目を引くという理由だけで赤を使わないでください。赤は「悪いもの/削除されたもの」を表します

フォント

  • Windows では、手書き風にするため Ink Free(C:/Windows/Fonts/Inkfree.ttf)を使用します
  • Linux/macOS では、ImageFont.load_default() にフォールバックします
  • 幅が約 1400px の画像に注釈を付ける場合、サイズは 36 にします
  • stroke_width=1 と stroke_fill=<same color as fill> を指定すると、太くなりすぎずに文字に厚みが出ます
  • 白い縁取りは使わないでください。不自然な発光効果のように見えます

形状

  • 円や楕円よりも、角丸長方形を優先します。端のピクセル化を抑えられます
  • draw.rounded_rectangle([x1, y1, x2, y2], radius=14, outline=color, width=5)
  • 対象コンテンツの周囲に 18px の余白を取ります

参考スニペット

from PIL import Image, ImageDraw, ImageFont

# Setup
font = ImageFont.truetype('C:/Windows/Fonts/Inkfree.ttf', 36)  # or load_default()
color = '#FF9F1C'  # orange for highlights
stroke = 5
pad = 18

img = Image.open('screenshot.png')
draw = ImageDraw.Draw(img)

# Rounded rect with padding
draw.rounded_rectangle(
    [x1 - pad, y1 - pad, x2 + pad, y2 + pad],
    radius=14, outline=color, width=stroke
)

# Leader line (same thickness as rect)
draw.line([x2 + pad, cy, x2 + pad + 40, cy - 30], fill=color, width=stroke)

# Label — same-color stroke for body, NO white stroke
draw.text(
    (x2 + pad + 45, cy - 60), 'label text',
    fill=color, font=font, stroke_width=1, stroke_fill=color
)

img.save('annotated.png')

アルゴリズムによる注釈 — annotate.py

注釈を付ける要素が複数ある画像には、以下の annotate.py モジュールを使用します。スクリプトと同じ場所に保存し、インポートしてください。ラベルが重ならないよう、自動で配置します。

クイックスタート
from annotate import annotate_image

result = annotate_image(
    'screenshot.png',
    [
        {'elem': (560, 275, 635, 390), 'label': 'button', 'draw_box': True},
        {'elem': (105, 453, 236, 470), 'label': 'status text'},
    ],
    debug=True,
)
result.save('annotated.png')
  • elem:厳密なバウンディングボックス (x1, y1, x2, y2)。正確なピクセル座標を指定する必要があります
  • label:テキストラベル(複数行にする場合は \n を使用できます)
  • draw_box:True の場合、要素の周囲に角丸長方形を描画します。False(デフォルト)の場合、要素を指す V 字型の矢印を描画します
  • debug:配置を検証するための対象領域の長方形と候補ヒートマップを表示します
座標グリッドヘルパー

見慣れない画像に注釈を付ける前に、必ず grid_image() を使ってください。 縮小プレビューでは画像が実際のピクセル寸法より小さく表示されるため、(0,0) から離れるほど誤差が大きくなります。

from annotate import grid_image

grid = grid_image('screenshot.png', step=100)
grid.save('grid.png')

その後、小さな切り抜き画像で確認します。

from PIL import Image
img = Image.open('screenshot.png')
crop = img.crop((x1 - 20, y1 - 20, x2 + 20, y2 + 20))
crop.save('verify.png')
アルゴリズムの概要
  1. リング検索:要素の端から MIN_ARROW(25px)以上、MAX_ARROW(120px)以下の候補を検索します
  2. コントラストのスコア付け:ラベルの文字が読みやすい配置を優先します — abs(avg_brightness - 147) - std * 0.3 - dist * 0.02
  3. 同時配置の解決:候補を個別に計算し、スコアの高い順に貪欲法で配置します
  4. ハードブロック:ラベルは、他の注釈の要素や余白付きボックスと重ならないようにします
  5. 近接ペナルティ:配置済みのボックスから 40px 以内にあるラベルには、スコアペナルティを適用します
  6. 矢印の交差ペナルティ:配置済みの矢印と交差する矢印には、-50 のペナルティを適用します
デバッグモードの色
色意味
シアン対象要素のボックス(elem + 余白)
グレー除外ゾーン(MIN_ARROW のバッファー)
赤→緑候補ヒートマップ(赤=不適、緑=適)
マゼンタ選択されたラベル位置
オレンジ最終的に描画された注釈
矢印のスタイル
  • draw_box=True:角丸長方形とラベルまでの直線。矢印の先端はありません
  • draw_box=False:線端が丸い V 字型の矢印
annotate.py — モジュール全体

以下を annotate.py として保存し、インポートしてください。

"""
Algorithmic screenshot annotation with automatic label placement.

pip install Pillow numpy
Optional for diff_images: pip install scipy
"""
import math
import numpy as np
from PIL import Image, ImageDraw, ImageFont

# --- Defaults ---
DEFAULT_FONT = 'C:/Windows/Fonts/Inkfree.ttf'
DEFAULT_FONT_SIZE = 32
DEFAULT_COLOR = '#FF9F1C'
DEFAULT_STROKE = 5
MIN_ARROW = 25
MAX_ARROW = 120
TEXT_PAD = 6
BREATH = 18
CROSSING_PENALTY = 50
PROXIMITY_MARGIN = 40
PROXIMITY_PENALTY = 50


def _rect_intersects(a, b):
    return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]


def _segments_intersect(p1, p2, p3, p4):
    def cross(o, a, b):
        return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
    d1, d2 = cross(p3, p4, p1), cross(p3, p4, p2)
    d3, d4 = cross(p1, p2, p3), cross(p1, p2, p4)
    return ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and \
           ((d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0))


def _line_rect_exit(cx, cy, tx, ty, rect):
    x1, y1, x2, y2 = rect
    dx, dy = tx - cx, ty - cy
    tmin, tmax = 0.0, 1.0
    for lo, hi, p, d in [(x1, x2, cx, dx), (y1, y2, cy, dy)]:
        if abs(d) < 1e-9:
            continue
        t0, t1 = (lo - p) / d, (hi - p) / d
        if t0 > t1:
            t0, t1 = t1, t0
        tmin, tmax = max(tmin, t0), min(tmax, t1)
    return (cx + dx * tmax, cy + dy * tmax)


def _rect_gap(a, b):
    dx = max(a[0] - b[2], b[0] - a[2], 0)
    dy = max(a[1] - b[3], b[1] - a[3], 0)
    if dx == 0 and dy == 0:
        return 0
    return math.sqrt(dx**2 + dy**2)


def _find_candidates(pixels, W, H, cyan, pw, ph, font):
    cx, cy = (cyan[0] + cyan[2]) / 2, (cyan[1] + cyan[3]) / 2
    excl_zone = (cyan[0] - MIN_ARROW, cyan[1] - MIN_ARROW,
                 cyan[2] + MIN_ARROW, cyan[3] + MIN_ARROW)
    sx1 = max(0, cyan[0] - MAX_ARROW - pw)
    sy1 = max(0, cyan[1] - MAX_ARROW - ph)
    sx2 = min(W - pw, cyan[2] + MAX_ARROW)
    sy2 = min(H - ph, cyan[3] + MAX_ARROW)
    step_x = max(8, min(pw // 2, MAX_ARROW // 3))
    step_y = max(8, min(ph // 2, MAX_ARROW // 3))
    cands = []
    for px in range(sx1, sx2, step_x):
        for py in range(sy1, sy2, step_y):
            pink = (px, py, px + pw, py + ph)
            if _rect_intersects(pink, excl_zone):
                continue
            gl, gr = cyan[0] - pink[2], pink[0] - cyan[2]
            gt, gb = cyan[1] - pink[3], pink[1] - cyan[3]
            hd, vd = max(gl, gr, 0), max(gt, gb, 0)
            ed = math.sqrt(hd**2 + vd**2) if (hd > 0 and vd > 0) else max(hd, vd)
            if ed > MAX_ARROW:
                continue
            region = pixels[py:py + ph, px:px + pw, :3].astype(float)
            score = abs(np.mean(region) - 147) - np.std(region) * 0.3
            dist = math.sqrt((px + pw/2 - cx)**2 + (py + ph/2 - cy)**2)
            score -= dist * 0.02
            cands.append(((px, py), score))
    return cands


def _resolve_placements(annots, font):
    placed = []
    all_elem_zones = []
    for ann in annots:
        all_elem_zones.append(ann['cyan'])
        if ann.get('draw_box', False):
            c = ann['cyan']
            all_elem_zones.append((c[0]-BREATH, c[1]-BREATH, c[2]+BREATH, c[3]+BREATH))
    for ann in sorted(annots, key=lambda a: -a['best_score']):
        pw, ph = ann['pw'], ann['ph']
        cyan = ann['cyan']
        cx, cy = ann['cyan_center']
        draw_box = ann.get('draw_box', False)
        best_pos, best_score = None, -999
        valid = []
        for (px, py), score in ann['candidates']:
            pink = (px, py, px + pw, py + ph)
            ok = True
            for ez in all_elem_zones:
                if ez == cyan:
                    continue
                if ann.get('draw_box', False):
                    own_viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)
                    if ez == own_viz:
                        continue
                if _rect_intersects(pink, ez):
                    ok = False; break
            if not ok:
                continue
            for p_pink, p_excl, p_viz, _ in placed:
                if _rect_intersects(pink, p_pink) or _rect_intersects(pink, p_excl):
                    ok = False; break
                if p_viz and _rect_intersects(pink, p_viz):
                    ok = False; break
            if not ok:
                continue
            for p_pink, p_excl, p_viz, _ in placed:
                for rect in [p_pink, p_excl, p_viz]:
                    if rect is None:
                        continue
                    gap = _rect_gap(pink, rect)
                    if gap < PROXIMITY_MARGIN:
                        score -= PROXIMITY_PENALTY * (1 - gap / PROXIMITY_MARGIN)
            for ez in all_elem_zones:
                if ez == cyan:
                    continue
                gap = _rect_gap(pink, ez)
                if gap < PROXIMITY_MARGIN:
                    score -= PROXIMITY_PENALTY * (1 - gap / PROXIMITY_MARGIN)
            tcx, tcy = px + pw/2, py + ph/2
            cand_start = _line_rect_exit(tcx, tcy, cx, cy, pink)
            if draw_box:
                viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)
                cand_end = _line_rect_exit(cx, cy, tcx, tcy, viz)
            else:
                cand_end = _line_rect_exit(cx, cy, tcx, tcy, cyan)
            for _, _, _, pa in placed:
                if pa and _segments_intersect(cand_start, cand_end, pa[0], pa[1]):
                    score -= CROSSING_PENALTY; break
            valid.append(((px, py), score))
            if score > best_score:
                best_score, best_pos = score, (px, py)
        ann['valid_candidates'] = valid
        if best_pos is None:
            ann['pink'] = ann['tpos'] = ann['astart'] = ann['aend'] = ann['viz'] = None
            continue
        px, py = best_pos
        pink = (px, py, px + pw, py + ph)
        ann['pink'] = pink
        ann['tpos'] = (px + TEXT_PAD, py + TEXT_PAD)
        tcx, tcy = px + pw/2, py + ph/2
        ann['astart'] = _line_rect_exit(tcx, tcy, cx, cy, pink)
        if draw_box:
            viz = (cyan[0]-BREATH, cyan[1]-BREATH, cyan[2]+BREATH, cyan[3]+BREATH)
            ann['viz'] = viz
            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, viz)
        else:
            ann['viz'] = None
            ann['aend'] = _line_rect_exit(cx, cy, tcx, tcy, cyan)
        placed.append((pink, ann['excl_zone'], ann['viz'], (ann['astart'], ann['aend'])))


def _draw_debug(img, annots, color):
    overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    for ann in annots:
        cands = ann.get('valid_candidates', ann['candidates'])
        if not cands:
            continue
        pw, ph = ann['pw'], ann['ph']
        scores = [s for _, s in cands]
        smin, smax = min(scores), max(scores)
        rng = smax - smin if smax > smin else 1
        for (px, py), score in cands:
            t = (score - smin) / rng
            if t < 0.5:
                r_c, g_c, b_c = 220, int(180 * (t * 2)), 0
            else:
                r_c, g_c, b_c = int(220 * (1 - (t-0.5)*2)), 200, 0
            alpha_fill = int(40 + 70 * t)
            alpha_out = int(80 + 120 * t)
            od.rectangle((px, py, px + pw, py + ph),
                         fill=(r_c, g_c, b_c, alpha_fill), outline=(r_c, g_c, b_c, alpha_out), width=1)
    for ann in annots:
        ez = ann['excl_zone']
        od.rectangle(ez, fill=(120, 120, 120, 50), outline=(160, 160, 160, 160), width=1)
        od.rectangle(ann['cyan'], fill=(0, 255, 255, 30), outline=(0, 255, 255, 180), width=2)
        if ann.get('pink'):
            od.rectangle(ann['pink'], fill=(255, 0, 255, 50),
                         outline=(255, 0, 255, 180), width=2)
    return Image.alpha_composite(img, overlay)


def _draw_annotations(img, annots, font, color, stroke_width):
    draw = ImageDraw.Draw(img)
    for ann in annots:
        if ann.get('viz'):
            draw.rounded_rectangle(ann['viz'], radius=12, outline=color, width=stroke_width)
        tpos = ann.get('tpos')
        astart, aend = ann.get('astart'), ann.get('aend')
        if not (tpos and astart and aend):
            continue
        sx, sy = int(astart[0]), int(astart[1])
        ex, ey = int(aend[0]), int(aend[1])
        draw.line([(sx, sy), (ex, ey)], fill=color, width=4, joint='curve')
        r = 2
        draw.ellipse([(sx-r, sy-r), (sx+r, sy+r)], fill=color)
        draw.ellipse([(ex-r, ey-r), (ex+r, ey+r)], fill=color)
        if not ann.get('draw_box', False):
            angle = math.atan2(ey - sy, ex - sx)
            al, spread = 18, 0.45
            ax = ex - al * math.cos(angle - spread)
            ay = ey - al * math.sin(angle - spread)
            bx = ex - al * math.cos(angle + spread)
            by = ey - al * math.sin(angle + spread)
            draw.line([(int(ax), int(ay)), (ex, ey)], fill=color, width=4)
            draw.line([(int(bx), int(by)), (ex, ey)], fill=color, width=4)
            for px_, py_ in [(int(ax), int(ay)), (int(bx), int(by))]:
                draw.ellipse([(px_-r, py_-r), (px_+r, py_+r)], fill=color)
        draw.text(tpos, ann['label'], fill=color, font=font,
                  stroke_width=1, stroke_fill=color)
    return img


def annotate_image(image_path, annotations, *,
                   debug=False,
                   font_path=DEFAULT_FONT,
                   font_size=DEFAULT_FONT_SIZE,
                   color=DEFAULT_COLOR,
                   stroke_width=DEFAULT_STROKE):
    """
    Annotate a screenshot with automatic label placement.

    Args:
        image_path: path to the input image
        annotations: list of dicts with keys:
            - elem: (x1, y1, x2, y2) tight bounding box of element
            - label: text label string
            - draw_box: (optional, default False) draw rounded rect around element
        debug: if True, draw developer rectangles (cyan/pink/gray/heatmap)
        font_path: path to TTF font file
        font_size: font size in pixels
        color: hex color for annotations (default orange #FF9F1C)
        stroke_width: width of orange highlight box outline

    Returns:
        PIL.Image with annotations drawn
    """
    font = ImageFont.truetype(font_path, font_size)
    img = Image.open(image_path).convert('RGBA')
    pixels = np.array(img)
    W, H = img.size
    annots = []
    for i, spec in enumerate(annotations):
        eb = spec['elem']
        em_pad = min(20, max(10, (eb[2] - eb[0]) // 10))
        cyan = (eb[0] - em_pad, eb[1] - em_pad, eb[2] + em_pad, eb[3] + em_pad)
        lines = spec['label'].split('\n')
        tw = max(font.getbbox(line)[2] - font.getbbox(line)[0] for line in lines)
        bbox = font.getbbox('Ay')
        line_h = bbox[3] - bbox[1]
        th = line_h * len(lines) + 4 * (len(lines) - 1)
        pw, ph = tw + 2 * TEXT_PAD, th + 2 * TEXT_PAD
        cands = _find_candidates(pixels, W, H, cyan, pw, ph, font)
        annots.append({
            'id': i,
            'label': spec['label'],
            'draw_box': spec.get('draw_box', False),
            'cyan': cyan,
            'cyan_center': ((cyan[0]+cyan[2])/2, (cyan[1]+cyan[3])/2),
            'excl_zone': (cyan[0]-MIN_ARROW, cyan[1]-MIN_ARROW,
                          cyan[2]+MIN_ARROW, cyan[3]+MIN_ARROW),
            'pw': pw, 'ph': ph,
            'candidates': cands,
            'best_score': max((s for _, s in cands), default=-999),
        })
    _resolve_placements(annots, font)
    annots.sort(key=lambda a: a['id'])
    if debug:
        img = _draw_debug(img, annots, color)
    img = _draw_annotations(img, annots, font, color, stroke_width)
    return img


def diff_images(before_path, after_path, *, threshold=30, min_pixels=300,
                dilate=5, debug=False):
    """Find changed regions between two screenshots and return cluster boxes.

    Returns (clusters, debug_img_or_None):
        clusters: list of (x1, y1, x2, y2, pixel_count) sorted largest-first
        debug_img: if debug=True, PIL Image with heatmap overlay and cluster boxes
    """
    from scipy import ndimage
    img_a = Image.open(before_path).convert('RGB')
    img_b = Image.open(after_path).convert('RGB')
    if img_a.size != img_b.size:
        raise ValueError(f"Image sizes differ: {img_a.size} vs {img_b.size}")
    arr_a = np.array(img_a, dtype=np.float32)
    arr_b = np.array(img_b, dtype=np.float32)
    W, H = img_a.size
    diff = np.abs(arr_b - arr_a).max(axis=2)
    mask = diff > threshold
    dilated = ndimage.binary_dilation(mask, iterations=dilate)
    labeled, n_clusters = ndimage.label(dilated)
    clusters = []
    for i in range(1, n_clusters + 1):
        ys, xs = np.where(labeled == i)
        if len(ys) < min_pixels:
            continue
        clusters.append((int(xs.min()), int(ys.min()),
                          int(xs.max()), int(ys.max()), len(ys)))
    clusters.sort(key=lambda c: -c[4])
    debug_img = None
    if debug:
        overlay = img_b.copy().convert('RGBA')
        norm = np.clip(diff / 255.0, 0, 1)
        show_mask = diff > 10
        r = np.clip((norm * 2) * 255, 0, 255).astype(np.uint8)
        g = np.clip((1 - np.abs(norm - 0.5) * 2) * 200, 0, 200).astype(np.uint8)
        b = np.clip((1 - norm) * 255, 0, 255).astype(np.uint8)
        a = np.where(show_mask, np.clip(norm * 200 + 40, 40, 220).astype(np.uint8), 0)
        heat = Image.fromarray(np.stack([r, g, b, a], axis=2), 'RGBA')
        overlay = Image.alpha_composite(overlay, heat)
        draw = ImageDraw.Draw(overlay)
        try:
            font = ImageFont.truetype('C:/Windows/Fonts/consola.ttf', 18)
        except OSError:
            font = ImageFont.load_default()
        for idx, (x1, y1, x2, y2, px_count) in enumerate(clusters):
            draw.rectangle([x1, y1, x2, y2], outline=(0, 255, 255, 200), width=3)
            label = f"#{idx+1}  {px_count:,}px"
            bbox = font.getbbox(label)
            tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
            lx, ly = x1, max(0, y1 - th - 8)
            draw.rectangle([lx, ly, lx + tw + 8, ly + th + 4], fill=(0, 0, 0, 180))
            draw.text((lx + 4, ly + 2), label, fill=(0, 255, 255, 255), font=font)
        debug_img = overlay
    return clusters, debug_img


def grid_image(image_path, step=100):
    """Draw a coordinate grid on an image for precise element location."""
    img = Image.open(image_path).convert('RGBA')
    draw = ImageDraw.Draw(img)
    W, H = img.size
    try:
        font = ImageFont.truetype('C:/Windows/Fonts/consola.ttf', 14)
    except OSError:
        font = ImageFont.load_default()
    for x in range(0, W, step):
        draw.line([(x, 0), (x, H)], fill=(255, 0, 0, 120), width=1)
        draw.text((x + 2, 2), str(x), fill=(255, 0, 0, 200), font=font)
    for y in range(0, H, step):
        draw.line([(0, y), (W, y)], fill=(255, 0, 0, 120), width=1)
        draw.text((2, y + 2), str(y), fill=(255, 0, 0, 200), font=font)
    return img

画像の差分検出

2 枚のスクリーンショットの変更箇所をプログラムで検出します。微妙な変更を見つけるための安全策として使用してください。違いが明らかな場合は、代わりに直接注釈を付けます。

from annotate import diff_images

clusters, debug_img = diff_images(
    'before.png', 'after.png',
    threshold=30,     # pixel difference floor (0-255)
    min_pixels=300,   # ignore tiny noise clusters
    dilate=5,         # merge nearby changed pixels
    debug=True,       # render heatmap overlay
)

# clusters = [(x1, y1, x2, y2, pixel_count), ...] sorted largest-first
if debug_img:
    debug_img.save('diff-debug.png')

# Feed clusters into annotate_image:
annotations = [
    {'elem': (x1, y1, x2, y2), 'label': f'Change #{i+1}', 'draw_box': True}
    for i, (x1, y1, x2, y2, _) in enumerate(clusters[:3])
]

デバッグ用ヒートマップの色: 青=小さな差、黄=中程度の差、赤=大きな差、シアンのボックス=クラスターのバウンディングボックス。

使用する場面: 微妙な不透明度の変化、破線、わずかな色の変化、アンチエイリアスの違い。 使用しない場面: 目視で確認できる変更。ラベルを分かりやすくするため、直接注釈を付けてください。

アニメーション GIF の注釈

静止画像とは異なり、アニメーションにはタイミング、切り替わり、互いに競合する視覚的な動きがあります。

要素のハイライト
  1. 大きな領域には四角形、小さな要素には矢印を使います — 500x300px の領域には四角形、200x25px の要素には矢印を使います
  2. ラベルは説明する対象のすぐ右に配置します — 短い矢印(30-80px)を使い、ラベルを隣接させます。視線の移動距離は約100px以内にします
  3. 矢印を自身のラベルに交差させないでください — 対象に最も近い端を選びます
  4. 下部バーや字幕のような配置は避けます — コンテンツとバーの間で視線が行き来します。対象に応じた配置のみを使います
  5. メインメッセージのフォントを大きくします — 主な要点は64pt以上、詳細な注釈は38ptにします
タイミングとペース
  1. フェード:10fpsで2フレームかけて表示します — 不透明度を50%から100%にします(合計0.2s)。低 FPS ではイージングカーブがきれいに見えません
  2. 入力 → 一時停止 → 注釈 — 動きの速い場面では注釈を表示しません。一時停止してから追加します
  3. フレームごとに表示時間を変えます — 動作中は速く(100ms)、一時停止中はゆっくり(600-800ms)、メインメッセージは長めに表示します(500ms)
  4. 滑らかな動きには高い FPS を使います — 入力や操作には最低10fpsが必要です
フェードイン表示の実装
# 2-frame pop-in at 10fps
FADE_ALPHAS = [0.50, 1.00]

for frame_idx in range(total_frames):
    if annotation_just_changed and local_idx < len(FADE_ALPHAS):
        alpha = FADE_ALPHAS[local_idx]
    else:
        alpha = 1.0
    # Apply alpha to annotation elements:
    # - pill background: fill=(r, g, b, int(base_alpha * alpha))
    # - text: fill=(*color, int(255 * alpha))
    # - rect outline: outline=(*color, int(255 * alpha))

ガイドライン

  1. すべての要素で太さを揃えます — 四角形の width、線の width、文字の見た目の太さに統一感を持たせます(約5px)
  2. ラベルは四角形の近くに配置します — 短い引き出し線(25-35px)を使います
  3. ラベルはコンテンツに重ねてもかまいません — 線が十分なコントラストを生み出します
  4. まずローカルで表示して確認します — PR をアップロードする前に検証します
  5. スクリーンショットはネイティブの1xで撮影し、HTMLで表示サイズを調整します — Markdown では <img width="300"> を使い、PIL でサイズ変更しないでください(アーティファクトが発生します)
  6. 必ず最初に Image.open(path).size を確認します — HiDPI のスクリーンショットは、見た目より大きくなっています(150% のスケーリングでは CSS ピクセル寸法の1.5倍になります)
  7. 短いラベルのほうが効果的です — 幅の広いラベルは適切な配置場所が少なくなります。可能であれば1~3語にします
  8. debug=True で確認します — 新しい画像の最初の注釈は、必ずデバッグモードで確認します

制限事項

  • Ink Free フォントは Windows 専用です。他のプラットフォームでは代替フォントが必要です
  • PIL のテキスト描画機能は基本的なものです — リッチテキストや Markdown には対応していません
  • アニメーション GIF の注釈にはフレームごとの処理が必要なため、長時間の録画では遅くなることがあります
  • アルゴリズムによる配置は、注釈が2~6個の場合に最も効果的です。それを超えると、配置が密集することがあります

コメント