gazou-compressor.jp

OGP自動トリミング:顔検出+サリエンシーで“映える”切り出し(Sharp/SmartCrop 実装)

OGP(SNSのカード画像)は最初の印象を決定します。一方で各プラットフォームは自動裁断の仕様や 表示比率が微妙に異なり、顔の端切れ・主役の縮退・テキスト欠けといった事故が起こりがちです。 本稿ではサリエンシー(注視領域)+顔検出を“加点”として使い、最終決定をSmartCropに委ねる安全設計を解説します。実運用に役立つ評価・プレビュー・A/Bテストの土台も用意します。

先に結論:事故を避ける3原則
  • 主役>文字>ロゴ>背景の優先度で注目領域を抽出。
  • 顔は加点にとどめ、誤検出でも破綻しない。
  • ルールオブサード+端から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. 公開前チェックリスト

7. まとめ

「主役の可視性」「端切れ回避」「安定したフレーミング」を数値化し、加点設計+SmartCropで自動化すれば、 各プラットフォームの差にも強いOGPが量産できます。

公開:2025-09-05

gazou-compressor.jp 編集部

画像圧縮・変換・背景除去などの実践テクニックと、Webで“速く・軽く・崩さない”ためのノウハウを発信しています。

関連記事