かつて JavaScript ユーザーがアップロードした不適切な画像を検出する という記事を書いたのですが、ここでは NSFW JS というJavaScriptライブラリを使ってエロ画像かどうかを判断しています。しかし不適切な画像はエロ画像だけではありません。
不適切な画像は性的な画像だけではない
性的なもの以外にも暴力/残酷な描写、自傷教唆やヘイトなどの違法性があるものも不適切な画像です。これらをアップロードされた画像のなかにこのようなものがないか簡易チェックできるものを今回は作ります。
使用するのは OpenAI です。ここからAPIキーを取得します。あとAPIを利用するときは料金がかかります。デフォルトの額は10$ですが、任意の額に変更できます。使用するモデルは omni-moderation というモデルで料金はかからないようですが、最初に入金しておかないとエラーが出て使うことができません。とりあえず最小の5ドルだけ入れておきました。

Create new secret keyからAPIキーを作成します。

urlをポストしたら判定結果を返す
この部分はPHPで作りたいのですが、PHPでOpenAI APIを操作するためのライブラリを取得できるように composer をインストールします。composer はここからダウンロード できます。
composer をインストールしたらコマンドプロンプトまたはターミナルに以下を入力し、openai-php/client と guzzlehttp/guzzle をインストールします。
|
1 |
composer require openai-php/client guzzlehttp/guzzle |
そのあと以下のようなコードを書きます。JSONでPOSTされたurl(画像ファイルのdataurlでも可)を取り出してその画像を判定し、その結果をJSONで返そうとしています。もし画像のurl以外のものが投げられた場合は’error’の文字列を返します。
api.php
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<?php if($_SERVER["REQUEST_METHOD"] == "POST"){ require __DIR__ . '/vendor/autoload.php'; $OPEN_AI_API_KEY = '取得したAPIキー'; try { // POSTされたJSON文字列を取り出し $json = file_get_contents("php://input"); // JSON文字列をobjectに変換(第2引数をtrueにしないとハマるので注意) $contents = json_decode($json, true); $url = $contents['url']; $client = OpenAI::client($OPEN_AI_API_KEY); $result = $client->moderations()->create([ 'model' => 'omni-moderation-latest', 'input' => [ [ 'type' => 'image_url', 'image_url' => [ 'url' => $url, ], ], ], ]); //配列をJSON形式に変換 $json = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); echo $json; } catch (Exception $e) { echo 'error'; } } else echo '不正なアクセスです'; |
これで以下のようなJSONが返されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
{ "id": "modr-970d409ef3bef3b70c73d8232df86e7d", "model": "omni-moderation-latest", "results": [ { "flagged": true, "categories": { "sexual": false, "sexual/minors": false, "harassment": false, "harassment/threatening": false, "hate": false, "hate/threatening": false, "illicit": false, "illicit/violent": false, "self-harm": false, "self-harm/intent": false, "self-harm/instructions": false, "violence": true, "violence/graphic": false }, "category_scores": { "sexual": 2.34135824776394e-7, "sexual/minors": 1.6346470245419304e-7, "harassment": 0.0011643905680426018, "harassment/threatening": 0.0022121340080906377, "hate": 3.1999824407395835e-7, "hate/threatening": 2.4923252458203563e-7, "illicit": 0.0005227032493135171, "illicit/violent": 3.682979260160596e-7, "self-harm": 0.0011175734280627694, "self-harm/intent": 0.0006264858507989037, "self-harm/instructions": 7.368592981140821e-8, "violence": 0.8599265510337075, "violence/graphic": 0.37701736389561064 }, "category_applied_input_types": { "sexual": [ "image" ], "sexual/minors": [], "harassment": [], "harassment/threatening": [], "hate": [], "hate/threatening": [], "illicit": [], "illicit/violent": [], "self-harm": [ "image" ], "self-harm/intent": [ "image" ], "self-harm/instructions": [ "image" ], "violence": [ "image" ], "violence/graphic": [ "image" ] } } ] } |
判定対象の選択と結果を表示するページをつくる
次に判定対象の選択と結果を表示するページをつくります。
index.htmlには判定対象になる画像のurlを入力するinputとファイルを選択するinputを配置します。[チェック]ボタンを押下すると先述のapi.phpにurlがPOSTされます。
index.html
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>不適切な写真かどうか判定する</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <h1>不適切な写真かどうか判定する</h1> <p>画像が性的であったりヘイトや暴力的であるか、自傷行為や残酷な描写があるかどうかを判定します。</p> <p>各要素は 0.0 ~ 1.0(0%~100%)で評価され、ひとつでも50%を超えるものは不可と判定されます。</p> <p><input type="text" id = 'image-url' placeholder="画像のurlを指定してください"></p> <p><input type="file" id = 'image-file'></p> <p><button id = 'check'>チェック</button></p> <div id = "result"></div> </div> <script src = "index.js"></script> </body> </html> |
style.css
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
body { background-color: white; } #container { max-width: 600px; margin: 0 auto 0 auto; } #image-url { width: 100%; } #check { width: 160px; height: 50px; margin-top: 10px; margin-bottom: 10px; } td { border: 1px solid black; padding: 2px 20px 2px 20px; } pre { height: 250px; overflow: scroll; } .red { font-weight: bold; color: red; } .orange { font-weight: bold; color: orange; } .green { font-weight: bold; color: green; } .gray { color: lightgray; } .large { font-size: x-large; } |
グローバル変数を示します。
index.js
|
1 2 3 4 |
const $imageUrl = document.getElementById('image-url'); const $imageFile = document.getElementById('image-file'); const $check = document.getElementById('check'); const $result = document.getElementById('result'); |
ページが読み込まれたときの処理を示します。ファイルが選択されたらdataurlを取得してその画像を表示し、[チェック]ボタンが押下されたら結果を取得して画像と判定結果を表示させます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
window.addEventListener('load', () => { $check.addEventListener('click', async() => { if($imageUrl.value != '') check($imageUrl.value); // 後述 else { const dataUrl = await getDataUrlFromFileInput(); // 後述 check(dataUrl); } }); $imageFile.addEventListener('click', () => { clearCheckTarget(); // 後述 }); $imageFile.addEventListener('change', async() => { const dataUrl = await getDataUrlFromFileInput(); $result.innerHTML = `<p><img src="${dataUrl}" width="200" /></p>`; console.log(dataUrl); }); }); |
選択されているファイルのdataurlを取得する処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
async function getDataUrlFromFileInput(){ return await new Promise(resolve => { try { const file = $imageFile.files[0]; if (file){ const reader = new FileReader(); reader.onload = ev => resolve(ev.target.result); reader.readAsDataURL(file); } else resolve(''); } catch(e){ resolve(''); } }); } |
選択状態を解除する処理を示します。
|
1 2 3 4 5 |
function clearCheckTarget(){ $imageFile.value = null; $imageUrl.value = ''; $result.innerHTML = ''; } |
api.phpにurlをPOSTして結果をうけとる処理を示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function check(url){ // JSON形式でPOST fetch('./api.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url, }) }).then(async(response) => { const json = await response.text(); if(json != '' && json != 'error') showResult(url, json); else $result.innerHTML = 'エラー'; }); } |
受け取った結果をページに表示する処理を示します。
各要素が英語だとわかりにくいので日本語に置き換えています。各要素の該当度が0~1.0の値で返されるのでパーセンテージに変換して四捨五入した値も同時に表示させています。該当しない要素(25%以下)は目立たない灰色、怪しい(50%以下)はオレンジ色、不可(50%超)は赤で表示させています。
また返されたJSON全体も表示させています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
function showResult(url, json){ const obj = JSON.parse(json); const keys = Object.keys(obj['results'][0]['categories']); let max = 0; let table = ''; table += '<table>' for(let i=0; i<keys.length; i++){ const key = keys[i]; const score = obj['results'][0]['categories'][key]['score']; let color = 'gray'; let judge = ''; if(score > 0.5){ judge = '×'; color = 'red'; } else if(score > 0.25){ judge = '△'; color = 'orange'; } table += `<tr><td class ="${color}">${judge(score)}</td><td class ="${color}">${key}</td><td>${Math.round(score * 100)} %</td><td>${score}</td></tr>`; if(max < score) max = score; } table += '</table>'; table = table.replace('harassment/threatening','嫌がらせ/脅迫') table = table.replace('hate/threatening','ヘイト/脅迫') table = table.replace('illicit/violent','違法/暴力') table = table.replace('self-harm/instructions','自傷行為/指示') table = table.replace('self-harm/intent','自傷行為/意図') table = table.replace('sexual/minors','性的/未成年者') table = table.replace('violence/graphic','暴力/残酷な描写') table = table.replace('harassment','嫌がらせ') table = table.replace('hate','ヘイト') table = table.replace('illicit','違法') table = table.replace('self-harm','自傷') table = table.replace('sexual','性的') table = table.replace('violence','暴力') let html = ''; if(max > 0.5) html += `<p class = "red large">判定結果:不可</p>`; else if(max > 0.25) html += `<p class = "orange large">判定結果:微妙</p>`; else html += `<p class = "green large">判定結果:OK</p>`; html += `<p><img src="${url}" width="200" /></p>`; html += `<p>${table}</p>`; html += `<p><pre>${json}</pre></p>`; $result.innerHTML = html; } |
