gazou-compressor.jp

自動ホワイトバランス&色かぶり補正:Gray World+シャドウ保護(Sharp)

自動ホワイトバランスはGray World(等平均仮説)が手堅い出発点です。暗部の色ノイズを増やさないようリフトは控えめにし、仕上げに彩度を微回復。色域の扱いはP3とsRGB記事も参照。

方針(TL;DR)
  • ゲインの上下限を設け過補正を防ぐ。
  • シャドウは保護し、彩度は軽く戻す。
  • 配信はSWR+ETagで安定化。

1. CLI(バッチ処理)

// scripts/auto-wb.ts — Gray World + シャドウ保護 + 彩度復元
// 依存: npm i -D sharp tsx
import sharp from "sharp";
import fs from "node:fs/promises";
import path from "node:path";

function clamp(x:number,min:number,max:number){return Math.max(min,Math.min(max,x));}

async function autoWB(buf:Buffer, opts={sat:1.06, gamma:1.0, maxGain:1.6, minGain:0.7}) {
  // sRGB化&回転
  let img = sharp(buf).rotate().toColourspace("srgb");
  const { data, info } = await img.clone().raw().toBuffer({ resolveWithObject: true });
  const len = info.width * info.height * info.channels;

  // Gray World: RGB平均を揃える
  let r=0,g=0,b=0, px=0;
  for (let i=0;i<len;i+=info.channels){ r+=data[i]; g+=data[i+1]; b+=data[i+2]; px++; }
  const rm=r/px, gm=g/px, bm=b/px; const mean=(rm+gm+bm)/3;
  const kr=clamp(mean/rm, opts.minGain, opts.maxGain);
  const kg=clamp(mean/gm, opts.minGain, opts.maxGain);
  const kb=clamp(mean/bm, opts.minGain, opts.maxGain);

  // 係数適用(シャドウ保護: リフト小さめ)
  img = img.linear([kr,kg,kb], [0,0,0]).gamma(1.0).modulate({ saturation: opts.sat });

  return {
    avif: await img.clone().avif({ quality: 55, effort: 4 }).toBuffer(),
    webp: await img.clone().webp({ quality: 82 }).toBuffer(),
    jpg:  await img.clone().jpeg({ quality: 80, mozjpeg: true, progressive: true }).toBuffer()
  };
}

async function main(){
  const file = process.argv[2]; if(!file) throw new Error("usage: tsx scripts/auto-wb.ts input.jpg");
  const buf = await fs.readFile(file);
  const out = await autoWB(buf,{});
  const base = path.parse(file).name; await fs.mkdir("out",{recursive:true});
  await fs.writeFile(`out/${base}-wb.avif`, out.avif);
  await fs.writeFile(`out/${base}-wb.webp`, out.webp);
  await fs.writeFile(`out/${base}-wb.jpg`, out.jpg);
  console.log("ok");
}
main().catch(e=>{console.error(e);process.exit(1);});

Version: 2025-09-06 – TechArticle化 / keywords拡張。

2. API(オンデマンド)

// app/api/awb/route.ts — 自動WB API
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
export const runtime = "nodejs"; export const dynamic = "force-dynamic";

function clamp(x:number,min:number,max:number){return Math.max(min,Math.min(max,x));}

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const src = searchParams.get("url"); const format = (searchParams.get("format") ?? "avif").toLowerCase();
  if(!src) return NextResponse.json({error:"url is required"}, {status:400});
  const r = await fetch(src,{cache:"no-store"}); if(!r.ok) return NextResponse.json({error:"fetch failed"},{status:502});
  let img = sharp(Buffer.from(await r.arrayBuffer())).rotate().toColourspace("srgb");
  const { data, info } = await img.clone().raw().toBuffer({ resolveWithObject: true });

  let rSum=0,gSum=0,bSum=0, px=0;
  for (let i=0;i<data.length;i+=info.channels){ rSum+=data[i]; gSum+=data[i+1]; bSum+=data[i+2]; px++; }
  const rm=rSum/px, gm=gSum/px, bm=bSum/px; const mean=(rm+gm+bm)/3;
  const kr=clamp(mean/rm,0.7,1.6), kg=clamp(mean/gm,0.7,1.6), kb=clamp(mean/bm,0.7,1.6);

  img = img.linear([kr,kg,kb], [0,0,0]).modulate({ saturation: 1.06 });

  let body:Buffer, type="image/avif";
  if(format==="webp"){ body=await img.webp({quality:82}).toBuffer(); type="image/webp"; }
  else if(format==="jpeg"||format==="jpg"){ body=await img.jpeg({quality:80,mozjpeg:true,progressive:true}).toBuffer(); type="image/jpeg"; }
  else{ body=await img.avif({quality:55,effort:4}).toBuffer(); }

  return new NextResponse(body,{headers:{ "Content-Type":type, "Cache-Control":"public, max-age=60, s-maxage=86400, stale-while-revalidate=604800" }});
}

3. 公開前チェック

公開:2025-09-06 / 監修:gazou-compressor.jp 編集部

gazou-compressor.jp 編集部

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

関連記事

トピック/更新日の近いコンテンツ