[Amazon Bedrock] Nova 2 Lite で画像内の認証情報を一括マスクするツールを作り、判定の一部をコードに移してみました
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
ブログや資料にスクリーンショットを載せる場合、アカウント ID やアクセスキーが写り込んでいないかを確認してマスクする作業が発生すると思います。1 枚 2 枚なら手作業で問題ないのですが、枚数が増えてくると、それなりに大変な作業になってきます。
この作業を自動化するアプローチが、AWS から公開されています。
Automatically redact PII in images with Amazon Nova
マルチモーダル LLM が画像を見て、文脈から個人情報を判断してマスクするという内容です。事前に正規表現やキーワードを登録しなくても、モデルが「これは認証情報だ」と判断する点が特徴になっています。
そこで今回は、このアプローチを認証情報(テキスト系)に絞り、フォルダ内の画像を一括処理するローカル CLI ツールとして実装してみました。AWS 上へはリソースを作成しないため、機密情報が写り込んだ画像を S3 にアップロードするような必要は無く、また、未使用時のコストも発生しません。
マスクの対象としたのは、以下の 7 種類です。
| 対象 | 形式・例 |
|---|---|
| AWS アカウント ID | 12 桁の数字(123456789012。コンソールでは 1234-5678-9012 と区切って表示されることもあります) |
| アクセスキー ID | AKIA で始まる文字列(AKIAIOSFODNN7EXAMPLE) |
| シークレットアクセスキー | 40 文字程度のランダムな文字列 |
| API トークン | API キー、ベアラートークン、セッショントークン |
| パスワード | パスワードとして表示されている値 |
| メールアドレス | user@example.com の形式 |
| 電話番号 | 電話番号として表示されている値 |
このうち AWS アカウント ID だけは「12 桁の数字」という形式で完全に定義できます。この違いが、後述するモデルとコードの役割分担につながっていきます。
最初に作成したツールで処理した結果をご確認ください。

作ってみると、モデルに任せてよい仕事と、そうでない仕事がはっきり分かれることが分かってきました。マスク済みの画像を検証させると、黒く塗ったはずの値を「まだ読める」と報告してきます。また「12 桁の数字が並んでいたら例外なく報告しろ」と命じても、長い識別子の中にある数字は拾えないことがありました。その一方で、同じ画像を文字起こしすると一字一句正確に読めます。この切り分けが、結果的に本稿の主題になっています。
なお、本ツールの検出は LLM の判断によるものですので、完全にすべての認証情報を確実に拾えるというわけではありません。あくまで作業負担を軽減するものであり、目視確認を置き換えるものではないという点をご了承ください。また、記載した測定値・料金は、あくまで筆者の環境で自作のサンプル画像 2 枚を対象に実測した一例です。画像の内容やモデルの応答によって変化するはずですので、数値そのものではなく測り方の参考として見ていただければ幸いです。
2 作成したツールと使い方
(1) 実行例
入力フォルダと出力フォルダを指定して実行します。
$ python mask.py --input ./images --output ./masked

検出件数やトークン数は、対象の画像によって変わります。マスク済みの画像は出力フォルダに保存されます。後述する再検証で漏れが疑われた画像は _review/ に振り分けられるため、目視確認の対象を絞り込むことができます。
12 桁スキャン として表示されているのは、アカウント ID を文字起こしと正規表現で拾った件数です。こちらは「6 モデルに任せること、任せないこと」で説明します。
(2) 隠し方は黒塗りとぼかしから選べる
黒塗りは確実ですが、資料に貼ったときに少し目立ちます。ぼかしで隠したい場面もあるかと思いますので、--style で切り替えられるようにしました。
$ python mask.py --input ./images --output ./masked --style blur
デフォルトは --style black です。

