GEMINI LABEN
DEPRECATION — gemini-omni-flash-preview が9月30日で廃止されます。移行先は8月27日に一般提供へ移った gemini-omni-1.1-flash です。今月中に手を打つ必要がありますMIGRATION — 差し替えはモデル文字列1行で済むことが多いのですが、既定の解像度が 720p に変わっています。プレビュー時代の前提のまま移すと、出力も課金も変わってしまいますOMNI — 1.1 で埋まった穴は4つです。シーン延長が40秒まで、先頭と末尾のフレーム制御、安価な 360p ドラフト、そして 4K アップスケールが加わりましたCOPILOT — 9月3日、Gemini 3.8 Flash が GitHub Copilot でも選べるようになりました。前日の Gemini API・AI Studio・Antigravity に続く配信面の拡大ですPRICE — 3.8 Flash の $0.75 入力 / $3.75 出力 per MTok は導入価格です。12月31日で失効し、2027年1月1日からは $1.50 / $7.50 per MTok になりますSPEECH — Gemini 3.5 Transcribe は音声認識に特化した2モデルです。85言語以上の発話単位の言語検出、話者分離、単語単位のタイムスタンプ、カスタム語彙バイアスに対応しますDEPRECATION — gemini-omni-flash-preview が9月30日で廃止されます。移行先は8月27日に一般提供へ移った gemini-omni-1.1-flash です。今月中に手を打つ必要がありますMIGRATION — 差し替えはモデル文字列1行で済むことが多いのですが、既定の解像度が 720p に変わっています。プレビュー時代の前提のまま移すと、出力も課金も変わってしまいますOMNI — 1.1 で埋まった穴は4つです。シーン延長が40秒まで、先頭と末尾のフレーム制御、安価な 360p ドラフト、そして 4K アップスケールが加わりましたCOPILOT — 9月3日、Gemini 3.8 Flash が GitHub Copilot でも選べるようになりました。前日の Gemini API・AI Studio・Antigravity に続く配信面の拡大ですPRICE — 3.8 Flash の $0.75 入力 / $3.75 出力 per MTok は導入価格です。12月31日で失効し、2027年1月1日からは $1.50 / $7.50 per MTok になりますSPEECH — Gemini 3.5 Transcribe は音声認識に特化した2モデルです。85言語以上の発話単位の言語検出、話者分離、単語単位のタイムスタンプ、カスタム語彙バイアスに対応します
記事一覧/開発ツール
開発ツール/2026-09-06中級

アイコンだけのボタンは、翻訳が揃っていても読み上げでは無言になります

翻訳ファイルが揃っていても、読み上げラベルの穴は残ります。iOS と Android からアイコン要素とラベルを集め、機械で決まる不備をローカルで確定させてから Gemini に文言だけを渡す二段構えを、実際の出力とともに書き残します。

Gemini API232アクセシビリティ3VoiceOverTalkBack多言語対応4

読み上げを有効にしたまま、自分の壁紙アプリの一覧画面を上から辿ってみた夜のことです。カテゴリの見出しまでは、思っていたとおりに読まれました。

ところが右上にアイコンが並ぶ帯へ入ったところで、端末は「ボタン」「ボタン」「画像」とだけ言って先へ進みました。どれを押せば保存できるのか、音だけでは分かりません。

その画面の翻訳は揃っておりました。多言語で配信しているので、未翻訳のキーが残っていないかは公開前に必ず確かめております。それでも、読み上げの側にはこれだけの穴が開いていたのです。

翻訳の網には、ラベルの穴が映りません

理由は単純でした。翻訳の検査は、キーの集合どうしを突き合わせる仕組みです。日本語にあって英語にないキーは見つかります。——けれども、ラベルそのものを書き忘れた箇所には、キーが存在しません。存在しないものは、未翻訳としても数えられないのです。

SwiftUI にはもう一つ、見落としやすい経路があります。.accessibilityLabel("ボタン") のように文字列リテラルを渡すと、それは LocalizedStringKey として扱われます。Localizable.strings に「ボタン」というキーが無ければ、警告もエラーも出ないまま、その文字列がそのまま読み上げられます。翻訳ファイルの側からは、この行は最初から見えていません。

Android の @string/ 参照も似た性質を持ちます。参照先がどこにも無ければビルドで気づけますが、ある言語の values にだけ無い場合は、既定の言語の文字列が黙って読まれます。

