Amazon SageMaker Canvasによる画像分類モデルの構築と実践

記事タイトルとURLをコピーする

こんにちは!イーゴリです。

背景

近年、機械学習を活用した画像分類のニーズは急速に高まっています。 例えば、以下のようなユースケースがあります。

  • ECサイトでの商品画像の自動分類
  • 不正画像や不適切コンテンツの検知
  • 製造業における外観検査の自動化
  • ペット・動物画像の自動タグ付け

しかし従来、画像分類モデルの構築には以下のような課題がありました。

  • Pythonや機械学習の専門知識が必要
  • モデル選定やハイパーパラメータ調整が難しい
  • 学習環境の構築に時間がかかる
  • 推論用APIの実装・運用が複雑

このような課題を解決するサービスがAmazon SageMaker Canvasです。

SageMaker Canvasを利用することで、

  • コードを書かずにモデルを作成できる
  • AutoMLにより最適なモデルが自動選択される
  • 数クリックで推論(Prediction)が可能
  • そのままエンドポイントとしてデプロイできる

といったメリットがあります。

本記事では、シンプルな例として「犬と猫の画像分類」を題材に、 以下の流れを実践します。

  1. 画像データをS3にアップロード
  2. SageMaker Canvasでデータセット作成
  3. 手動ラベル付与
  4. 画像分類モデルの構築
  5. 推論(Single / Batch)
  6. エンドポイント作成
  7. AWS CLI / Webアプリからの利用

機械学習の専門知識がなくても、 実際に「動く画像分類API」を構築できることをゴールとします。

構成図

画像を収集し、Amazon S3にアップロードする

まず、S3バケットを作成します。

[バケットを作成]をクリックします。

バケット名:任意の名前(例:sagemaker-dataset-433)

その他の項目はすべてデフォルトのままにして、[バケットを作成] をクリックします。

動物が好きなので、サンプルとして猫と犬の画像をS3にアップロードします。

  • 検証用データセット

犬:149枚
猫:149枚

  • 検証用データセット

犬:15枚
猫:15枚

アップロード結果は以下の通りです。

Amazon SageMaker Canvas 初期設定(ドメイン作成)

まず、[Amazon SageMaker AI]に移動します。

画面に移動後、初期設定を行います。

続いて、ドメインを作成します。

