OGP自動トリミング:顔検出+サリエンシーで“映える”切り出し(Sharp/SmartCrop 実装)
OGP(SNSのカード画像)は最初の印象を決定します。一方で各プラットフォームは自動裁断の仕様や 表示比率が微妙に異なり、顔の端切れ・主役の縮退・テキスト欠けといった事故が起こりがちです。 本稿ではサリエンシー(注視領域)+顔検出を“加点”として使い、最終決定をSmartCropに委ねる安全設計を解説します。実運用に役立つ評価・プレビュー・A/Bテストの土台も用意します。
- 主役>文字>ロゴ>背景の優先度で注目領域を抽出。
- 顔は加点にとどめ、誤検出でも破綻しない。
- ルールオブサード+端から4%の安全余白を守る。
1. プラットフォーム差と“安全マージン”
表示枠はおおむね16:9周辺に収斂しますが、実際の切れ方は周辺UIにも依存します。 そのため「重要要素は中心〜上三分割に寄せ、端から4%以上離す」というレイアウト規律が安定解になります。 これを満たす限り、比率やUIの差異による事故が大幅に減ります。
2. 実装A:サリエンシー単独(SmartCrop×Sharp)
注視度のみでも背景優勢問題は大きく改善します。まずは軽量な構成から導入し、CTRや端切れ率を観測します。
// scripts/smartcrop-ogp.ts
// 依存: npm i -D sharp smartcrop-sharp tsx
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import smartcrop from "smartcrop-sharp";
const IN = process.env.IN ?? "input";
const OUT = process.env.OUT ?? "out";
const W = Number(process.env.W ?? 1200);
const H = Number(process.env.H ?? 630);
async function cropOne(file: string) {
const src = path.join(IN, file);
const buf = await fs.readFile(src);
const { topCrop } = await smartcrop.crop(buf, {
width: W, height: H, ruleOfThirds: true, minScale: 1.0,
});
const img = sharp(buf);
const meta = await img.metadata();
const extracted = await img
.extract({
left: Math.max(0, topCrop.x),
top: Math.max(0, topCrop.y),
width: Math.min(topCrop.width, meta.width ?? topCrop.width),
height: Math.min(topCrop.height, meta.height ?? topCrop.height),
})
.resize(W, H)
.jpeg({ quality: 84, mozjpeg: true, progressive: true })
.toBuffer();
const outPath = path.join(OUT, path.parse(file).name + "-ogp.jpg");
await fs.mkdir(OUT, { recursive: true });
await fs.writeFile(outPath, extracted);
console.log("OGP:", outPath);
}
async function main() {
const files = (await fs.readdir(IN)).filter((f) => /\.(jpe?g|png|webp|avif)$/i.test(f));
for (const f of files) await cropOne(f);
}
main().catch((e) => { console.error(e); process.exit(1); });
3. 実装B:顔検出を“強すぎない加点”で
顔は視線を集めますが、必ず追従させると破綻しやすくなります。検出結果は少し大きめに膨らませ、 SmartCropに渡すboost
へ。これによりテキストやロゴが主役のケースでも自然なバランスを保てます。
// scripts/face-aware-crop.ts(任意の顔加点)
// 依存: npm i -D sharp smartcrop-sharp @vladmandic/face-api @tensorflow/tfjs-node canvas tsx
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import smartcrop from "smartcrop-sharp";
type Rect = { x: number; y: number; width: number; height: number; weight?: number };
async function detectFaces(buf: Buffer): Promise<Rect[]> {
try {
const faceapi = await import("@vladmandic/face-api");
const canvasMod = await import("canvas");
// @ts-ignore
const { Canvas, Image, ImageData } = canvasMod;
// @ts-ignore
faceapi.env.monkeyPatch({ Canvas, Image, ImageData });
await faceapi.nets.tinyFaceDetector.loadFromDisk("./models");
const img = await canvasMod.loadImage(buf);
const results = await faceapi.detectAllFaces(img, new faceapi.TinyFaceDetectorOptions());
return results.map((r: any) => ({ x: r.box.x, y: r.box.y, width: r.box.width, height: r.box.height, weight: 1.6 }));
} catch { return []; }
}
async function cropWithBoost(file: string, size = { w: 1200, h: 630 }) {
const buf = await fs.readFile(file);
const faces = await detectFaces(buf);
const { topCrop } = await smartcrop.crop(buf, {
width: size.w, height: size.h, ruleOfThirds: true, minScale: 1.0,
boost: faces.map((r) => ({
x: Math.max(0, r.x - r.width * 0.2),
y: Math.max(0, r.y - r.height * 0.25),
width: r.width * 1.4, height: r.height * 1.6, weight: r.weight ?? 1.2,
})),
});
const out = await sharp(buf)
.extract({ left: topCrop.x, top: topCrop.y, width: topCrop.width, height: topCrop.height })
.resize(size.w, size.h)
.jpeg({ quality: 84, mozjpeg: true, progressive: true })
.toBuffer();
await fs.mkdir("out", { recursive: true });
await fs.writeFile(path.join("out", path.parse(file).name + "-ogp.jpg"), out);
}
(async () => {
const IN = process.env.IN ?? "input";
const files = (await fs.readdir(IN)).filter((f) => /\.(jpe?g|png|webp|avif)$/i.test(f));
for (const f of files) await cropWithBoost(path.join(IN, f));
})();
4. Next.js API連携(オンデマンドOGP)
CMS/UGCと組み合わせ、投稿と同時に“映える”サムネイルを返せます。CDNはSWR運用がおすすめ。
// app/api/ogp-crop/route.ts(オンデマンド生成)
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import smartcrop from "smartcrop-sharp";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const src = searchParams.get("url");
const w = Number(searchParams.get("w") ?? 1200);
const h = Number(searchParams.get("h") ?? 630);
if (!src) return NextResponse.json({ error: "url is required" }, { status: 400 });
const res = await fetch(src, { cache: "force-cache" });
if (!res.ok) return NextResponse.json({ error: "fetch failed" }, { status: 502 });
const buf = Buffer.from(await res.arrayBuffer());
const { topCrop } = await smartcrop.crop(buf, { width: w, height: h, ruleOfThirds: true });
const out = await sharp(buf)
.extract({ left: topCrop.x, top: topCrop.y, width: topCrop.width, height: topCrop.height })
.resize(w, h)
.jpeg({ quality: 84, mozjpeg: true, progressive: true })
.toBuffer();
const headers = new Headers({
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=60, s-maxage=86400, stale-while-revalidate=604800",
});
return new NextResponse(out, { headers, status: 200 });
}
5. プレビューと評価指標
人手チェックの効率化には連結プレビューと簡易スコアが有効です。端切れ率・顔中心ズレ・テキスト欠損(OCR併用可) などの負の指標を管理して、A/BテストではCTRだけに依存しない評価を行います。
// scripts/preview-grid.ts — 一括プレビュー(確認用の連結画像を作る)
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
(async () => {
const DIR = process.env.DIR ?? "out";
const files = (await fs.readdir(DIR)).filter((f) => /-ogp\.jpg$/i.test(f)).slice(0, 24);
const thumbs = await Promise.all(files.map((f) => sharp(path.join(DIR, f)).resize(400).toBuffer()));
const cols = 4, rows = Math.ceil(thumbs.length / cols);
const w = 400 * cols, h = 225 * rows; // 16:9の想定
const canvas = sharp({ create: { width: w, height: h, channels: 3, background: "#111" } });
const composites = thumbs.map((buf, i) => ({
input: buf, left: (i % cols) * 400, top: Math.floor(i / cols) * 225,
}));
const out = await canvas.composite(composites).jpeg({ quality: 80 }).toBuffer();
await fs.writeFile("preview-grid.jpg", out);
})();
// scripts/ogp-eval.ts — 失敗検知&簡易スコア
// 依存: npm i -D sharp tsx
import fs from "node:fs/promises";
import path from "path";
import sharp from "sharp";
type Score = { file: string; edgeSafe: boolean; minSafe: number; };
async function scoreOne(p: string): Promise<Score> {
const meta = await sharp(p).metadata();
const w = meta.width ?? 0, h = meta.height ?? 0;
const safe = Math.round(Math.min(w, h) * 0.04); // 4%の安全余白
// 端の明度勾配など高度な解析は割愛。まず“余白の確保”に着目。
const edgeSafe = w >= 1000 && h >= 520;
return { file: path.basename(p), edgeSafe, minSafe: safe };
}
(async () => {
const DIR = process.env.DIR ?? "out";
const files = (await fs.readdir(DIR)).filter((f) => /-ogp\.jpg$/i.test(f));
const results = await Promise.all(files.map((f) => scoreOne(path.join(DIR, f))));
results.sort((a, b) => Number(b.edgeSafe) - Number(a.edgeSafe));
console.table(results);
})();
6. 公開前チェックリスト
- 重要要素(顔・ロゴ・文字)が端から4%以上離れている。
- 主役の目線・ロゴ中央が上三分割付近に収まる。
- 低解像度ソース(
<1000×520
)を自動弾き。 - 顔検出は加点のみで、失敗しても破綻しない。
- APIはCDN+SWR、バッチは差分更新&失敗リスト化。
7. まとめ
「主役の可視性」「端切れ回避」「安定したフレーミング」を数値化し、加点設計+SmartCropで自動化すれば、 各プラットフォームの差にも強いOGPが量産できます。