ラベルが要るかどうかは静的に決まり、何と読ませるかは画面を見ないと決まりません。 この二つを分けてから、ようやく手が動くようになりました。前半は自分の環境で確定できますし、後半はモデルに相談する価値があります。

まず、アイコン要素とラベルを1枚に集めます

判定の前に材料を集めます。iOS は SwiftUI の Image(...) を起点に直後のモディファイアを見て、Android はレイアウト XML の ImageButtonImageView を拾います。この段階では良し悪しを決めません。決めないことが、あとで効いてきます。

# collect_labels.py — アイコン要素と読み上げラベルをコードから集めます
import json
import re
import sys
from pathlib import Path
from xml.etree import ElementTree
 
ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
ICON_VIEWS = {"ImageButton", "ImageView"}
 
# SwiftUI では文字列リテラルも LocalizedStringKey として扱われます。
# Text(...) 包みの有無にかかわらず、まず strings への登録を探します
LABEL_RE = re.compile(r'\.accessibilityLabel\(\s*(?:Text\(\s*)?"([^"]*)"')
IMAGE_RE = re.compile(r'\bImage\(\s*(?:systemName:\s*)?"([^"]+)"')
STRINGS_RE = re.compile(r'"([^"]+)"\s*=\s*"([^"]*)"\s*;')
 
 
def load_lproj(root: Path):
    table = {}
    for path in sorted(root.glob("*.lproj/Localizable.strings")):
        locale = path.parent.name.replace(".lproj", "")
        locale = "default" if locale == "en" else locale
        for key, value in STRINGS_RE.findall(path.read_text(encoding="utf-8")):
            table.setdefault(key, {})[locale] = value
    return table
 
 
def collect_ios(root: Path):
    strings = load_lproj(root)
    records = []
    for path in sorted(root.rglob("*.swift")):
        lines = path.read_text(encoding="utf-8").splitlines()
        for i, line in enumerate(lines):
            icon = IMAGE_RE.search(line)
            if not icon:
                continue
            # モディファイアは直後に並ぶので、後ろ4行だけを見ます
            window = "\n".join(lines[i : i + 5])
            label = LABEL_RE.search(window)
            # strings に登録があればキー、無ければその文字列がそのまま読まれます
            resolved = strings.get(label.group(1)) if label else None
            is_button = any("Button(" in lines[j] for j in range(max(0, i - 3), i))
            records.append(
                {
                    "platform": "ios",
                    "element": "Button+Image" if is_button else "Image",
                    "icon": icon.group(1),
                    "interactive": is_button,
                    "label_raw": label.group(1) if label else None,
                    "label_kind": ("key" if resolved else "literal") if label else None,
                    "values": resolved or {},
                }
            )
    return records
 
 
def load_android_strings(res_dir: Path):
    table = {}
    for xml in sorted(res_dir.glob("values*/strings.xml")):
        locale = xml.parent.name.replace("values-", "")
        locale = "default" if locale == "values" else locale
        for node in ElementTree.parse(xml).getroot().findall("string"):
            table.setdefault(node.get("name"), {})[locale] = (node.text or "").strip()
    return table
 
 
def collect_android(res_dir: Path):
    strings = load_android_strings(res_dir)
    records = []
    for xml in sorted((res_dir / "layout").glob("*.xml")):
        for node in ElementTree.parse(xml).iter():
            if node.tag not in ICON_VIEWS:
                continue
            desc = node.get(ANDROID_NS + "contentDescription")
            key = desc[8:] if desc and desc.startswith("@string/") else None
            values = strings.get(key, {}) if key else {}
            records.append(
                {
                    "platform": "android",
                    "element": node.tag,
                    "icon": (node.get(ANDROID_NS + "src") or "").replace("@drawable/", ""),
                    "interactive": node.tag == "ImageButton",
                    "label_raw": desc,
                    "label_kind": ("key" if values else "literal") if desc else None,
                    "values": values,
                }
            )
    return records
 
 
if __name__ == "__main__":
    root = Path(sys.argv[1])
    records = collect_ios(root / "ios") + collect_android(root / "android" / "res")
    json.dump(records, sys.stdout, ensure_ascii=False, indent=2)

手元の検証用プロジェクトでは、アイコン要素が 11 件集まりました。ボタンが 9 件、画像が 2 件です。

解決する前に役割語を探して、自分の命名で落としました