今回は[シングルユーザー向けの設定 (クイックセットアップ]を選択し、[設定]をクリックします。

セットアップが完了し、画面が切り替わるまで待機します。

次に [Canvas] に移動し、作成したドメインを選択した上で [Canvasを開く] をクリックします。

Amazon SageMaker Canvas でデータセットを作成する

[Datasets] に移動し、[Import Data] をクリックします。

続いて [Images] を選択します。

トレーニング用データセット

「Create an image dataset」の画面で任意のDataset Nameを入力し、[Create]をクリックします。

ローカル端末から直接アップロードすることも可能ですが、今回は画像をS3にアップロードしているため、S3を選択し、トレーニング用のデータセットを選択した上で、[Create Dataset]をクリックします。

Filter by data typeでImageを選択すると、対象のデータセットが見やすくなります。

検証用データセット

「Create an image dataset」の画面で任意のDataset Nameを入力し、[Create]をクリックします。

ローカル端末から直接アップロードすることも可能ですが、今回は画像をS3にアップロードしているため、S3を選択し、検証用のデータセットを選択した上で、[Create Dataset]をクリックします。

画像にラベルを付与

今回は猫と犬を区別するラベルが付与されていないため、手動でラベルを付与します。

「training-cats-vs-dogs」の右にある [...] をクリックし、[View Dataset] をクリックします。

通常、数時間後に画像が表示されるようになりますが、すぐにラベルを付与したい場合は、「Cats」および「Dogs」というフォルダ名が存在していれば、画像が表示されていなくても手動でラベルを付与できます。

ここでのハマりポイントとして、「Label」がなかなか表示されないことがありましたが、任意の画像を一度クリックすると表示されるようになります。

[Add Label] で [Cat] と [Dog] を追加します。

対象の画像を選択し、Unlabeledからcatまたはdogを選択します。

結果は以下の通りです。

次に別の動物の種類を選択し、同様の手順でラベルを付与します。

最終的にUnlabeledの画像がないことを確認して、次に進みます。

トレーニングデータセットを用いて画像分類モデルを構築する

[Models] に移動し、[Create new model] をクリックします。

任意のModel nameを入力し、[Image analysis] を選択した上で、[Create] をクリックします。

トレーニングデータセットを選択します。

次の画面で、[Quick Build](目安処理時間:15〜30分)または [Standard Build](目安処理時間:数時間)を選択します。

[Quick Build] の右にある [▲] をクリックすると、[Standard Build] が表示されます。

それぞれの違いは以下の通りです。

[Quick Build]:少ないイテレーション数で素早くモデルを構築します。データや設定の確認など、テスト目的に適しています。精度は [Standard Build] より低くなります。

[Standard Build]:AutoMLを使用して複数のアルゴリズムやハイパーパラメータを試し、最適なモデルを構築します。精度が高く、本番利用に適しています。

今回は動作確認を目的として [Quick Build] を選択しますが、より高い精度が必要な場合は [Standard Build] の利用をおすすめします。

Quick BuildとStandard Buildの比較表:

項目 Quick Build Standard Build
処理時間(数値・カテゴリ) 2〜20分 2〜4時間
処理時間(画像・テキスト) 15〜30分 2〜5時間
精度 普通 高い
データのダウンサンプリング 50,000行を超えると自動で50,000行に削減 削減なし(最大5GB)
最小データ数 2カテゴリ分類:500行以上 250行以上(画像は50枚以上)
用途 動作確認・テスト 本番利用

ポイント:

  • Quick Build は大きなデータセットだと 強制的にダウンサンプリングされるため精度が下がる
  • Standard Build は 最低250行のデータが必要
  • 画像分類の Standard Build は最低 50枚の画像が必要

詳細は下記になりますので、ご参照ください。

docs.aws.amazon.com

今回は動作確認を目的として [Quick Build] を選択しますが、より高い精度が必要な場合は [Standard Build] の利用をおすすめします。

[Analyze] の画面で進捗が100%になれば完了です。

検証データセットを用いてモデルの評価を行う

Single Prediction(1枚の場合)

試しに [Predict] をクリックし、猫と犬の分類を確認してみます。

画像を選択し、[Create Dataset] をクリックします。

選択した画像は猫だったため、正しく分類されています。

続いて、バッチ処理も確認します。

Batch Prediction(複数枚の場合)

[Batch Prediction] を選択し、[Manual] > [Select Dataset] をクリックします。

対象のデータセット(例:validation-cats-vs-dogs)を選択し、[Generate Predictions] をクリックします。

「Status」が「Generate Predictions」から「Ready」になるまで待機します。

[...] をクリックし、[View prediction results] をクリックします。

バッチ処理の結果が表示されます。

ただし、1件ずつ確認すると時間がかかるため、CSVでの確認をおすすめします。

CSVファイルをダウンロードします。[Download prediction] > [CSV] をクリックします。

「predicted_label」および「confidence_score」を確認できます。

CSVファイルの構造は以下の通りです。

カラム名 説明
image_name 画像ファイル名
image_url S3上のファイルパス
predicted_label 予測結果:dog または cat
confidence_score モデルの信頼度(0〜1)

結果からわかること:

  • ファイル 6150〜6164 → すべて dog、信頼度ほぼ 100%(0.9997以上)
  • ファイル 6456〜6470 → すべて cat、信頼度 100%
  • FAILED — ファイルの処理に失敗(破損または形式が不正な可能性あり)

モデルの精度は非常に高く、ほぼすべての画像でconfidence_scoreが1.0に近い結果となりました。

SageMaker Canvasからモデルをデプロイし、エンドポイントを作成する

[Deploy] ボタンが無効になっているため、エンドポイントを作成できるように設定を行います。

Canvasで使用しているドメインおよびユーザープロファイルを開きます。

ML Ops設定を有効化し、[送信] をクリックします。

続いて、[Create Deployment] をクリックします。

適切なインスタンスタイプを選択し、[Deploy] をクリックします。

「Status」が「Creating」から「In service」に変わるまで待機します。

これでエンドポイントが利用可能になります。

モデル登録

任意の手順ですが、作成したモデルをModel Registryに登録することも可能です。

[My Models] > [Add to Model Registry] をクリックします。

作成したエンドポイントをAWS CLI・Python(Webページ)から利用する

AWS CLI

下記のコマンドで確認できます。

aws sagemaker-runtime invoke-endpoint \
  --endpoint-name canvas-dogs-vs-cats \
  --body fileb://12472.jpg \ 
  --content-type image/jpeg \ 
  --accept application/json \
  output.txt && cat output.txt
{
    "ContentType": "application/json",
    "InvokedProductionVariant": "canvas-model-variant-2026-05-04-04-51-57-521450"
}
{"predicted_label": "cat", "probability": 1.0, "probabilities": [1.0, 6.9957616088967e-12], "labels": ["cat", "dog"]}%                                                                          

Python(Webページ)

ローカル端末上のWebページ版

最も簡単な確認方法は、ローカル端末上でPythonを実行する方法です。

以下のファイルを作成します。

  • server.py
#!/usr/bin/env python3
import boto3
import base64
import json
from http.server import HTTPServer, BaseHTTPRequestHandler

ENDPOINT_NAME = "canvas-dogs-vs-cats"
PORT = 8080

class Handler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', '*')
        self.end_headers()

    def do_POST(self):
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length)

        try:
            client = boto3.client('sagemaker-runtime')
            response = client.invoke_endpoint(
                EndpointName=ENDPOINT_NAME,
                ContentType='image/jpeg',
                Accept='application/json',
                Body=body
            )
            result = response['Body'].read()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(result)
        except Exception as e:
            self.send_response(500)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            self.wfile.write(json.dumps({"error": str(e)}).encode())

    def log_message(self, format, *args):
        print(f"[{self.address_string()}] {format % args}")

