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
10 changes: 5 additions & 5 deletions .claude/skills/scope-guard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ user-invocable: false

# scope-guard — sensitive-detector のスコープ判断と不変条件

このサービスは Misskey の `AiService.detectSensitive`(nsfwjs + @tensorflow/tfjs-node 推論)を
このサービスは Misskey の `AiService.detectSensitive`(ONNX Runtime 推論)を
HTTP サイドカーに切り出したもの。切り出して嬉しいのは **ネイティブ ML スタック
tfjs-node / libtensorflow、モデルのメモリ常駐、x64 avx2+fma 制約、glibc 依存)の隔離** だけ。
onnxruntime-node、モデルのメモリ常駐、glibc 依存)の隔離** だけ。

## 機能を足してよいかの判断軸(エンドポイント数は不変条件ではない)

Expand All @@ -25,11 +25,11 @@ HTTP サイドカーに切り出したもの。切り出して嬉しいのは **

- 返すのは **推論の生予測値だけ**。`sensitive` / `porn` のしきい値判定・フレーム集約・
per-user ポリシーは **Misskey 本体(`FileInfoService.ts` の `judgePrediction`)に残す**。ここには入れない。
- 受け取るのは **正規化済み画像バイト**。画像正規化(sharp の resize/rotate/flatten/PNG 変換)と
動画・APNG のフレーム抽出(ffmpeg)は本体に残す。**v1 では sharp / fluent-ffmpeg / ffmpeg を依存に足さない。**
- 受け取るのは **299×299 の正規化済み PNG**。画像正規化(sharp の resize/rotate/flatten/PNG 変換)と
動画・APNG のフレーム抽出(ffmpeg)は本体に残す。**sharp / fluent-ffmpeg / ffmpeg を依存に足さない。**
- **物理パス入力・mediaDir・ディレクトリトラバーサル防御・JSON 入力スキーマ** は持ち込まない
(入力は画像バイナリ本体)。
- 予測値の形は nsfwjs の生出力(全クラス・確率降順)。`predictions[].className` は本体が
- 予測値の形は ONNX モデルの生出力(全クラス・確率降順)。`predictions[].className` は本体が
`find(x => x.className === 'Sexy')` で引ける契約([packages/core/src/types.ts](../../../packages/core/src/types.ts) の `Prediction`)。
- **HTTP 応答はバッチ形**。成功は `{ success:true, result:{ results: BatchItemResult[] } }`
(パーツ順保持)。`BatchItemResult` はパーツ毎に `{ success:true, predictions }` か
Expand Down
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ SENSITIVE_DETECTOR_API_KEY=
# ホスト側で公開するポート(コンテナ内は固定で 3009)。
# Misskey 本体が 3000 を使うので、ホスト側は 3009 にずらしておく。
HOST_PORT=3009

# ONNX Runtime の 1 推論あたりのスレッド数。デフォルト 1。
# 0 = ONNX Runtime 既定(全コア使用)。
# Misskey 本体と同居する場合は 1〜2 が推奨。専用マシンなら 0 で全コアを活用できる。
# SENSITIVE_DETECTOR_THREADS=1
13 changes: 3 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# syntax=docker/dockerfile:1

# @tensorflow/tfjs-node は glibc と TensorFlow C binary に結びつくネイティブアドオン(tfjs_binding.node)に依存する。
# 現行の @tensorflow/tfjs-node@4.22.0 は配布バイナリ設定が N-API v8 までで、Node 24 以上のビルド/実行互換性は未保証。
# nsfwjs も TensorFlow.js backend 経由でこの制約を受けるため、Debian slim(glibc 2.36) + Node 22 系に固定する。
# onnxruntime-node は glibc に依存するネイティブバイナリを含む。Debian slim + Node 22 系をベースにする。
FROM node:22-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
Expand All @@ -14,22 +12,17 @@ WORKDIR /app