最初に書いた検査は、集めた文字列に「ボタン」「button」「画像」などの役割語が含まれていないかを、そのまま調べるものでした。読み上げは要素の役割を自分で言いますので、ラベル側で重ねると二重に読まれてしまうからです。

走らせた結果の冒頭が、これでした。

ROLE_WORD  | ios/Button+Image | arrow.down.circle   | 役割語が含まれます: save_button
MISSING    | ios/Button+Image | heart               | 操作できる要素にラベルがありません
ROLE_WORD  | ios/Button+Image | square.and.arrow.up | 役割語が含まれます: ボタン
ROLE_WORD  | ios/Button+Image | shuffle             | 役割語が含まれます: shuffle_button

1行目と4行目で手が止まりました。save_buttonshuffle_button も、私が付けたキーの名前です。読み上げが実際に読むのは「保存」と「ランダム」であって、キーの名前ではありません。自分の命名規則で自分の検査を落としていたわけです。

直し方は、順序を一つ入れ替えるだけでした。役割語の検査は、キーを解決したあとの表示文字列に対してだけ行います。@string/ 参照そのものや、キー名を検査の入力にしてはいけません。

いま思えば、これは「集めるところでは判定しない」と決めておきながら、判定の側で生の文字列を受け取ってしまった、という設計の綻びでした。集める側と判定する側の受け渡しに、何を渡すかを書いておくべきだったのです。

機械で決まることだけを、ローカルで確定させます

順序を直した検査が次のものです。ここで見るのは、ラベルが在るか、参照が解決するか、必要な言語ぶん揃っているか、役割語を重ねていないかの四つだけです。

# prescreen.py — モデルに渡す前に、機械で決まる不備をここで落とします
import json
import sys
 
ROLE_WORDS = ("ボタン", "button", "画像", "image", "アイコン", "icon")
REQUIRED_LOCALES = ("default", "ja")
 
 
def display_texts(record):
    """読み上げが実際に読む文字列だけを返します。キーや参照は含めません"""
    if record.get("label_kind") == "key":
        return list((record.get("values") or {}).values())
    if record.get("label_kind") == "literal":
        return [record["label_raw"]]
    return []
 
 
def verdicts(record):
    out = []
    kind = record.get("label_kind")
    values = record.get("values") or {}
 
    if not kind:
        if record["interactive"]:
            return [("MISSING", "操作できる要素にラベルがありません")]
        return [("DECORATIVE?", "装飾なら読み上げから明示的に外します")]
 
    if kind == "literal":
        out.append(("HARDCODED", f"どの言語でも同じ文字列が読まれます: {record['label_raw']}"))
    elif not values:
        out.append(("UNRESOLVED", f"{record['label_raw']} が解決できません"))
    else:
        gaps = [loc for loc in REQUIRED_LOCALES if not values.get(loc)]
        if gaps:
            out.append(("LOCALE_GAP", f"未定義の言語: {', '.join(gaps)}"))
 
    # 役割語は「解決したあとの表示文字列」だけを見ます
    for text in display_texts(record):
        if any(word in (text or "").lower() for word in ROLE_WORDS):
            out.append(("ROLE_WORD", f"役割語が含まれます: {text}"))
            break
 
    return out
 
 