黒塗りはその領域を単色で置き換えるため、隠すという目的に対して確実です。認証情報を扱うツールですので、確実な方を既定とし、ぼかしは見た目を優先したい場合の選択肢という位置づけにしています。
(3) セットアップと動作確認
必要なライブラリは boto3 と Pillow だけです。認証情報が写り込んだスクリーンショットを模したサンプル画像も生成できるようにしてありますので、clone 後すぐに動作を確認できます。値はすべてダミーです。
$ git clone https://github.com/furuya02/nova2-image-credential-masker.git
$ cd nova2-image-credential-masker
$ pip install -r requirements.txt
$ python samples/gen_samples.py --output ./images
$ python mask.py --input ./images --output ./masked
権限は、推論プロファイル経由での呼び出しとなるため、プロファイルと基盤モデルの両方に対する bedrock:InvokeModel が必要です。加えて、Bedrock のコンソールで Nova 2 Lite のモデルアクセスを有効にしておきます。詳細な手順と IAM ポリシーの例は README に記載しました。
Github README.ja.md
3 設計の考え方
(1) 全体構成
構成は以下のとおりです。

ローカルから Bedrock を呼び出すだけの構成です。呼び出し先は Nova 2 Lite の 1 つだけです。
処理は 5 つのステップに分かれていますが、12 桁かどうかの判定だけはローカルで行っています。この切り分けに至った経緯は「6 モデルに任せること、任せないこと」で説明します。
(2) 公式ブログの構成との違い
冒頭で紹介した AWS 公式ブログの構成は、以下のようになっています。
| 役割 | 使用しているもの |
|---|---|
| 初期スクリーニング・PII 分類・最終検証 | Nova 2 Lite |
| テキストの座標取得 | Amazon Textract |
| 顔などの視覚的な PII | SAM 3 on Amazon SageMaker |
| 全体のオーケストレーション | S3 / EventBridge / Step Functions / Lambda |
大量の画像を継続的に処理するための構成であり、堅牢な作りになっています。
一方、今回作りたかったのは「手元に溜まったスクリーンショットを、公開前にまとめて確認する」という日常業務向けのものでした。そこで、以下の方針としました。
| 観点 | 公式ブログ | 本ツール |
|---|---|---|
| 対象 | 継続的に流れてくる画像 | 手元のフォルダ内の画像 |
| 検出対象 | PII 全般(顔なども含む) | 認証情報(テキスト系)のみ |
| 実行場所 | AWS 上(サーバーレス) | ローカル |
| 構成要素 | 複数サービスの組み合わせ | Bedrock API のみ |
(3) テキストの座標の扱い
マスクするには、認証情報が画像の「どこ」にあるかを知る必要があります。公式ブログでは Textract で座標を取得していますが、今回の用途では 2 点が課題になりました。
1 点目は、対応言語です。Textract が抽出できるのは英語、ドイツ語、フランス語、スペイン語、イタリア語、ポルトガル語とされており、日本語は含まれていません。
日本語の AWS コンソール画面を扱いたかったため、この点が問題になりました。
2 点目は、リージョンです。2026 年 8 月時点で、Textract は東京リージョンでは提供されていません。aws ssm get-parameters-by-path で ap-northeast-1 のサービス一覧を取得したところ、bedrock や rekognition は返ってくる一方、textract は含まれていませんでした。最新の状況は以下で確認できます。
そこで本ツールでは、座標の取得も Nova 2 Lite に任せる構成としました。Nova 2 の画像理解の機能にはバウンディングボックスの検出が含まれており、[0, 1000] に正規化されたスケールで座標を返すことができます。スクリーンショット内の要素を特定する用途が想定されている旨も記載されています。
Multimodal understanding - Amazon Nova
バウンディングボックスの活用例は、物体検出を題材にした公式ブログでも紹介されています。ただし、スクリーンショットに描かれた文字列に対してどこまでの精度が出るかは、実際に測ってみる必要がありました。
Object detection with Amazon Nova 2 Lite
この構成であれば、日本語の画面にも対応でき、東京リージョンで完結し、かつ呼び出し先が Bedrock API 1 本になるためツールが軽量になります。
なお、これは制約下での選択です。英語の画面で、かつ Textract が利用できるリージョンであれば、座標の確実性という点では OCR 専用サービスである Textract の方が有利かと思います。日本語の画面を東京リージョンで扱いたいという条件があったために、この構成を採っています。
(4) 推論プロファイル(処理を国内に閉じる)
Nova 2 Liteは、inferenceTypesSupported が INFERENCE_PROFILE のみとなっており、ON_DEMAND が含まれていません。つまり、モデル ID を直接指定した呼び出しはできず、推論プロファイル経由で呼び出す必要があります。
$ aws bedrock list-foundation-models --region ap-northeast-1 \
--query "modelSummaries[?modelId=='amazon.nova-2-lite-v1:0'].[modelId,inferenceTypesSupported]" \
--output text

