| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173 |
- // 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 };
|