if __name__ == "__main__":
    records = json.load(open(sys.argv[1], encoding="utf-8"))
    passed = []
    for rec in records:
        found = verdicts(rec)
        head = f"{rec['platform']}/{rec['element']}"
        if not found:
            passed.append(rec)
            print(f"{'PASS':<11}| {head:<22}| {rec['icon']:<20}| 文言の判定へ回します")
            continue
        for code, detail in found:
            print(f"{code:<11}| {head:<22}| {rec['icon']:<20}| {detail}")
    stopped = len(records) - len(passed)
    print(f"合計 {len(records)} 件 / ローカルで確定 {stopped} 件 / モデルへ回す {len(passed)} 件")
    json.dump(passed, open("to_judge.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)

同じ 11 件に対して、出力はこうなりました。

PASS       | ios/Button+Image     | arrow.down.circle  | 文言の判定へ回します
MISSING    | ios/Button+Image     | heart              | 操作できる要素にラベルがありません
HARDCODED  | ios/Button+Image     | square.and.arrow.up| どの言語でも同じ文字列が読まれます: ボタン
ROLE_WORD  | ios/Button+Image     | square.and.arrow.up| 役割語が含まれます: ボタン
HARDCODED  | ios/Button+Image     | square.grid.2x2    | どの言語でも同じ文字列が読まれます: カテゴリ一覧を開くボタン
ROLE_WORD  | ios/Button+Image     | square.grid.2x2    | 役割語が含まれます: カテゴリ一覧を開くボタン
LOCALE_GAP | ios/Button+Image     | shuffle            | 未定義の言語: default
HARDCODED  | ios/Image            | hero_banner        | どの言語でも同じ文字列が読まれます: 画像
ROLE_WORD  | ios/Image            | hero_banner        | 役割語が含まれます: 画像
PASS       | android/ImageButton  | ic_save            | 文言の判定へ回します
MISSING    | android/ImageButton  | ic_heart           | 操作できる要素にラベルがありません
PASS       | android/ImageButton  | ic_share           | 文言の判定へ回します
UNRESOLVED | android/ImageButton  | ic_shuffle         | @string/shuffle_button が解決できません
ROLE_WORD  | android/ImageView    | hero_banner        | 役割語が含まれます: Image
合計 11 件 / ローカルで確定 8 件 / モデルへ回す 3 件

11 件のうち 8 件が、モデルを呼ばずに確定しました。残る 3 件を言語ごとに開いても 6 行です。判定の名前と意味を、そのまま表にしておきます。

判定意味直し方
MISSING操作できる要素にラベルが無いラベルを足す。ここは議論の余地がありません
DECORATIVE?装飾の画像にラベルが無い装飾なら読み上げから明示的に外す
HARDCODEDキーとして解決できず、その文字列がそのまま読まれるキーを定義して各言語へ移す
UNRESOLVED参照先がどの言語にも無いキーの綴りか定義漏れを確認する
LOCALE_GAP一部の言語にだけ定義が無い不足している言語を埋める
ROLE_WORD読み上げが自分で言う役割語が入っている役割語を外し、動作だけを書く

前半の二つは公開前に毎回走らせ、三つ目はときどきにしております。終了コードを非ゼロにするのは MISSINGUNRESOLVED の二つだけです。確実に間違いだと言い切れるのがこの二つだからで、残りは表示するにとどめて自分で読みます。判断の分かれる項目でビルドを止める検査は、ひと月ほどで無効化されてしまいます。無効化された検査は、何も見つけてくれません。

もう一つ、時間を節約してくれた習慣があります。集めた結果の JSON を、小さなファイルとしてそのまま履歴に残しております。あとからラベルが消えたときに、どの変更で落ちたのかが差分で分かるので、読み上げを有効にして画面を辿り直さずに済みます。

DECORATIVE? に疑問符を付けてあるのは、装飾かどうかを機械が決められないからです。ここだけは人が答えます。

Gemini に渡すのは、残った文言だけです

残った 3 件は、静的には何も悪いところがありません。「保存」も「共有」も、キーとして解決でき、日本語にも英語にも定義があります。それでも画面の中で紛らわしいことはありますし、言語によっては長すぎることもあります。この判断だけをモデルに渡します。

# judge_labels.py — 静的検問を通ったラベルだけを Gemini に渡します
import json
import os
import sys
 
from google import genai
from google.genai import types
 
MODEL = "gemini-3.8-flash"
 
SCHEMA = {
    "type": "object",
    "properties": {
        "results": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "id": {"type": "string"},
                    "locale": {"type": "string"},
                    "verdict": {
                        "type": "string",
                        "enum": ["ok", "ambiguous", "duplicate", "too_long", "wrong_register"],
                    },
                    "reason": {"type": "string"},
                    "suggestion": {"type": "string"},
                },
                "required": ["id", "locale", "verdict", "reason"],
            },
        }
    },
    "required": ["results"],
}
 
INSTRUCTION = """あなたは画面読み上げのレビュー担当です。
与えられるのは、ある一画面に並ぶ要素のラベルです。次の観点だけで判定してください。
 
- ok: その言語の読み上げとして、何が起きるかが分かる
- ambiguous: 同じ画面の他のラベルと区別がつかない、または対象が分からない
- duplicate: 同じ画面の別ラベルと実質同じ意味になっている
- too_long: 読み上げに3秒以上かかり、先頭だけでは用が足りない
- wrong_register: その言語として不自然、または画面内で文体が揃っていない
 
一般論でラベルの良し悪しを語らず、渡した画面の中だけで比べてください。
suggestion は verdict が ok 以外のときだけ、その言語で書いてください。"""
 
 
def build_payload(records):
    screen = []
    for rec in records:
        for locale, text in (rec.get("values") or {}).items():
            screen.append(
                {
                    "id": f"{rec['platform']}:{rec['icon']}",
                    "locale": locale,
                    "role": "button" if rec["interactive"] else "image",
                    "label": text,
                }
            )
    return screen
 
 
