// lib/sharpness.mjs — 候选图自动择优 // // 用途:人物资产流水线每步会出 N 张候选(定妆照 3 张、后续 2 张), // 需要自动挑出「最锐」的一张作为下一步的参考图。 // // 两条实现,按可用性自动选择(都不改仓库外的文件): // ① 首选 Python PIL:完全照抄 KB 里 `pickSharpest` 的算法 // (灰度 → np.gradient 的平方均值,即拉普拉斯锐度的等价物) // ② 兜底纯 Node:用已在本仓依赖里的 ffmpeg 把图转成 8bit 灰度 rawvideo, // 在 Node 里算同一套梯度能量。无需 numpy/PIL。 // // 无论走哪条,返回结构一致;选不出时**绝不编造**,返回 {ok:false} 并说明原因。 import { existsSync, readFileSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { checkFfmpeg } from './ffmpeg.mjs'; const PY_CANDIDATES = ['python3', 'python']; /** 探测可用的 python 解释器 */ function findPython() { for (const bin of PY_CANDIDATES) { try { const r = spawnSync(bin, ['-I', '-c', 'import PIL, numpy; print("ok")'], { encoding: 'utf8', timeout: 20_000 }); if (r.status === 0 && String(r.stdout).includes('ok')) return bin; } catch { /* 换下一个 */ } } return null; } /** * PIL 版择优(照抄 KB 的 pickSharpest 逻辑,仅改为 -I 隔离模式 + 参数化)。 * @returns {{ok:boolean, best:string|null, scores:Array<{file:string,score:number}>, method:string, error?:string}} */ function pickWithPIL(python, files) { // 路径通过 argv 传入,避免把路径拼进代码字符串(也避免引号/编码问题) const py = ` import sys, json from PIL import Image import numpy as np files = json.loads(sys.argv[1]) out = [] best, bestScore = None, -1.0 for f in files: try: a = np.asarray(Image.open(f).convert('L')).astype(float) gy, gx = np.gradient(a) s = float((gx**2 + gy**2).mean()) except Exception as e: out.append({'file': f, 'score': -1.0, 'error': str(e)}) continue out.append({'file': f, 'score': s}) if s > bestScore: bestScore, best = s, f print(json.dumps({'best': best, 'scores': out})) `; const r = spawnSync(python, ['-I', '-c', py, JSON.stringify(files)], { encoding: 'utf8', timeout: 120_000 }); if (r.status !== 0) { return { ok: false, best: null, scores: [], method: 'python-PIL', error: (r.stderr || 'python 执行失败').slice(0, 400) }; } try { const j = JSON.parse(r.stdout.trim().split('\n').pop()); return { ok: !!j.best, best: j.best, scores: j.scores || [], method: 'python-PIL' }; } catch (e) { return { ok: false, best: null, scores: [], method: 'python-PIL', error: `解析 python 输出失败:${e.message}` }; } } /** 同步版 ffmpeg 灰度解码(用 spawnSync 拿真实 Buffer,避免 utf8 破损) */ function decodeGraySync(ffmpegBin, file, width = 512) { const args = ['-v', 'error', '-i', file, '-vf', `scale=${width}:-2,format=gray`, '-f', 'rawvideo', '-pix_fmt', 'gray', 'pipe:1']; const r = spawnSync(ffmpegBin, args, { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024, timeout: 120_000 }); if (r.status !== 0 || !r.stdout?.length) { throw new Error(`ffmpeg 解码失败:${String(r.stderr).slice(0, 200)}`); } return r.stdout; } /** 从灰度 raw 缓冲算梯度平方均值(PIL 版口径) */ function gradientEnergy(buf, width) { const h = Math.floor(buf.length / width); let sum = 0; let n = 0; for (let y = 0; y < h; y++) { const row = y * width; for (let x = 0; x < width - 1 && row + x + 1 < buf.length; x++) { const gx = buf[row + x + 1] - buf[row + x]; sum += gx * gx; n++; } } for (let y = 0; y < h - 1; y++) { const row = y * width; const next = (y + 1) * width; for (let x = 0; x < width && next + x < buf.length; x++) { const gy = buf[next + x] - buf[row + x]; sum += gy * gy; n++; } } return n ? sum / n : 0; } function pickWithRawFfmpeg(files) { const ff = checkFfmpeg(); if (!ff.ffmpeg.available) { return { ok: false, best: null, scores: [], method: 'ffmpeg-raw', error: 'ffmpeg 不可用' }; } const bin = process.env.FFMPEG_BIN || 'ffmpeg'; const scores = []; let best = null; let bestScore = -1; for (const f of files) { try { if (!existsSync(f)) throw new Error('文件不存在'); const buf = decodeGraySync(bin, f); const s = gradientEnergy(buf, 512); scores.push({ file: f, score: s }); if (s > bestScore) { bestScore = s; best = f; } } catch (e) { scores.push({ file: f, score: -1, error: e.message }); } } return { ok: !!best, best, scores, method: 'ffmpeg-raw' }; } /** * 从候选图里挑最锐的一张。 * @param {string[]} files * @param {{force?:'python'|'ffmpeg'}} [o] * @returns {Promise<{ok:boolean, best:string|null, scores:Array, method:string, error?:string}>} */ export async function pickSharpest(files, o = {}) { const list = (files || []).filter((f) => f && existsSync(f)); if (!list.length) return { ok: false, best: null, scores: [], method: 'none', error: '没有候选文件' }; if (list.length === 1) { return { ok: true, best: list[0], scores: [{ file: list[0], score: null }], method: 'single' }; } const python = o.force === 'ffmpeg' ? null : findPython(); if (python) { const r = pickWithPIL(python, list); if (r.ok) return r; // PIL 路径失败 → 打印原因后继续走兜底,绝不静默 process.stderr.write(` ! PIL 择优失败(${r.error || '未知'}),改用 ffmpeg 兜底\n`); } return pickWithRawFfmpeg(list); } /** 打印择优明细(给用户看的一行) */ export function renderPick(r, files) { if (!r.ok) return `择优失败:${r.error || '未知'}`; const chosen = files.indexOf(r.best) + 1; const detail = r.scores .map((s, i) => `#${i + 1}=${s.score == null ? 'n/a' : Number(s.score).toFixed(1)}`) .join(' '); return `择优 → 候选 #${chosen}(${r.method};锐度 ${detail})`; } export function fileBytes(p) { try { return statSync(p).size; } catch { return 0; } } export { readFileSync };