print(f"Starting proxy on http://localhost:{PORT}")
print(f"Endpoint: {ENDPOINT_NAME}")
HTTPServer(('', PORT), Handler).serve_forever()
  • index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SageMaker Image Classifier</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #1a1a1a; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
  .container { width: 100%; max-width: 600px; }
  h1 { font-size: 20px; font-weight: 500; margin-bottom: 0.25rem; }
  .subtitle { font-size: 13px; color: #666; margin-bottom: 1.5rem; }
  .card { background: #fff; border: 1px solid #e5e5e5; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
  .card-title { font-size: 12px; font-weight: 500; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
  label { font-size: 13px; color: #555; display: block; margin-bottom: 5px; }
  input[type="text"], input[type="password"], select {
    width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 8px;
    font-size: 14px; font-family: inherit; color: #1a1a1a; background: #fafafa;
    outline: none; transition: border-color 0.15s;
  }
  input:focus, select:focus { border-color: #999; background: #fff; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  .field { margin-bottom: 12px; }
  .drop-zone {
    border: 2px dashed #ddd; border-radius: 8px; padding: 2rem;
    text-align: center; cursor: pointer; transition: all 0.15s; background: #fafafa;
  }
  .drop-zone:hover { border-color: #aaa; background: #f0f0f0; }
  .drop-zone.dragover { border-color: #555; background: #efefef; }
  .drop-zone p { font-size: 14px; color: #666; margin-bottom: 4px; }
  .drop-zone small { font-size: 12px; color: #aaa; }
  #preview { width: 100%; max-height: 240px; object-fit: contain; border-radius: 8px; display: none; margin-top: 12px; }
  #fileName { font-size: 12px; color: #888; margin-top: 6px; display: none; }
  .btn {
    width: 100%; padding: 11px; background: #1a1a1a; color: #fff;
    border: none; border-radius: 8px; font-size: 14px; font-family: inherit;
    cursor: pointer; transition: opacity 0.15s;
  }
  .btn:hover { opacity: 0.85; }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }
  .spinner { display: none; margin: 1rem auto 0; width: 22px; height: 22px; border: 2px solid #ddd; border-top-color: #333; border-radius: 50%; animation: spin 0.6s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .result { border-radius: 8px; padding: 1.25rem; margin-top: 1rem; display: none; }
  .result.success { background: #f0faf4; border: 1px solid #b8e5c8; }
  .result.error { background: #fdf0f0; border: 1px solid #f0b8b8; }
  .result-label { font-size: 24px; font-weight: 500; color: #1a1a1a; }
  .result-prob { font-size: 13px; color: #666; margin-top: 4px; }
  .bar-wrap { margin-top: 14px; }
  .bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
  .bar-name { min-width: 60px; color: #555; }
  .bar-track { flex: 1; background: #e8e8e8; border-radius: 4px; height: 8px; overflow: hidden; }
  .bar-fill { height: 100%; border-radius: 4px; background: #1a1a1a; transition: width 0.4s ease; }
  .bar-val { min-width: 40px; text-align: right; font-weight: 500; color: #1a1a1a; }
  .toggle-btn { font-size: 12px; color: #888; cursor: pointer; background: none; border: none; font-family: inherit; text-decoration: underline; margin-top: 10px; padding: 0; }
  pre { background: #f5f5f5; border-radius: 8px; padding: 1rem; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-top: 10px; display: none; max-height: 200px; overflow-y: auto; color: #444; }

</style>
</head>
<body>
<div class="container">
  <h1>SageMaker Image Classifier</h1>
  <p class="subtitle">SageMaker Canvas Endpoint を使った画像分類ツール</p>

  <div class="card">
    <div class="card-title">設定</div>
    <div class="field">
      <label>エンドポイント名</label>
      <input type="text" id="endpoint" placeholder="canvas-dogs-vs-cats" value="canvas-dogs-vs-cats" />
    </div>
  </div>

  <div class="card">
    <div class="card-title">画像</div>
    <div class="drop-zone" id="dropZone">
      <p>画像をドラッグ&ドロップ、またはクリックして選択</p>
      <small>.jpg, .jpeg, .png</small>
      <input type="file" id="fileInput" accept="image/*" style="display:none;" />
    </div>
    <img id="preview" alt="preview" />
    <div id="fileName"></div>
  </div>

  <button class="btn" id="runBtn" onclick="runInference()">分類を実行する</button>
  <div class="spinner" id="spinner"></div>

  <div class="result" id="result">
    <div class="result-label" id="resultLabel"></div>
    <div class="result-prob" id="resultProb"></div>
    <div class="bar-wrap" id="barWrap"></div>
    <button class="toggle-btn" onclick="toggleCode()">生のレスポンスを表示</button>
    <pre id="rawCode"></pre>
  </div>
</div>

<script>
const drop = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
let selectedFile = null;

drop.addEventListener('click', () => fileInput.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dragover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('dragover'); if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });

function setFile(f) {
  selectedFile = f;
  const preview = document.getElementById('preview');
  preview.src = URL.createObjectURL(f);
  preview.style.display = 'block';
  const fn = document.getElementById('fileName');
  fn.textContent = f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)';
  fn.style.display = 'block';
  document.getElementById('result').style.display = 'none';
}

function toggleCode() {
  const c = document.getElementById('rawCode');
  c.style.display = c.style.display === 'none' ? 'block' : 'none';
}

async function runInference() {

  if (!selectedFile) { alert('画像を選択してください'); return; }

  const btn = document.getElementById('runBtn');
  const spinner = document.getElementById('spinner');
  btn.disabled = true;
  spinner.style.display = 'block';
  document.getElementById('result').style.display = 'none';

  try {
    const arrayBuffer = await selectedFile.arrayBuffer();
    const body = new Uint8Array(arrayBuffer);
    const response = await fetch('http://localhost:8080', {
      method: 'POST',
      headers: { 'Content-Type': 'image/jpeg' },
      body
    });
    if (!response.ok) { const t = await response.text(); throw new Error(`HTTP ${response.status}: ${t}`); }
    const text = await response.text();
    console.log('RAW RESPONSE:', text);
    let data;
    try {
      data = JSON.parse(text);
    } catch(e) {
      const parts = text.trim().split(',');
      data = {
        predicted_label: parts[0] ? parts[0].trim() : '—',
        probability: parts[1] ? parseFloat(parts[1].trim()) : null,
        labels: parts[2] ? JSON.parse(parts[2].trim()) : [],
        probabilities: parts[3] ? JSON.parse(parts[3].trim()) : [],
        _raw: text
      };
    }
    showResult(data);
  } catch (err) {
    const r = document.getElementById('result');
    r.className = 'result error';
    r.style.display = 'block';
    document.getElementById('resultLabel').textContent = 'エラー';
    document.getElementById('resultProb').textContent = err.message;
    document.getElementById('barWrap').innerHTML = '';
    document.getElementById('rawCode').textContent = err.stack || err.message;
    document.getElementById('rawCode').style.display = 'block';
  } finally {
    btn.disabled = false;
    spinner.style.display = 'none';
  }
}

function showResult(data) {
  const r = document.getElementById('result');
  r.className = 'result success';
  r.style.display = 'block';
  document.getElementById('rawCode').textContent = JSON.stringify(data, null, 2);
  document.getElementById('rawCode').style.display = 'none';
  const label = data.predicted_label || data.label || (data.labels && data.labels[0]) || '—';
  const prob = data.probability !== undefined ? (data.probability * 100).toFixed(1) : null;
  document.getElementById('resultLabel').textContent = label;
  document.getElementById('resultProb').textContent = prob ? `信頼度: ${prob}%` : '';
  const barWrap = document.getElementById('barWrap');
  barWrap.innerHTML = '';
  const labels = data.labels || [];
  const probs = data.probabilities || [];
  if (labels.length && probs.length) {
    labels.forEach((l, i) => {
      const p = (probs[i] * 100).toFixed(1);
      barWrap.innerHTML += `<div class="bar-row"><span class="bar-name">${l}</span><div class="bar-track"><div class="bar-fill" style="width:${p}%"></div></div><span class="bar-val">${p}%</span></div>`;
    });
  }
}

function signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType }) {
  const parsedUrl = new URL(url);
  const host = parsedUrl.host;
  const path = parsedUrl.pathname;
  const bodyHash = sha256Hex(body);
  const hasToken = sessionToken && sessionToken.length > 0;
  const canonicalHeaders = hasToken
    ? `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`
    : `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\n`;
  const signedHeaders = hasToken
    ? 'content-type;host;x-amz-date;x-amz-security-token'
    : 'content-type;host;x-amz-date';
  const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${bodyHash}`;
  const credentialScope = `${dateStamp}/${region}/sagemaker/aws4_request`;
  const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256Hex(strToBytes(canonicalRequest))}`;
  const signingKey = getSigningKey(keySecret, dateStamp, region, 'sagemaker');
  const signature = hmacHex(signingKey, stringToSign);
  const result = {
    'Content-Type': contentType,
    'X-Amz-Date': amzDate,
    'Authorization': `AWS4-HMAC-SHA256 Credential=${keyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
  };
  if (hasToken) result['X-Amz-Security-Token'] = sessionToken;
  return result;
}

function strToBytes(s) {
  const arr = new Uint8Array(s.length);
  for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i) & 0xff;
  return arr;
}

function sha256Hex(data) {
  const bytes = typeof data === 'string' ? strToBytes(data) : data;
  return toHex(sha256(bytes));
}

function hmacHex(key, data) {
  const k = typeof key === 'string' ? strToBytes(key) : key;
  return toHex(hmacSha256(k, strToBytes(data)));
}

function hmacRaw(key, data) {
  const k = typeof key === 'string' ? strToBytes(key) : key;
  return hmacSha256(k, strToBytes(data));
}

function getSigningKey(secret, date, region, service) {
  const kDate = hmacRaw('AWS4' + secret, date);
  const kRegion = hmacRaw(kDate, region);
  const kService = hmacRaw(kRegion, service);
  return hmacRaw(kService, 'aws4_request');
}

function toHex(arr) {
  return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}

function hmacSha256(key, data) {
  const blockSize = 64;
  let k = key.length > blockSize ? sha256(key) : key;
  const kPad = new Uint8Array(blockSize);
  kPad.set(k);
  const oKey = kPad.map(b => b ^ 0x5c);
  const iKey = kPad.map(b => b ^ 0x36);
  const inner = new Uint8Array(iKey.length + data.length);
  inner.set(iKey); inner.set(data, iKey.length);
  const innerHash = sha256(inner);
  const outer = new Uint8Array(oKey.length + innerHash.length);
  outer.set(oKey); outer.set(innerHash, oKey.length);
  return sha256(outer);
}

function sha256(data) {
  const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
  let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
  const msg = new Uint8Array(data);
  const bitLen = msg.length * 8;
  const padLen = msg.length % 64 < 56 ? 56 - msg.length % 64 : 120 - msg.length % 64;
  const padded = new Uint8Array(msg.length + padLen + 8);
  padded.set(msg);
  padded[msg.length] = 0x80;
  const dv = new DataView(padded.buffer);
  dv.setUint32(padded.length - 4, bitLen >>> 0, false);
  dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false);
  for (let i = 0; i < padded.length; i += 64) {
    const w = new Array(64);
    for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false);
    for (let j = 16; j < 64; j++) {
      const s0 = rr(w[j-15],7)^rr(w[j-15],18)^(w[j-15]>>>3);
      const s1 = rr(w[j-2],17)^rr(w[j-2],19)^(w[j-2]>>>10);
      w[j] = (w[j-16]+s0+w[j-7]+s1) >>> 0;
    }
    let [a,b,c,d,e,f,g,hh] = h;
    for (let j = 0; j < 64; j++) {
      const S1 = rr(e,6)^rr(e,11)^rr(e,25);
      const ch = (e&f)^(~e&g);
      const t1 = (hh+S1+ch+K[j]+w[j]) >>> 0;
      const S0 = rr(a,2)^rr(a,13)^rr(a,22);
      const maj = (a&b)^(a&c)^(b&c);
      const t2 = (S0+maj) >>> 0;
      hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
    }
    h = [h[0]+a,h[1]+b,h[2]+c,h[3]+d,h[4]+e,h[5]+f,h[6]+g,h[7]+hh].map(x=>x>>>0);
  }
  const out = new Uint8Array(32);
  const odv = new DataView(out.buffer);
  h.forEach((v,i) => odv.setUint32(i*4, v, false));
  return out;
}

function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
</script>
</body>
</html>

下記のコマンドを実行します。

python3 server.py

http://localhost:8080にアクセスします。

画像をアップロードし、[分類を実行する] をクリックします。cat または dog の判定結果が表示されます。

AWS上のWebページ版

以下の index.html をS3に配置し、ブラウザからアクセスします。

  • index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SageMaker Image Classifier</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #1a1a1a; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
  .container { width: 100%; max-width: 600px; }
  h1 { font-size: 20px; font-weight: 500; margin-bottom: 0.25rem; }
  .subtitle { font-size: 13px; color: #666; margin-bottom: 1.5rem; }
  .card { background: #fff; border: 1px solid #e5e5e5; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
  .card-title { font-size: 12px; font-weight: 500; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
  label { font-size: 13px; color: #555; display: block; margin-bottom: 5px; }
  input[type="text"], input[type="password"], select {
    width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 8px;
    font-size: 14px; font-family: inherit; color: #1a1a1a; background: #fafafa;
    outline: none; transition: border-color 0.15s;
  }
  input:focus, select:focus { border-color: #999; background: #fff; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  .field { margin-bottom: 12px; }
  .drop-zone {
    border: 2px dashed #ddd; border-radius: 8px; padding: 2rem;
    text-align: center; cursor: pointer; transition: all 0.15s; background: #fafafa;
  }
  .drop-zone:hover { border-color: #aaa; background: #f0f0f0; }
  .drop-zone.dragover { border-color: #555; background: #efefef; }
  .drop-zone p { font-size: 14px; color: #666; margin-bottom: 4px; }
  .drop-zone small { font-size: 12px; color: #aaa; }
  #preview { width: 100%; max-height: 240px; object-fit: contain; border-radius: 8px; display: none; margin-top: 12px; }
  #fileName { font-size: 12px; color: #888; margin-top: 6px; display: none; }
  .btn {
    width: 100%; padding: 11px; background: #1a1a1a; color: #fff;
    border: none; border-radius: 8px; font-size: 14px; font-family: inherit;
    cursor: pointer; transition: opacity 0.15s;
  }
  .btn:hover { opacity: 0.85; }
  .btn:disabled { opacity: 0.4; cursor: not-allowed; }
  .spinner { display: none; margin: 1rem auto 0; width: 22px; height: 22px; border: 2px solid #ddd; border-top-color: #333; border-radius: 50%; animation: spin 0.6s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .result { border-radius: 8px; padding: 1.25rem; margin-top: 1rem; display: none; }
  .result.success { background: #f0faf4; border: 1px solid #b8e5c8; }
  .result.error { background: #fdf0f0; border: 1px solid #f0b8b8; }
  .result-label { font-size: 24px; font-weight: 500; color: #1a1a1a; }
  .result-prob { font-size: 13px; color: #666; margin-top: 4px; }
  .bar-wrap { margin-top: 14px; }
  .bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
  .bar-name { min-width: 60px; color: #555; }
  .bar-track { flex: 1; background: #e8e8e8; border-radius: 4px; height: 8px; overflow: hidden; }
  .bar-fill { height: 100%; border-radius: 4px; background: #1a1a1a; transition: width 0.4s ease; }
  .bar-val { min-width: 40px; text-align: right; font-weight: 500; color: #1a1a1a; }
  .toggle-btn { font-size: 12px; color: #888; cursor: pointer; background: none; border: none; font-family: inherit; text-decoration: underline; margin-top: 10px; padding: 0; }
  pre { background: #f5f5f5; border-radius: 8px; padding: 1rem; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-top: 10px; display: none; max-height: 200px; overflow-y: auto; color: #444; }

</style>
</head>
<body>
<div class="container">
  <h1>SageMaker Image Classifier</h1>
  <p class="subtitle">SageMaker Canvas Endpoint を使った画像分類ツール</p>

  <div class="card">
    <div class="card-title">AWS 設定</div>
    <div class="row">
      <div class="field">
        <label>AWS Access Key ID</label>
        <input type="password" id="keyId" placeholder="AKIA..." />
      </div>
      <div class="field">
        <label>AWS Secret Access Key</label>
        <input type="password" id="keySecret" placeholder="secret..." />
      </div>
    </div>
    <div class="row">
      <div class="field">
        <label>Region</label>
        <select id="region">
          <option value="us-east-1">us-east-1</option>
          <option value="us-west-2" selected>us-west-2</option>
          <option value="eu-west-1">eu-west-1</option>
          <option value="ap-northeast-1">ap-northeast-1</option>
          <option value="ap-southeast-1">ap-southeast-1</option>
          <option value="ap-southeast-2">ap-southeast-2</option>
        </select>
      </div>
      <div class="field">
        <label>Endpoint Name</label>
        <input type="text" id="endpoint" placeholder="canvas-dog-cat" />
      </div>
    </div>
    <div class="field" style="margin-top:0;">
      <label>Session Token <span style="color:#aaa;font-size:12px;">(一時的な認証情報の場合のみ)</span></label>
      <input type="password" id="sessionToken" placeholder="FwoGZXIvYXdz..." />
    </div>
  </div>

  <div class="card">
    <div class="card-title">画像</div>
    <div class="drop-zone" id="dropZone">
      <p>画像をドラッグ&ドロップ、またはクリックして選択</p>
      <small>.jpg, .jpeg, .png</small>
      <input type="file" id="fileInput" accept="image/*" style="display:none;" />
    </div>
    <img id="preview" alt="preview" />
    <div id="fileName"></div>
  </div>

  <button class="btn" id="runBtn" onclick="runInference()">分類を実行する</button>
  <div class="spinner" id="spinner"></div>

  <div class="result" id="result">
    <div class="result-label" id="resultLabel"></div>
    <div class="result-prob" id="resultProb"></div>
    <div class="bar-wrap" id="barWrap"></div>
    <button class="toggle-btn" onclick="toggleCode()">生のレスポンスを表示</button>
    <pre id="rawCode"></pre>
  </div>
</div>

<script>
const drop = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
let selectedFile = null;

drop.addEventListener('click', () => fileInput.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dragover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('dragover'); if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });

function setFile(f) {
  selectedFile = f;
  const preview = document.getElementById('preview');
  preview.src = URL.createObjectURL(f);
  preview.style.display = 'block';
  const fn = document.getElementById('fileName');
  fn.textContent = f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)';
  fn.style.display = 'block';
  document.getElementById('result').style.display = 'none';
}

function toggleCode() {
  const c = document.getElementById('rawCode');
  c.style.display = c.style.display === 'none' ? 'block' : 'none';
}

async function runInference() {
  const keyId = document.getElementById('keyId').value.trim();
  const keySecret = document.getElementById('keySecret').value.trim();
  const region = document.getElementById('region').value;
  const endpointName = document.getElementById('endpoint').value.trim();

  if (!keyId || !keySecret || !endpointName) { alert('AWSキーとエンドポイント名を入力してください'); return; }
  if (!selectedFile) { alert('画像を選択してください'); return; }

  const btn = document.getElementById('runBtn');
  const spinner = document.getElementById('spinner');
  btn.disabled = true;
  spinner.style.display = 'block';
  document.getElementById('result').style.display = 'none';

  try {
    const arrayBuffer = await selectedFile.arrayBuffer();
    const body = new Uint8Array(arrayBuffer);
    const url = `https://runtime.sagemaker.${region}.amazonaws.com/endpoints/${endpointName}/invocations`;
    const now = new Date();
    const amzDate = now.toISOString().replace(/[:\-]|\.\d{3}/g, '').slice(0,15) + 'Z';
    const dateStamp = amzDate.slice(0, 8);
    const sessionToken = document.getElementById('sessionToken').value.trim();
  const headers = signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType: 'application/x-image' });
    const response = await fetch(url, { method: 'POST', headers, body });
    if (!response.ok) { const t = await response.text(); throw new Error(`HTTP ${response.status}: ${t}`); }
    const text = await response.text();
    let data;
    try {
      data = JSON.parse(text);
    } catch(e) {
      const parts = text.trim().split(',');
      data = {
        predicted_label: parts[0] ? parts[0].trim() : '—',
        probability: parts[1] ? parseFloat(parts[1].trim()) : null,
        labels: parts[2] ? JSON.parse(parts[2].trim()) : [],
        probabilities: parts[3] ? JSON.parse(parts[3].trim()) : [],
        _raw: text
      };
    }
    showResult(data);
  } catch (err) {
    const r = document.getElementById('result');
    r.className = 'result error';
    r.style.display = 'block';
    document.getElementById('resultLabel').textContent = 'エラー';
    document.getElementById('resultProb').textContent = err.message;
    document.getElementById('barWrap').innerHTML = '';
    document.getElementById('rawCode').textContent = err.stack || err.message;
    document.getElementById('rawCode').style.display = 'block';
  } finally {
    btn.disabled = false;
    spinner.style.display = 'none';
  }
}

function showResult(data) {
  const r = document.getElementById('result');
  r.className = 'result success';
  r.style.display = 'block';
  document.getElementById('rawCode').textContent = JSON.stringify(data, null, 2);
  document.getElementById('rawCode').style.display = 'none';
  const label = data.predicted_label || data.label || (data.labels && data.labels[0]) || '—';
  const prob = data.probability !== undefined ? (data.probability * 100).toFixed(1) : null;
  document.getElementById('resultLabel').textContent = label;
  document.getElementById('resultProb').textContent = prob ? `信頼度: ${prob}%` : '';
  const barWrap = document.getElementById('barWrap');
  barWrap.innerHTML = '';
  const labels = data.labels || [];
  const probs = data.probabilities || [];
  if (labels.length && probs.length) {
    labels.forEach((l, i) => {
      const p = (probs[i] * 100).toFixed(1);
      barWrap.innerHTML += `<div class="bar-row"><span class="bar-name">${l}</span><div class="bar-track"><div class="bar-fill" style="width:${p}%"></div></div><span class="bar-val">${p}%</span></div>`;
    });
  }
}

function signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType }) {
  const parsedUrl = new URL(url);
  const host = parsedUrl.host;
  const path = parsedUrl.pathname;
  const bodyHash = sha256Hex(body);
  const hasToken = sessionToken && sessionToken.length > 0;
  const canonicalHeaders = hasToken
    ? `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`
    : `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\n`;
  const signedHeaders = hasToken
    ? 'content-type;host;x-amz-date;x-amz-security-token'
    : 'content-type;host;x-amz-date';
  const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${bodyHash}`;
  const credentialScope = `${dateStamp}/${region}/sagemaker/aws4_request`;
  const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256Hex(strToBytes(canonicalRequest))}`;
  const signingKey = getSigningKey(keySecret, dateStamp, region, 'sagemaker');
  const signature = hmacHex(signingKey, stringToSign);
  const result = {
    'Content-Type': contentType,
    'X-Amz-Date': amzDate,
    'Authorization': `AWS4-HMAC-SHA256 Credential=${keyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
  };
  if (hasToken) result['X-Amz-Security-Token'] = sessionToken;
  return result;
}

function strToBytes(s) {
  const arr = new Uint8Array(s.length);
  for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i) & 0xff;
  return arr;
}

function sha256Hex(data) {
  const bytes = typeof data === 'string' ? strToBytes(data) : data;
  return toHex(sha256(bytes));
}

function hmacHex(key, data) {
  const k = typeof key === 'string' ? strToBytes(key) : key;
  return toHex(hmacSha256(k, strToBytes(data)));
}

function hmacRaw(key, data) {
  const k = typeof key === 'string' ? strToBytes(key) : key;
  return hmacSha256(k, strToBytes(data));
}

function getSigningKey(secret, date, region, service) {
  const kDate = hmacRaw('AWS4' + secret, date);
  const kRegion = hmacRaw(kDate, region);
  const kService = hmacRaw(kRegion, service);
  return hmacRaw(kService, 'aws4_request');
}

function toHex(arr) {
  return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}

function hmacSha256(key, data) {
  const blockSize = 64;
  let k = key.length > blockSize ? sha256(key) : key;
  const kPad = new Uint8Array(blockSize);
  kPad.set(k);
  const oKey = kPad.map(b => b ^ 0x5c);
  const iKey = kPad.map(b => b ^ 0x36);
  const inner = new Uint8Array(iKey.length + data.length);
  inner.set(iKey); inner.set(data, iKey.length);
  const innerHash = sha256(inner);
  const outer = new Uint8Array(oKey.length + innerHash.length);
  outer.set(oKey); outer.set(innerHash, oKey.length);
  return sha256(outer);
}

function sha256(data) {
  const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
  let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
  const msg = new Uint8Array(data);
  const bitLen = msg.length * 8;
  const padLen = msg.length % 64 < 56 ? 56 - msg.length % 64 : 120 - msg.length % 64;
  const padded = new Uint8Array(msg.length + padLen + 8);
  padded.set(msg);
  padded[msg.length] = 0x80;
  const dv = new DataView(padded.buffer);
  dv.setUint32(padded.length - 4, bitLen >>> 0, false);
  dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false);
  for (let i = 0; i < padded.length; i += 64) {
    const w = new Array(64);
    for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false);
    for (let j = 16; j < 64; j++) {
      const s0 = rr(w[j-15],7)^rr(w[j-15],18)^(w[j-15]>>>3);
      const s1 = rr(w[j-2],17)^rr(w[j-2],19)^(w[j-2]>>>10);
      w[j] = (w[j-16]+s0+w[j-7]+s1) >>> 0;
    }
    let [a,b,c,d,e,f,g,hh] = h;
    for (let j = 0; j < 64; j++) {
      const S1 = rr(e,6)^rr(e,11)^rr(e,25);
      const ch = (e&f)^(~e&g);
      const t1 = (hh+S1+ch+K[j]+w[j]) >>> 0;
      const S0 = rr(a,2)^rr(a,13)^rr(a,22);
      const maj = (a&b)^(a&c)^(b&c);
      const t2 = (S0+maj) >>> 0;
      hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
    }
    h = [h[0]+a,h[1]+b,h[2]+c,h[3]+d,h[4]+e,h[5]+f,h[6]+g,h[7]+hh].map(x=>x>>>0);
  }
  const out = new Uint8Array(32);
  const odv = new DataView(out.buffer);
  h.forEach((v,i) => odv.setUint32(i*4, v, false));
  return out;
}

function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
</script>
</body>
</html>

セキュリティの観点では、このような構成が適切だと考えています。

本記事では詳細な構築手順は割愛し、設計時に押さえておくべきポイントのみ解説します。

WAF Rate LimitとAPI Gateway Throttlingの違い

WAF Rate Limit API Gateway Throttling
場所 入口(サービスの手前) AWS内部(WAFの後)
カウント対象 特定IPからのリクエスト数 エンドポイント全体のリクエスト数
ブロック対象 不審なIPアドレス サービスの過負荷
レスポンス 403 Forbidden 429 Too Many Requests
目的 攻撃からの保護 過負荷からの保護

例:

  • WAF:「このIPが10秒間に1000リクエスト送信 → ブロック」
  • API Gateway:「異なるIPから同時に500リクエスト → 制限」

両者は補完関係にありますが、目的が異なりますので、両方とも設定することをおすすめします。WAFは誰がアクセスしているかを監視し、API Gatewayはどれだけアクセスされているかを監視します。

以上、御一読ありがとうございました。

本田 イーゴリ (記事一覧)

カスタマーサクセス部

・2024 Japan AWS Top Engineers (Security)
・AWS SAP, DOP, SCS, DBS, SAA, DVA, COA, CLF, ANS, AIF, MLS, MLA, DEA
・Azure AZ-900
・EC-Council CCSE

趣味:日本国内旅行(47都道府県制覇)・ドライブ・音楽