そして、jp で始まるプロファイルで、東京と大阪にルーティングされることが分かります、
$ aws bedrock list-inference-profiles --region ap-northeast-1 \
--query "inferenceProfileSummaries[?contains(inferenceProfileId, 'nova-2-lite')].[inferenceProfileId,models[].modelArn]" \
--output json

今回、こちらを使用しました。
DEFAULT_MODEL_ID = "jp.amazon.nova-2-lite-v1:0"
4 コードの解説
Github mask.py
画像 1 枚あたり、以下の 5 ステップで処理しています。
| ステップ | 処理 | 判定するのは |
|---|---|---|
| 1 スクリーニング | 認証情報の有無だけを判定 | モデル |
| 2 検出 | バウンディングボックスを取得 | モデル |
| 3 12 桁スキャン | テキストを文字起こしして、12 桁の数字を含む行を拾う | コード(正規表現) |
| 4 マスク | Pillow で塗り潰し | コード |
| 5 再検証 | マスク済み画像を再度チェック | モデル + コード |
ステップ 1 を分けているのは、コストを抑えるためです。認証情報が写っていない画像に対して座標付きの検出を走らせるのは無駄になりますので、まず有無だけを短い出力で判定し、無ければコピーのみで終了します。
(1) 検出のプロンプト
検出対象のカテゴリは、プロンプトで定義しています。
# mask.py(要点抜粋)
CATEGORIES = """- aws_account_id: a run of exactly 12 consecutive digits. It may stand alone, or
sit inside a longer identifier such as arn:aws:iam::123456789012:user/foo -
report those as well. Cover ONLY the 12 digits: the characters before and after
them must stay readable. "text" must be the 12 digits alone.
- aws_access_key_id: an access key ID such as AKIA...
(以下省略)"""
検出結果は、構造化した JSON で受け取ります。
# mask.py(要点抜粋)
Respond with JSON only, no markdown fence, in this exact shape:
{"findings": [{"category": "...", "text": "...", "bbox": [x1, y1, x2, y2]}]}
(2) 座標の変換とマスク
Nova が返す座標は [0, 1000] に正規化されていますので、画像の実寸に変換します。
# mask.py(要点抜粋)
def apply_mask(img, findings, pad_x_ratio, pad_y_ratio, style=DEFAULT_STYLE):
w, h = img.size
d = ImageDraw.Draw(img)
boxes = []
for f in findings:
x1, y1, x2, y2 = f["bbox"]
px1, py1 = x1 / 1000 * w, y1 / 1000 * h
px2, py2 = x2 / 1000 * w, y2 / 1000 * h
bh = py2 - py1 # 検出枠の高さ = 文字サイズの目安
pad_x, pad_y = bh * pad_x_ratio, bh * pad_y_ratio
box = [max(0, px1 - pad_x), max(0, py1 - pad_y),
min(w, px2 + pad_x), min(h, py2 + pad_y)]
d.rectangle(box, fill="black") # --style blur ならここでぼかしを掛ける
boxes.append(box)
return boxes
検出枠は実際の文字位置に対して多少ずれますので、パディングを付けてから塗り潰します。このパディングの決め方については、次章で実測した内容を紹介します。
鍵になるのは、塗り潰した矩形のリストを返している点です。これを「6 モデルに任せること、任せないこと」で使用します。
5 検出座標の精度
Nova が返す座標が実用に足るかどうかを確認するため、正解の座標が分かっている画像を用意して測定しました。
(1) 測定方法
実画面のスクリーンショットでは正解の座標が得られませんので、サンプル画像を生成する際に、描画したテキストの領域を正解データ(Ground Truth)として記録しておき、Nova が返した座標との IoU(重なりの割合)を計算しました。合成画像を使っているのは、正解座標を厳密に持てるのがこの方法だけだったためです。
緑が正解の位置、赤が Nova 2 Lite が返した位置です。

