Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Misskey 本体の `AiService.detectSensitive`(NSFW 推論)を切り出した

### `POST /v1/detect-images`

複数の正規化済み画像を一括推論する。
複数の正規化済み画像を一括推論する。詳しい入出力仕様、型、エラー、実装例は
[docs/api-detect-images.md](docs/api-detect-images.md) を参照。

| | |
| --- | --- |
Expand All @@ -25,26 +26,35 @@ Misskey 本体の `AiService.detectSensitive`(NSFW 推論)を切り出した
"success": true,
"result": {
"results": [
{ "success": true, "predictions": [{ "className": "Neutral", "probability": 0.98 }, "..."] },
{ "success": false, "error": { "code": "IMAGE_DECODE_FAILED", "message": "..." } }
{
"success": true,
"predictions": [{ "className": "Neutral", "probability": 0.98 }]
},
{
"success": false,
"error": { "code": "IMAGE_DECODE_FAILED", "message": "..." }
}
]
}
}
```

`results` の順序はリクエストパーツの順序と一致する。1 枚でも失敗しても全体は 200 を返す(部分成功)。
各パーツのサイズ上限は `maxBinarySize` を個別適用。
成功パートの `predictions` は推論モデルの生出力で、サービス側ではしきい値判定をしない。
各パーツのサイズ上限は `maxBinarySize` を個別適用する。

失敗(4xx)はリクエスト全体に問題がある場合のみ:
全体失敗(4xx/5xx)はリクエスト全体に問題がある場合のみ:

| code | status | 意味 |
| --- | --- | --- |
| `AUTHENTICATION_REQUIRED` | 401 | トークン欠落 / 不一致 |
| `INVALID_REQUEST` | 400 | パーツなし / multipart パース失敗 |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Content-Type が `multipart/form-data` 以外 |
| `REQUEST_TOO_LARGE` | 413 | `Content-Length` が `maxBodySize` 超過、パーツ数が `maxParts` 超過、または画像 dimensions が上限超過 |
| `REQUEST_TOO_LARGE` | 413 | リクエストボディが `maxBodySize` 超過、またはパーツ数が `maxParts` 超過 |
| `DETECTION_FAILED` | 500 | リクエスト処理パイプラインの想定外エラー |

パーツ個別の失敗(`IMAGE_DECODE_FAILED`、`REQUEST_TOO_LARGE` 等)は `results[i].error.code` に入り、HTTP は 200。
パーツ個別の失敗(画像 dimensions 上限超過による `REQUEST_TOO_LARGE`、`IMAGE_DECODE_FAILED` 等)は
`results[i].error.code` に入り、HTTP は 200。

動作確認:

Expand Down
271 changes: 271 additions & 0 deletions docs/api-detect-images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# `POST /v1/detect-images`

複数の正規化済み画像を `multipart/form-data` で受け取り、各画像について推論モデルの生の予測値を返す。

この API は画像のリサイズ、回転、透過塗りつぶし、動画フレーム抽出、しきい値判定を行わない。呼び出し元は事前に Misskey 本体と同じ正規化を済ませ、返ってきた `predictions` を使って `sensitive` / `porn` などの判定を行う。

## リクエスト

```http
POST /v1/detect-images
Content-Type: multipart/form-data; boundary=...
Authorization: Bearer <token>
```

`Authorization` は `config.apiKey` を設定している場合のみ必須。`apiKey` 未設定時は省略できる。

### ヘッダー

| Header | 型 | 必須 | 説明 |
| --- | --- | --- | --- |
| `Content-Type` | `multipart/form-data` | はい | JSON や単一画像バイナリではなく、必ず multipart で送る。`boundary` は HTTP クライアントに生成させる。 |
| `Authorization` | `Bearer <string>` | 条件付き | `config.apiKey` 設定時のみ必要。欠落または不一致の場合は 401。 |
| `X-Request-Id` | `string` | いいえ | ログ相関用。英数字、`-`、`_`、`.` の有効な値ならレスポンスにも同じ値が返る。不正または未指定ならサーバーが生成する。 |

### multipart body

各パートに画像ファイルを入れる。フィールド名は任意で、レスポンスの `results` はリクエストパートの順序に対応する。

| 項目 | 型 | 必須 | 説明 |
| --- | --- | --- | --- |
| field name | `string` | はい | 任意。例: `image0`、`image1`。同じ名前を繰り返してもよいが、呼び出し元では順序で対応付ける。 |
| filename | `string` | 任意 | API の判定には使わない。 |
| part `Content-Type` | `image/png` \| `image/jpeg` \| `image/gif` \| `image/bmp` | はい | これ以外はそのパートだけ `UNSUPPORTED_MEDIA_TYPE` になる。 |
| part body | binary | はい | 正規化済み画像バイト。空ボディはそのパートだけ `INVALID_REQUEST` になる。 |

### 入力制限

設定値は [config.example.mjs](../config.example.mjs) を参照。

| 制限 | 対象 | 超過時 |
| --- | --- | --- |
| `maxBodySize` | リクエスト全体 | HTTP 413。`success: false` の全体エラー。 |
| `maxParts` | multipart パート数 | HTTP 413。`success: false` の全体エラー。 |
| `maxBinarySize` | 各画像パートのバイト数 | HTTP 200 のまま、該当 `results[i]` が `REQUEST_TOO_LARGE`。 |
| `maxImageWidth` / `maxImageHeight` / `maxImagePixels` | デコード後の画像 dimensions | HTTP 200 のまま、該当 `results[i]` が `REQUEST_TOO_LARGE`。 |

既定値では 299x299 までの正規化済み画像を想定している。

## レスポンス

このエンドポイントのレスポンス JSON は、成功または部分成功時と、全体エラー時で形が異なる。

### 成功または部分成功: HTTP 200

パート単位の失敗があっても、multipart リクエスト全体が処理できた場合は HTTP 200 を返す。この場合だけ `result.results` が存在し、`result.results[i]` が i 番目のリクエストパートに対応する。

```ts
type DetectImagesResponse = DetectImagesSuccessResponse | ErrorResponse;