if __name__ == "__main__":
    records = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "to_judge.json", encoding="utf-8"))
    payload = build_payload(records)
    if not payload:
        print("判定対象がありません。ローカルの検問で全件が確定しました")
        raise SystemExit(0)
 
    client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
    response = client.models.generate_content(
        model=MODEL,
        contents=json.dumps({"screen": payload}, ensure_ascii=False),
        config=types.GenerateContentConfig(
            system_instruction=INSTRUCTION,
            temperature=0,
            response_mime_type="application/json",
            response_schema=SCHEMA,
        ),
    )
 
    for item in sorted(json.loads(response.text)["results"], key=lambda r: (r["id"], r["locale"])):
        mark = "  " if item["verdict"] == "ok" else "→ "
        print(f"{mark}{item['id']:<26}{item['locale']:<8}{item['verdict']:<14}{item['reason']}")
        if item.get("suggestion"):
            print(f"{'':<34}案: {item['suggestion']}")

渡す情報を決めるところに、少し時間を使いました。アイコン名は渡していません。ic_share のような名前を見せると、ラベルではなくアイコン名の説明が返ってきてしまうためです。逆に、同じ画面に並ぶ他のラベルは必ず一緒に渡します。紛らわしいかどうかは、その画面の中でしか決まらないからです。

temperature は 0 にして、response_schema で形を固定しています。判定の名前が揺れると、翌週の出力と並べられなくなります。

読み上げの言葉は、画面を見ないと決まりません

返ってきた指摘のうち、そのまま採用したものは半分ほどでした。残りは自分で書き直しております。

たとえば「保存」は、一覧画面では通じますが、詳細画面に同じラベルの別ボタンが並ぶと紛らわしくなります。モデルからは長い言い換えが返ってきましたが、私は「この壁紙を保存」に落ち着けました。読み上げの先頭で用件が分かることを優先したかったのです。

長さの問題も、日本語だけを見ていると気づきません。「ランダム」は二拍ほどで読み終わりますが、同じ意味の語は言語によってかなり長くなります。並べて聞いてみると、短い語を先に置く語順のほうが速く用が足りることが分かりました。

——なお、画面全体を対象にした監査の設計や誤検出の畳み方は、Gemini API でアクセシビリティ監査を自動化する — 誤検出に振り回されない設計 に別途書いております。今回のようにラベルだけへ範囲を絞ると、検査は驚くほど短く書けます。

今日できる、いちばん小さな一手

読み上げを有効にして、自分のアプリの1画面だけを上から下まで辿ってみてください。スクリプトを書くのはそのあとで十分です。私も、辿ってみるまでは穴が開いていることに気づいておりませんでした。

スクリプトは、ラベルが無い場所を教えてくれます。モデルは、文言が濁っている場所を教えてくれます。——けれども、隅に並ぶアイコンの帯こそ人がいちばん手を伸ばすところだ、ということは、どちらも知りません。そこは自分で聞くほかありませんでした。

お読みいただきありがとうございました。同じ課題に向き合っている方の、最初の1画面の役に立てば幸いです。

シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

開発ツール2026-09-02
モデル入れ替えの合否を一致率で決めると、カテゴリ分布のずれは通過します
画像分類バッチのモデルを差し替えたとき、ゴールデンセットの一致率は通るのにカテゴリ構成比だけが動くことがあります。必要サンプル数を実際に計算し、切替前に判定できる対応比較のハーネスを組みました。
開発ツール2026-08-30
モデル廃止日をまたぐ古い端末に、読み替えではなく拒否を返す設計
モデルが止まっても、配布済みのアプリは止まりません。後継へ黙って差し替えるか、明示的に断るか。出力契約を軸に廃止台帳を作り、残存曲線から自分の締切日を逆算するまでの実装と計測をまとめました。
開発ツール2026-08-28
1日2回のはずの分類バッチが1回しか動かない — 成果物から実行回数を復元する
定期実行のうち片方の枠だけが不発火し、失敗ログが一件も出ないまま処理量が半分になっていた運用の記録です。成果物と未処理残高から実行回数を復元し、起きなかった回を名指しする手順を動くコードでまとめます。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →