sharpness.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // lib/sharpness.mjs — 候选图自动择优
  2. //
  3. // 用途:人物资产流水线每步会出 N 张候选(定妆照 3 张、后续 2 张),
  4. // 需要自动挑出「最锐」的一张作为下一步的参考图。
  5. //
  6. // 两条实现,按可用性自动选择(都不改仓库外的文件):
  7. // ① 首选 Python PIL:完全照抄 KB 里 `pickSharpest` 的算法
  8. // (灰度 → np.gradient 的平方均值,即拉普拉斯锐度的等价物)
  9. // ② 兜底纯 Node:用已在本仓依赖里的 ffmpeg 把图转成 8bit 灰度 rawvideo,
  10. // 在 Node 里算同一套梯度能量。无需 numpy/PIL。
  11. //
  12. // 无论走哪条,返回结构一致;选不出时**绝不编造**,返回 {ok:false} 并说明原因。
  13. import { existsSync, readFileSync, statSync } from 'node:fs';
  14. import { spawnSync } from 'node:child_process';
  15. import { checkFfmpeg } from './ffmpeg.mjs';
  16. const PY_CANDIDATES = ['python3', 'python'];
  17. /** 探测可用的 python 解释器 */
  18. function findPython() {
  19. for (const bin of PY_CANDIDATES) {
  20. try {
  21. const r = spawnSync(bin, ['-I', '-c', 'import PIL, numpy; print("ok")'], { encoding: 'utf8', timeout: 20_000 });
  22. if (r.status === 0 && String(r.stdout).includes('ok')) return bin;
  23. } catch {
  24. /* 换下一个 */
  25. }
  26. }
  27. return null;
  28. }
  29. /**
  30. * PIL 版择优(照抄 KB 的 pickSharpest 逻辑,仅改为 -I 隔离模式 + 参数化)。
  31. * @returns {{ok:boolean, best:string|null, scores:Array<{file:string,score:number}>, method:string, error?:string}}
  32. */
  33. function pickWithPIL(python, files) {
  34. // 路径通过 argv 传入,避免把路径拼进代码字符串(也避免引号/编码问题)
  35. const py = `
  36. import sys, json
  37. from PIL import Image
  38. import numpy as np
  39. files = json.loads(sys.argv[1])
  40. out = []
  41. best, bestScore = None, -1.0
  42. for f in files:
  43. try:
  44. a = np.asarray(Image.open(f).convert('L')).astype(float)
  45. gy, gx = np.gradient(a)
  46. s = float((gx**2 + gy**2).mean())
  47. except Exception as e:
  48. out.append({'file': f, 'score': -1.0, 'error': str(e)})
  49. continue
  50. out.append({'file': f, 'score': s})
  51. if s > bestScore:
  52. bestScore, best = s, f
  53. print(json.dumps({'best': best, 'scores': out}))
  54. `;
  55. const r = spawnSync(python, ['-I', '-c', py, JSON.stringify(files)], { encoding: 'utf8', timeout: 120_000 });
  56. if (r.status !== 0) {
  57. return { ok: false, best: null, scores: [], method: 'python-PIL', error: (r.stderr || 'python 执行失败').slice(0, 400) };
  58. }
  59. try {
  60. const j = JSON.parse(r.stdout.trim().split('\n').pop());
  61. return { ok: !!j.best, best: j.best, scores: j.scores || [], method: 'python-PIL' };
  62. } catch (e) {
  63. return { ok: false, best: null, scores: [], method: 'python-PIL', error: `解析 python 输出失败:${e.message}` };
  64. }
  65. }
  66. /** 同步版 ffmpeg 灰度解码(用 spawnSync 拿真实 Buffer,避免 utf8 破损) */
  67. function decodeGraySync(ffmpegBin, file, width = 512) {
  68. const args = ['-v', 'error', '-i', file, '-vf', `scale=${width}:-2,format=gray`, '-f', 'rawvideo', '-pix_fmt', 'gray', 'pipe:1'];
  69. const r = spawnSync(ffmpegBin, args, { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024, timeout: 120_000 });
  70. if (r.status !== 0 || !r.stdout?.length) {
  71. throw new Error(`ffmpeg 解码失败:${String(r.stderr).slice(0, 200)}`);
  72. }
  73. return r.stdout;
  74. }
  75. /** 从灰度 raw 缓冲算梯度平方均值(PIL 版口径) */
  76. function gradientEnergy(buf, width) {
  77. const h = Math.floor(buf.length / width);
  78. let sum = 0;
  79. let n = 0;
  80. for (let y = 0; y < h; y++) {
  81. const row = y * width;
  82. for (let x = 0; x < width - 1 && row + x + 1 < buf.length; x++) {
  83. const gx = buf[row + x + 1] - buf[row + x];
  84. sum += gx * gx;
  85. n++;
  86. }
  87. }
  88. for (let y = 0; y < h - 1; y++) {
  89. const row = y * width;
  90. const next = (y + 1) * width;
  91. for (let x = 0; x < width && next + x < buf.length; x++) {
  92. const gy = buf[next + x] - buf[row + x];
  93. sum += gy * gy;
  94. n++;
  95. }
  96. }
  97. return n ? sum / n : 0;
  98. }
  99. function pickWithRawFfmpeg(files) {
  100. const ff = checkFfmpeg();
  101. if (!ff.ffmpeg.available) {
  102. return { ok: false, best: null, scores: [], method: 'ffmpeg-raw', error: 'ffmpeg 不可用' };
  103. }
  104. const bin = process.env.FFMPEG_BIN || 'ffmpeg';
  105. const scores = [];
  106. let best = null;
  107. let bestScore = -1;
  108. for (const f of files) {
  109. try {
  110. if (!existsSync(f)) throw new Error('文件不存在');
  111. const buf = decodeGraySync(bin, f);
  112. const s = gradientEnergy(buf, 512);
  113. scores.push({ file: f, score: s });
  114. if (s > bestScore) {
  115. bestScore = s;
  116. best = f;
  117. }
  118. } catch (e) {
  119. scores.push({ file: f, score: -1, error: e.message });
  120. }
  121. }
  122. return { ok: !!best, best, scores, method: 'ffmpeg-raw' };
  123. }
  124. /**
  125. * 从候选图里挑最锐的一张。
  126. * @param {string[]} files
  127. * @param {{force?:'python'|'ffmpeg'}} [o]
  128. * @returns {Promise<{ok:boolean, best:string|null, scores:Array, method:string, error?:string}>}
  129. */
  130. export async function pickSharpest(files, o = {}) {
  131. const list = (files || []).filter((f) => f && existsSync(f));
  132. if (!list.length) return { ok: false, best: null, scores: [], method: 'none', error: '没有候选文件' };
  133. if (list.length === 1) {
  134. return { ok: true, best: list[0], scores: [{ file: list[0], score: null }], method: 'single' };
  135. }
  136. const python = o.force === 'ffmpeg' ? null : findPython();
  137. if (python) {
  138. const r = pickWithPIL(python, list);
  139. if (r.ok) return r;
  140. // PIL 路径失败 → 打印原因后继续走兜底,绝不静默
  141. process.stderr.write(` ! PIL 择优失败(${r.error || '未知'}),改用 ffmpeg 兜底\n`);
  142. }
  143. return pickWithRawFfmpeg(list);
  144. }
  145. /** 打印择优明细(给用户看的一行) */
  146. export function renderPick(r, files) {
  147. if (!r.ok) return `择优失败:${r.error || '未知'}`;
  148. const chosen = files.indexOf(r.best) + 1;
  149. const detail = r.scores
  150. .map((s, i) => `#${i + 1}=${s.score == null ? 'n/a' : Number(s.score).toFixed(1)}`)
  151. .join(' ');
  152. return `择优 → 候选 #${chosen}(${r.method};锐度 ${detail})`;
  153. }
  154. export function fileBytes(p) {
  155. try {
  156. return statSync(p).size;
  157. } catch {
  158. return 0;
  159. }
  160. }
  161. export { readFileSync };