type DetectImagesSuccessResponse = {
success: true;
result: {
results: BatchItemResult[];
};
};

type BatchItemResult =
| {
success: true;
predictions: Prediction[];
}
| {
success: false;
error: DetectError;
};

type Prediction = {
className: string;
probability: number;
};

type DetectError = {
code: DetectErrorCode;
message: string;
};

type DetectErrorCode =
| 'AUTHENTICATION_REQUIRED'
| 'INVALID_REQUEST'
| 'UNSUPPORTED_MEDIA_TYPE'
| 'REQUEST_TOO_LARGE'
| 'IMAGE_DECODE_FAILED'
| 'MODEL_UNAVAILABLE'
| 'DETECTION_FAILED';
```

#### 成功パート

| Field | 型 | 説明 |
| --- | --- | --- |
| `success` | `true` | パート単位の推論に成功したことを表す。 |
| `predictions` | `Prediction[]` | 推論モデルの生出力。サービス側ではしきい値判定やクラスの集約をしない。 |
| `predictions[].className` | `string` | クラス名。現在同梱しているモデルでは `Drawing`、`Hentai`、`Neutral`、`Porn`、`Sexy`。API 型としては将来のモデル差し替えに備えて `string`。 |
| `predictions[].probability` | `number` | 確率。通常は `0` 以上 `1` 以下。 |

#### 失敗パート

| Field | 型 | 説明 |
| --- | --- | --- |
| `success` | `false` | そのパートだけ処理に失敗したことを表す。 |
| `error.code` | `DetectErrorCode` | 呼び出し元が分岐に使う安定したコード。 |
| `error.message` | `string` | 人間向けの診断テキスト。内容は API 契約として固定しない。 |

パート単位で返り得る主な `error.code`:

| code | 意味 |
| --- | --- |
| `INVALID_REQUEST` | パートがファイルではない、または空ボディ。 |
| `UNSUPPORTED_MEDIA_TYPE` | パートの `Content-Type` が未対応。 |
| `REQUEST_TOO_LARGE` | パートサイズまたは画像 dimensions が上限超過。 |
| `IMAGE_DECODE_FAILED` | 画像バイトを読み取れない、または画像 dimensions を読めない。 |
| `MODEL_UNAVAILABLE` | モデルが利用できない。CPU 非対応、モデルロード失敗など。 |
| `DETECTION_FAILED` | 推論処理の失敗、タイムアウト、想定外エラー。 |

### 全体エラー: HTTP 4xx / 5xx

リクエスト全体を処理できない場合は `success: false` を返す。この場合はトップレベルに `error` があり、`result` はない。

```ts
type ErrorResponse = {
success: false;
error: {
code: DetectErrorCode;
message: string;
};
};
```

| status | code | 主な原因 |
| --- | --- | --- |
| 400 | `INVALID_REQUEST` | multipart パース失敗、パートなし。 |
| 401 | `AUTHENTICATION_REQUIRED` | `config.apiKey` 設定時に Bearer token が欠落または不一致。 |
| 413 | `REQUEST_TOO_LARGE` | リクエスト全体の `maxBodySize` 超過、または `maxParts` 超過。 |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | リクエストの `Content-Type` が `multipart/form-data` ではない。 |
| 500 | `DETECTION_FAILED` | リクエスト処理パイプラインの想定外エラー。 |

このエンドポイントの通常処理では、画像デコード失敗の `IMAGE_DECODE_FAILED` とモデル利用不可の `MODEL_UNAVAILABLE` は全体エラーではなく、HTTP 200 の `results[i].error.code` に入る。

`bodyLimit` は Content-Type 検査より前に実行されるため、上限を超える非 multipart ボディは 415 ではなく 413 になる。

## 例

### curl

```sh
curl -X POST 'http://127.0.0.1:3000/v1/detect-images' \
-H 'Authorization: Bearer <token>' \
-F 'image0=@frame1.png;type=image/png' \
-F 'image1=@frame2.png;type=image/png'
```

`-F` を使う場合、`Content-Type: multipart/form-data; boundary=...` は curl が自動生成するため、手動で指定しない。

### Node.js / fetch

```ts
import { readFile } from 'node:fs/promises';