# ---- build stage ----
FROM base AS build
# tfjs-node のネイティブアドオン(node-gyp)ビルドに必要なツール。
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# 依存解決のレイヤキャッシュ用に manifest を先に置く。
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY packages/core/package.json ./packages/core/
COPY apps/server/package.json ./apps/server/

# onlyBuiltDependencies(@tensorflow/tfjs-node) のネイティブビルドを承認しつつ install。
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

COPY . .
RUN pnpm run build
# server とその prod 依存(core / tfjs-node の .node 含む)だけを /app/deploy へ取り出す。
# server とその prod 依存(core / onnxruntime-node 含む)だけを /app/deploy へ取り出す。
# pnpm v10 では injected workspace でない deploy に --legacy が必要(symlink リンク方式を維持)。
RUN pnpm deploy --legacy --filter=@misskey-sensitive-detector/server --prod /app/deploy

Expand All @@ -38,7 +31,7 @@ FROM base AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/deploy ./
# nsfwjs モデルをイメージに同梱する(/models へ焼き込み)。config から modelDir: '/models' で参照する。
# ONNX モデルをイメージに同梱する(/models へ焼き込み)。config から modelDir: '/models' で参照する。
# モデルは公開重みで秘密ではないため同梱して構わない。差し替えたい場合は実行時に /models を上書きマウントする。
COPY nsfw-model/ /models/
COPY scripts/healthcheck.mjs /scripts/healthcheck.mjs
Expand Down
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# sensitive-detector