日本語の画面でも検出できており、位置もほぼ一致していることが確認できました。
(2) 測定結果
対象はサンプル画像 2 枚(計 12 箇所)で、母数が小さいため傾向をつかむ目安としてご覧ください。同じ 2 枚に対して 5 回実行しています。
この 5 回で得られたすべての検出について IoU を計算し、0.46 から 0.89 の範囲に収まりました。
IoU の値だけを見ると高くありませんが、これは検出枠が正解よりもやや大きめに返ってくることが主な要因です。マスクの用途では、正解より大きい分には問題になりません。
重要なのは「覆えていない部分がどれだけあるか」ですので、各辺について不足しているピクセル数を測定しました。文字の大きさに依存しないよう、テキストの高さに対する比率で整理しています。
| 辺 | 最大不足量(テキスト高さに対する比率) |
|---|---|
| 左 | 1.24 |
| 右 | 0.03 |
| 上 | 0.00 |
| 下 | 0.04 |
上下はすべての検出で正解を覆えており、不足は左端に偏っていました。左端では最大で文字の高さの 1.24 倍、文字数にすると 1 文字から 2 文字分ほどはみ出しています。
興味深かったのは、この 1.24 という値が、日を改めて測り直したときも同じだった点です。検出される箇所数の方は実行ごとに変動するのに対し、ずれ方には一定の偏りがあるように見えます。ただし、フォントや背景が異なる画像では別の傾向になる可能性がありますので、手元の画像で同じ測り方を試していただくのが確実かと思います。
(3) パディングの決め方
当初、パディングを「画像の長辺に対する比率」で実装していましたが、これには問題がありました。長い文字列に埋め込まれた数字のように前後の文字が密着している箇所では、パディングが過大になり、周囲まで黒塗りになってしまいます。
そこで、パディングの基準を検出枠の高さに対する倍率に変更しました。
# mask.py(要点抜粋)
DEFAULT_PAD_X = 1.5
DEFAULT_PAD_Y = 0.4
文字サイズに追従しますので、解像度の異なる画像でも同じ設定値を使用できます。値は上記の実測結果に余裕を加えたものです。対象とする画像によっては過不足が出ますので、--padding-x / --padding-y で調整できるようにしてあります。
6 モデルに任せること、任せないこと
ここからが本題です。実装を進めるうちに、モデルに任せてうまくいく仕事と、任せてはいけない仕事がはっきりしてきました。
(1) 黒く塗ったはずの値が「まだ読める」と報告される
公式ブログでは、最後にマスク済みの画像をもう一度モデルに渡して、漏れがないかを確認するステップが置かれています。本ツールでも同じ構成としましたが、完全に黒塗りできている画像に対して「まだ読める認証情報はあるか」と尋ねたところ、以下の応答が返ってきました。
{"remaining": [{"category": "aws_account_id", "text": "123456789012"}]}
しかし、この 123456789012 は画像上には存在しません。完全に黒く塗り潰されています。黒塗りの周囲に残っているラベル(AWS_ACCOUNT_ID= や "Account":)から、そこにあったはずの値を文脈で補完しているようです。
このままでは、正しくマスクできた画像まですべて「要確認」に振り分けられてしまい、目視確認の対象を絞り込むという目的が達成できません。
(2) プロンプトでの抑制は効かなかった
まず、プロンプトで禁止することを試しました。
- 黒い矩形で覆われた箇所は報告しないこと
- 実際に読める文字だけを、見えたとおりに書き写すこと
これらを追記して実行しましたが、結果は改善しませんでした。想定では、明示的に禁止すれば減ると考えていましたが、実測では、それまで問題のなかったもう 1 枚でも同じように存在しない値を報告するようになりました。
(3) 座標で機械的に判定する
そこで、再検証にも座標を返させることにしました。報告された位置が黒塗り矩形の内側であれば、それは黒く塗った場所を「読めた」と言っていることになりますので、推測とみなして破棄します。
# mask.py(要点抜粋)
def drop_hallucinations(remaining, boxes, size):
w, h = size
real = []
for r in remaining:
bbox = r.get("bbox")
cx = (bbox[0] + bbox[2]) / 2 / 1000 * w
cy = (bbox[1] + bbox[3]) / 2 / 1000 * h
if not any(b[0] <= cx <= b[2] and b[1] <= cy <= b[3] for b in boxes):
real.append(r)
return real
ここでは 2 つのことが同時に起きています。ハルシネーションは黒塗り矩形の内側を指すため破棄され、マスクがずれて実際に読めてしまっている場合は矩形の外側に出るため漏れとして検知できます。
(4) 長い識別子の中の数字が拾えない
もう 1 つ、性質の違う問題に当たりました。arn:aws:iam::123456789012:user/sin-hirauchi のような文字列の中にあるアカウント ID が、検出プロンプトでは拾えないのです。
プロンプトの書き方を変えて実測してみました。サンプル 2 枚(正解 5 箇所 / 7 箇所)での検出数です。
| プロンプトの書き方 | 01(正解 5) | 02(正解 7) | 長い識別子の中の 12 桁 |
|---|---|---|---|
| ARN の例を挙げて明示する | 5 | 7 | 拾える |
| 「長い識別子の中にある場合も」と一般化 | 4 | 7 | 拾えない |
区切り文字(: / -)を列挙して具体化 |
4 | 6 | 拾えない |
| 「判断するな。12 桁が並んでいたら例外なく報告しろ」 | 4 | 7 | 拾えない |
「例外なく報告しろ」と命じても拾えませんでした。ここまで来ると、指示の書き方の問題ではなさそうです。
一方、同じ画像を書き起こさせてみると、この文字列を一字一句正確に読めていました。
'arn:aws:iam::123456789012:user/sin-hirauchi'
つまり、読めていないのではなく、読んだものを「12 桁の数字の並び」として扱えないということかと思います。モデルはこの文字列を 1 つの識別子として捉えており、文字単位で走査してはいないようです。正規表現なら確実にできる処理が、LLM には苦手だという事なのかも知れません。
(5) 判定をコードに移す
そこで、12 桁かどうかの判定をモデルから、コード側の正規表現に移しました。モデルに任せるのは「読むこと」だけです。
# mask.py(要点抜粋)
DIGIT_RUN = re.compile(r"\d{12}|\d{4}[\s-]\d{4}[\s-]\d{4}")
def scan_once(client, args, img_bytes, fmt, usage):
data = converse(client, args.model_id, img_bytes, fmt, OCR_PROMPT,
usage, max_tokens=args.max_tokens)
hits = []
for line in items_of(data, "lines"):
if not isinstance(line, dict):
continue
text, bbox = line.get("text", ""), line.get("bbox")
if valid_bbox(bbox) and DIGIT_RUN.search(str(text)):
hits.append({"category": "digits12", "text": text, "bbox": bbox})
return hits
該当した行は、値だけを切り出さずに行ごとマスクします。範囲は広くなりますが、値の一部が枠からはみ出して残るということがなくなります。