const form = new FormData();

form.append(
'image0',
new File([await readFile('frame1.png')], 'frame1.png', { type: 'image/png' }),
);
form.append(
'image1',
new File([await readFile('frame2.png')], 'frame2.png', { type: 'image/png' }),
);

const res = await fetch('http://127.0.0.1:3000/v1/detect-images', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SENSITIVE_DETECTOR_API_KEY}`,
},
body: form,
});

const body = await res.json();

if (!body.success) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}

for (const [index, result] of body.result.results.entries()) {
if (!result.success) {
console.warn(`image ${index} failed: ${result.error.code}`);
continue;
}

const porn = result.predictions.find((p) => p.className === 'Porn')?.probability ?? 0;
const hentai = result.predictions.find((p) => p.className === 'Hentai')?.probability ?? 0;
console.log({ index, porn, hentai });
}
```

### 成功レスポンス

```json
{
"success": true,
"result": {
"results": [
{
"success": true,
"predictions": [
{ "className": "Neutral", "probability": 0.95 },
{ "className": "Drawing", "probability": 0.03 },
{ "className": "Sexy", "probability": 0.01 },
{ "className": "Hentai", "probability": 0.005 },
{ "className": "Porn", "probability": 0.005 }
]
}
]
}
}
```

### 部分成功レスポンス

2 枚目だけ壊れた画像だった場合の例:

```json
{
"success": true,
"result": {
"results": [
{
"success": true,
"predictions": [
{ "className": "Neutral", "probability": 0.95 },
{ "className": "Drawing", "probability": 0.03 },
{ "className": "Sexy", "probability": 0.01 },
{ "className": "Hentai", "probability": 0.005 },
{ "className": "Porn", "probability": 0.005 }
]
},
{
"success": false,
"error": {
"code": "IMAGE_DECODE_FAILED",
"message": "failed to read image dimensions"
}
}
]
}
}
```

### 全体エラーレスポンス

Bearer token が必要な設定で未指定だった場合の例:

```json
{
"success": false,
"error": {
"code": "AUTHENTICATION_REQUIRED",
"message": "missing or invalid bearer token"
}
}
```