Misskey 本体の `AiService.detectSensitive`([nsfwjs](https://github.com/infinitered/nsfwjs) + `@tensorflow/tfjs-node` による NSFW 推論)を切り出した、独立 HTTP サイドカーサービス。
Misskey 本体の `AiService.detectSensitive`(NSFW 推論)を切り出した、独立 HTTP サイドカーサービス。推論エンジンには [ONNX Runtime](https://onnxruntime.ai/) を使用する

切り出して嬉しいのは **ネイティブ ML スタック(tfjs-node / libtensorflow、モデルのメモリ常駐、x64 の avx2+fma CPU 制約、glibc 依存)の隔離** であり、本サービスはそこだけに徹する。画像の正規化(リサイズ・回転・透過塗りつぶし)や動画フレーム抽出は **Misskey 本体に残し**、本サービスは **正規化済み画像バイトを受け取り nsfwjs の生の予測値をそのまま返す**。しきい値判定(`sensitive` / `porn`)も本体側に残す。
切り出して嬉しいのは **ネイティブ ML スタック(ONNX Runtime、モデルのメモリ常駐)の隔離** であり、本サービスはそこだけに徹する。画像の正規化(リサイズ・回転・透過塗りつぶし)や動画フレーム抽出は **Misskey 本体に残し**、本サービスは **299×299 に正規化済みの PNG を受け取り、生の予測値をそのまま返す**。しきい値判定(`sensitive` / `porn`)も本体側に残す。

背景: [misskey-dev/misskey#16804](https://github.com/misskey-dev/misskey/issues/16804)

Expand All @@ -16,7 +16,7 @@ Misskey 本体の `AiService.detectSensitive`([nsfwjs](https://github.com/infi
| --- | --- |
| Content-Type | `multipart/form-data` |
| Authorization | `Bearer <token>`(`config.apiKey` 設定時のみ要求) |
| Body | 各パートに画像バイナリ(フィールド名は任意、順序を保持)。パートの Content-Type は `image/png` / `image/jpeg` / `image/gif` / `image/bmp` |
| Body | 各パートに画像バイナリ(フィールド名は任意、順序を保持)。パートの Content-Type は `image/png` |

成功(200):

Expand Down Expand Up @@ -61,32 +61,31 @@ curl -X POST localhost:3000/v1/detect-images \
- `port` / `socket`: どちらか一方必須。
- `host`: `port` 待ち受け時の bind ホスト。既定 `127.0.0.1`(ローカルのみ)。外部公開する場合のみ `0.0.0.0` を明示する(Docker は `config.docker.mjs` で `0.0.0.0` 指定済み)。
- **移行メモ**: 既定が `0.0.0.0` から `127.0.0.1` に変わった。`port` 待ち受けで別ホスト/別コンテナから到達させていた既存利用者は、`host: '0.0.0.0'`(や特定の bind アドレス)を明示する必要がある。Docker 利用は変更不要。
- `modelDir`: 必須。nsfwjs モデルディレクトリ。
- `modelDir`: 必須。ONNX モデルディレクトリ(`nsfw_model.onnx` を含むパス)
- `apiKey`: 静的 Bearer token。`port` で TCP 待ち受けする場合は `apiKey` が必須。
- `allowUnauthenticatedTcp`: `port` で `apiKey` なしを許すためのフラグ。開発用・外部から到達不能な環境以外では使わない。
- `maxBinarySize`(1MB) / `maxImageWidth`(299) / `maxImageHeight`(299) / `maxImagePixels`(89401) / `maxParts`(10) / `maxBodySize`(12MB) / `maxConcurrentJobs`(2) / `requestTimeoutMs`(60000)。

## 開発

```sh
pnpm install # tfjs-node のネイティブビルドを含む
pnpm install
pnpm run build # tsdown(高速 JS 出力。依存は external)
pnpm run typecheck # core を build してから各 package を型チェック
pnpm run lint # biome
pnpm run test:unit # 純粋ロジック
pnpm run test:integration # 実モデルロード+実 classify(CPU/モデルが無ければ skip)
pnpm run test:integration # 実モデルロード+実 classify(モデルが無ければ skip)

# ローカル起動(config に modelDir を設定)
pnpm --filter @misskey-sensitive-detector/server dev -- --config ./config.dev.mjs
```

統合テストは `SENSITIVE_DETECTOR_TEST_MODEL_DIR` でモデルディレクトリを上書きできる
(既定: `/home/osamu/develop/misskey/packages/backend/nsfw-model`)。CPU が avx2+fma 非対応、
またはモデルが無い環境では自動的に skip する。
(既定: `/home/osamu/develop/misskey/packages/backend/nsfw-model`)。モデルが無い環境では自動的に skip する。

## 構成

- `packages/core` (`@misskey-sensitive-detector/core`): 推論エンジン。重いネイティブ実依存(tfjs-node / nsfwjs)はここに集約。
- `packages/core` (`@misskey-sensitive-detector/core`): 推論エンジン。重いネイティブ実依存(onnxruntime-node)はここに集約。
- `apps/server` (`@misskey-sensitive-detector/server`): 薄い HTTP 層(Hono + pino)。

## Docker
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ export async function bootstrap(argv: readonly string[], basedir = process.cwd()
warnConfigCoherence(raw, config, logger);

// 起動時に 1 回だけモデルをロードする(失敗しても常駐し続ける)。
const classifier = await createClassifier(config.modelDir, { logger });
const intraOpNumThreads = parseInt(process.env.SENSITIVE_DETECTOR_THREADS ?? '1', 10) || undefined;
const classifier = await createClassifier(config.modelDir, { logger, intraOpNumThreads });
if (!classifier.available) {
logger.warn('model is unavailable; every /v1/detect-image request will return MODEL_UNAVAILABLE (503)');
}
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/routes/detect-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mapWithConcurrency } from '../lib/concurrency.js';
import { exceedsImageLimits, readImageDimensions } from '../lib/image-metadata.js';
import type { AppDeps, AppEnv } from '../types.js';

const ACCEPTED_CONTENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/bmp']);
const ACCEPTED_CONTENT_TYPES = new Set(['image/png']);

function parseContentType(value: string): string {
return value.split(';')[0]?.trim().toLowerCase() ?? '';
Expand Down
7 changes: 4 additions & 3 deletions apps/server/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,14 @@ describe('POST /v1/detect-images', () => {
expect(results[2]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE');
});

it('accepts image/jpeg, image/gif, image/bmp', async () => {
it('rejects image/jpeg, image/gif, image/bmp with UNSUPPORTED_MEDIA_TYPE', async () => {
const app = buildTestApp();
for (const contentType of ['image/jpeg', 'image/gif', 'image/bmp']) {
const res = await postImages(app, [{ data: png, contentType }]);
expect(res.status).toBe(200);
const body = (await res.json()) as { result: { results: { success: boolean }[] } };
expect(body.result.results[0]?.success).toBe(true);
const body = (await res.json()) as { result: { results: { success: boolean; error?: { code: string } }[] } };
expect(body.result.results[0]?.success).toBe(false);
expect(body.result.results[0]?.error?.code).toBe('UNSUPPORTED_MEDIA_TYPE');
}
});

Expand Down
35 changes: 35 additions & 0 deletions nsfw-model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,41 @@ Misskey commit from which they were copied was not recorded at the time of impor
> [upstream LICENSE.md](https://github.com/GantMan/nsfw_model/blob/master/LICENSE.md)
> for details.

## ONNX Conversion

`nsfw_model.onnx` is converted from the TensorFlow.js model files in this directory.
The conversion was verified on an M2 MacBook Air (macOS, ARM64).

### Prerequisites

- Docker

### Steps

Run the following command from the `nsfw-model/` directory:

```sh
docker run --rm -v $(pwd):/model python:3.11-slim bash -c "pip install tensorflow==2.15.1 tensorflowjs==4.22.0 tf2onnx onnx==1.16.2 setuptools==75.8.2 && python3 -c \"
import types, sys
# Mock tensorflow_decision_forests to avoid inference.so error
mod = types.ModuleType('tensorflow_decision_forests')
mod.keras = types.ModuleType('tensorflow_decision_forests.keras')
sys.modules['tensorflow_decision_forests'] = mod
sys.modules['tensorflow_decision_forests.keras'] = mod.keras

import tensorflowjs as tfjs
import tensorflow as tf
import tf2onnx
model = tfjs.converters.load_keras_model('/model/model.json')
input_spec = (tf.TensorSpec((1, 299, 299, 3), tf.float32, name='input'),)
tf2onnx.convert.from_keras(model, input_signature=input_spec, output_path='/model/nsfw_model.onnx')
print('ONNX model saved successfully')
\""
```

This produces `nsfw_model.onnx` with input shape `[1, 299, 299, 3]` (float32) and
output shape `[1, 5]` (softmax probabilities for Drawing, Hentai, Neutral, Porn, Sexy).

## License Notices

### GantMan/nsfw_model
Expand Down
Binary file removed nsfw-model/group1-shard1of6
Binary file not shown.
2 changes: 0 additions & 2 deletions nsfw-model/group1-shard2of6

This file was deleted.

3 changes: 0 additions & 3 deletions nsfw-model/group1-shard3of6

This file was deleted.

3 changes: 0 additions & 3 deletions nsfw-model/group1-shard4of6

This file was deleted.

18 changes: 0 additions & 18 deletions nsfw-model/group1-shard5of6

This file was deleted.

3 changes: 0 additions & 3 deletions nsfw-model/group1-shard6of6

This file was deleted.

1 change: 0 additions & 1 deletion nsfw-model/model.json

This file was deleted.

Binary file added nsfw-model/nsfw_model.onnx
Binary file not shown.
7 changes: 3 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tensorflow/tfjs-node": "catalog:",
"nsfwjs": "catalog:",
"systeminformation": "catalog:"
"onnxruntime-node": "catalog:",
"pngjs": "catalog:"
},
"devDependencies": {
"@tensorflow/tfjs": "catalog:"
"@types/pngjs": "catalog:"
}
}
Loading