なお、AWS コンソールではアカウント ID が 1234-5678-9012 と区切って表示されることがありますので、正規表現はその形も拾うようにしています。
効果を確かめるため、日本語のコンソール風画面にある ARN の中のアカウント ID を、拾えたかどうかで比べました。同じ画像に対して 5 回ずつ実行しています。
| 拾い方 | 5 回中の成功 |
|---|---|
| 検出プロンプト(モデルが判断) | 0 回 |
| 12 桁スキャン(正規表現が判断) | 5 回 |
モデルでは、 5 回とも同じ箇所を落としてしまいましたが、正規表現は 5 回とも拾うことが出来ました。
(6) 文字起こしそのものも揺らぐ
ただし、文字起こしを 1 回行えば済むわけでもありませんでした。同じ画像に対して同じプロンプトを投げても、拾える行数が実行のたびに変わります。実際の画面で試したときには、3 箇所ある 12 桁のうち 1 箇所しか拾えないこともありました。
そこで、文字起こしは既定で 3 回実行し、結果を重ね合わせています。
# mask.py(要点抜粋)
hits = []
for _ in range(max(1, args.ocr_passes)):
hits += scan_once(client, args, img_bytes, fmt, usage)
return dedupe_hits(hits)
回数は --ocr-passes で調整できます。呼び出しは増えますが、取りこぼしを減らすことを優先しました。
(7) 整理すると
今回の実装で、モデルとコードの分担は以下のようになりました。
| 仕事 | 担当 | 理由 |
|---|---|---|
| 画像から文字を読む | モデル | 日本語混じりの画面でも正確に読める |
| 要素の位置を返す | モデル | 座標を返せる。多少のずれはパディングで吸収できる |
| 12 桁かどうかの判定 | コード | 形式で定義できる。モデルは文字列走査が苦手 |
| 黒塗り済みかどうかの判定 | コード | 座標の内外で機械的に決まる |
| 認証情報らしさの判断 | モデル | 形式で定義できないため、文脈判断が要る |
結果的には、形式で定義できるものはコードに、文脈が要るものはモデルに という当たり前のような結論となっています。
7 料金
発生するのは Bedrock の従量課金のみです。
2026 年 8 月時点の東京リージョンの Nova 2 Lite の単価は、Price List API(aws pricing get-products --service-code AmazonBedrock)で確認したところ以下でした。
| 項目 | 単価 |
|---|---|
| 入力 | $0.396 / 100 万トークン |
| 出力 | $3.311 / 100 万トークン |
本ツールでは 1 画像あたり、スクリーニング・検出・再検証に加えて文字起こしを 3 回呼び出します。「2 作成したツールと使い方」で示した実行例では、10 回の呼び出しで入力 5,046 トークン、出力 5,080 トークン、金額にして $0.0188、1 画像あたり $0.0094 ほどでした。
文字起こしを繰り返している分、素直に 1 回ずつ呼ぶ構成より高くなっています。
なお、画像の入力トークンについては、解像度によらず 1 枚あたり 230 トークン の固定で課金されるとドキュメントに記載されています。
Multimodal understanding - Amazon Nova
8 最後に
AWS 公式ブログのアプローチを、認証情報に絞ったローカル CLI ツールとして実装してみました。今回の検証で分かったことを 3 点にまとめます。
- 形式で定義できるものは、モデルに判断させない方がよい。12 桁かどうかは正規表現で決まりますので、「例外なく報告しろ」と命じるより、文字起こししてコードで判定する方が確実でした
- LLM の出力を LLM で検証する構成では、検証結果もハルシネーションする。プロンプトでの抑制は効かず、座標による決定論的な判定と組み合わせることで実用的になりました
- モデルが返す座標は、用途に応じた後処理とセットで考える必要がある。IoU の値そのものよりも、「どの方向にどれだけ不足するか」を測る方が、パディングの設計には有用でした
加えて、Nova 2 Lite には jp. から始まる推論プロファイルが用意されており、処理を日本国内のリージョンに閉じることができます。機密情報を扱うツールでは選択肢として押さえておきたいところです。
繰り返しになりますが、本稿の測定値はサンプル画像 2 枚での一例です。実際の画面では文字サイズやレイアウトが異なりますので、特定環境での一例として捉えてください。
使用したコードは、下記に置きました。
Github nova2-image-credential-masker







