こんにちは、こちらの記事でYouTube広告をスキップする仕組みを作りましたが、技術的に仕方なしでNode.jsで判定をしました。
その時に作ったサーバープログラムを紹介します。
1. Node.jsでTeachable Machineをシンプルに使う
まずは以下の記事を参考に特定の画像で判定をする処理です。
npm init -y && npm pkg set type=module
app.mjs
import { JSDOM } from 'jsdom';
import { loadImage } from 'canvas';
import * as tmImage from '@teachablemachine/image';
// JSDOM のセットアップ
const dom = new JSDOM('');
global.document = dom.window.document;
global.HTMLVideoElement = dom.window.HTMLVideoElement;
// Teachable Machine のモデルURL
const URL = 'https://teachablemachine.withgoogle.com/models/_ZwRgCncv/';
async function init() {
const modelURL = URL + 'model.json';
const metadataURL = URL + 'metadata.json';
// モデルをロード
const model = await tmImage.load(modelURL, metadataURL);
// クラスラベルを取得
const classes = model.getClassLabels();
console.log(classes);
// 画像を読み込み
const image = await loadImage('choki.png');
// 予測を実行
const predictions = await model.predict(image);
console.log(predictions);
}
init();
choki.pngを読み込ませて、じゃんけんの結果を得るといったサンプルです。
2. シンプルなサーバー化
次にexpressでサーバー化しました。
/predictに画像をポストして利用します。
$ curl -X POST http://localhost:3000/predict \
-F "image=@./choki.png"
server.mjs
// server.mjs
import express from 'express';
import multer from 'multer';
import { JSDOM } from 'jsdom';
import { loadImage } from 'canvas';
import * as tmImage from '@teachablemachine/image';
import fs from 'fs/promises';
// --- Node上でTMを動かすための最小DOMセットアップ ---
const dom = new JSDOM('');
global.document = dom.window.document;
global.HTMLVideoElement = dom.window.HTMLVideoElement;
// --- Teachable Machine モデル ---
const URL = 'https://teachablemachine.withgoogle.com/models/_ZwRgCncv/'; // じゃんけんモデル
const modelURL = URL + 'model.json';
const metadataURL = URL + 'metadata.json';
// 起動時に一度だけロード
const model = await tmImage.load(modelURL, metadataURL);
console.log('Model loaded:', model.getClassLabels());
// --- Express + multer ---
const app = express();
const upload = multer({ dest: 'uploads/' });
// 画像を投げると予測だけ返すシンプルAPI
app.post('/predict', upload.single('image'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
const image = await loadImage(req.file.path);
const predictions = await model.predict(image);
const best = predictions.reduce((a, b) =>
a.probability > b.probability ? a : b
);
await fs.unlink(req.file.path); // 一時ファイル削除
res.json({
topClass: best.className,
probability: best.probability,
predictions
});
} catch (err) {
console.error(err);
// 失敗しても一時ファイルが残らないように
if (req.file?.path) {
try { await fs.unlink(req.file.path); } catch {}
}
res.status(500).json({ error: 'Prediction failed', details: err.message });
}
});
app.get('/', (_req, res) => {
res.send('OK. POST /predict with form-data key "image".');
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
3. 画像の保存も行いモード切り替え
こちらの記事で実施したようにデバイス側から送られてくる画像を保存するエンドポイントも最終的には作りました。
- モード確認
$ curl http://localhost:3000/mode
{"mode":"predict","endpoint":"/predict","description":"Image prediction mode"}
- 保存
$ curl -X POST http://localhost:3000/save \
-F "image=@./choki.png"
{
"success": true,
"filename": "2025-12-06T01-23-45-678Z.png",
"path": "saved_images/2025-12-06T01-23-45-678Z.png",
"timestamp": "2025-12-06T01:23:45.912Z"
}
- 推論
$ curl -X POST "http://localhost:3000/predict?target=choki" \
-F "image=@./choki.png"
{
"target": "choki",
"topClass": "choki",
"probability": 0.95,
"result": 1
}
コード
server.mjs
import express from 'express';
import multer from 'multer';
import { JSDOM } from 'jsdom';
import { loadImage } from 'canvas';
import * as tmImage from '@teachablemachine/image';
import fs from 'fs/promises';
// コマンドライン引数からモードを取得(デフォルトは 'predict')
const args = process.argv.slice(2);
const modeIndex = args.indexOf('--mode');
const MODE = modeIndex !== -1 && args[modeIndex + 1] ? args[modeIndex + 1] : 'predict';
// モードのバリデーション
if (!['predict', 'save'].includes(MODE)) {
console.error(`Invalid mode: ${MODE}. Use 'predict' or 'save'.`);
process.exit(1);
}
console.log(`Server mode: ${MODE}`);
// 環境初期化
const dom = new JSDOM('');
global.document = dom.window.document;
global.HTMLVideoElement = dom.window.HTMLVideoElement;
// モデルURL
const URL = 'https://teachablemachine.withgoogle.com/models/_ZwRgCncv/'; //じゃんけん
const modelURL = URL + 'model.json';
const metadataURL = URL + 'metadata.json';
// モデルロード(起動時に一度だけ)
const model = await tmImage.load(modelURL, metadataURL);
console.log('Model loaded:', model.getClassLabels());
// Express 設定
const app = express();
const upload = multer({ dest: 'uploads/' });
// 画像アップロード用エンドポイント
app.post('/predict', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image uploaded' });
}
// 一時ファイルをロード
const image = await loadImage(req.file.path);
// 予測
const predictions = await model.predict(image);
const best = predictions.reduce((a, b) => (a.probability > b.probability ? a : b));
// 判定結果をコンソールに表示
console.log('=== Prediction Result ===');
console.log(`Top Class: ${best.className}`);
console.log(`Probability: ${(best.probability * 100).toFixed(2)}%`);
console.log('All predictions:');
predictions.forEach(pred => {
console.log(` - ${pred.className}: ${(pred.probability * 100).toFixed(2)}%`);
});
// 一時ファイル削除
await fs.unlink(req.file.path);
// クエリで指定されたクラス名
const target = req.query.target;
if (!target) {
console.log('========================\n');
return res.json({ topClass: best.className, probability: best.probability });
}
// 一致すれば 1, 違えば 0
const result = best.className === target ? 1 : 0;
console.log(`Target: ${target}`);
console.log(`Result: ${result === 1 ? 'MATCH ✓' : 'NO MATCH ✗'}`);
console.log('========================\n');
res.json({ target, topClass: best.className, probability: best.probability, result });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Prediction failed', details: err.message });
}
});
// モード確認用エンドポイント
app.get('/mode', (req, res) => {
res.json({
mode: MODE,
endpoint: `/${MODE}`,
description: MODE === 'predict' ? 'Image prediction mode' : 'Image save mode'
});
});
// 写真保存用エンドポイント
app.post('/save', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image uploaded' });
}
// 保存ディレクトリの作成(なければ)
const saveDir = 'saved_images';
await fs.mkdir(saveDir, { recursive: true });
// タイムスタンプ付きファイル名生成
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const ext = req.file.originalname ? req.file.originalname.split('.').pop() : 'jpg';
const filename = `${timestamp}.${ext}`;
const savePath = `${saveDir}/${filename}`;
// 一時ファイルを保存先に移動
await fs.rename(req.file.path, savePath);
res.json({
success: true,
filename,
path: savePath,
timestamp: new Date().toISOString()
});
} catch (err) {
console.error(err);
// エラー時は一時ファイルを削除
try {
await fs.unlink(req.file.path);
} catch {}
res.status(500).json({ error: 'Save failed', details: err.message });
}
});
// サーバー起動
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
所感など
Teachable Machineの組み込み版を使うと書き込みで毎回大変なのでこの形式にしてしまうのはある種正解なのかもしれない
Teachable Machineのライブラリですが、通常はブラウザで利用する想定なのでJSDOMを使って擬似的に再現してる部分は面白い実装ですよね @tkyko13 さんに感謝。