自動ホワイトバランス&色かぶり補正: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. 公開前チェック
- 肌色・白壁が自然か。
- 暗部で色ノイズが増えていない。
- sRGB/P3の色差が許容内。