museav-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * 本地去水印 —— remove-watermark 的核心实现。
3
+ * 链路:纯像素启发式定位水印(零模型依赖,不占内存)→ LaMa 掩码修复 →
4
+ * 输出干净图。也可 --mask 手工给掩码(白=要去除的区域),跳过自动定位。
5
+ * 全本地、免登录、零成本;许可证均兼容(LaMa 模型 Apache-2.0,onnxruntime MIT)。
6
+ */
7
+ import { mkdir, writeFile, stat } from 'node:fs/promises'
8
+ import { join } from 'node:path'
9
+ import { homedir } from 'node:os'
10
+
11
+ const LAMA_URLS = [
12
+ 'https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
13
+ // 国内网络拉不动 HF 时的镜像(同一文件;镜像没缓存会回落直链,所以放第二位)
14
+ 'https://hf-mirror.com/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
15
+ ]
16
+ const LAMA_PATH = join(homedir(), '.museav-models', 'lama', 'lama_fp32.onnx')
17
+
18
+ async function ensureLamaModel(): Promise<string> {
19
+ try {
20
+ if ((await stat(LAMA_PATH)).size > 50_000_000) return LAMA_PATH
21
+ } catch { /* 未下载 */ }
22
+ await mkdir(join(homedir(), '.museav-models', 'lama'), { recursive: true })
23
+ process.stderr.write('↓ 首次使用,下载 LaMa 修复模型(~200MB,一次性,缓存到 ~/.museav-models/lama)...\n')
24
+ let lastErr: unknown = null
25
+ for (const url of LAMA_URLS) {
26
+ try {
27
+ const resp = await fetch(url, { redirect: 'follow' })
28
+ if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`)
29
+ const total = Number(resp.headers.get('content-length') || 0)
30
+ const chunks: Buffer[] = []
31
+ let got = 0
32
+ const reader = resp.body.getReader()
33
+ for (;;) {
34
+ const { done, value } = await reader.read()
35
+ if (done) break
36
+ chunks.push(Buffer.from(value))
37
+ got += value.length
38
+ if (total) process.stderr.write(` ${(got / 1048576).toFixed(0)}/${(total / 1048576).toFixed(0)}MB\r`)
39
+ }
40
+ process.stderr.write('\n')
41
+ const buf = Buffer.concat(chunks)
42
+ if (buf.length < 50_000_000) throw new Error('下载不完整')
43
+ await writeFile(LAMA_PATH, buf)
44
+ return LAMA_PATH
45
+ } catch (e) {
46
+ lastErr = e
47
+ }
48
+ }
49
+ throw new Error(`LaMa 模型下载失败(${lastErr instanceof Error ? lastErr.message : lastErr}),也可手动放到 ${LAMA_PATH}`)
50
+ }
51
+
52
+ /** LaMa 要求边长被 8 整除:右/下边缘复制填充,修完再裁回 */
53
+ const pad8 = (n: number) => Math.ceil(n / 8) * 8
54
+
55
+ export interface Bbox {
56
+ x1: number
57
+ y1: number
58
+ x2: number
59
+ y2: number
60
+ }
61
+
62
+ /**
63
+ * 纯像素启发式水印定位(零模型依赖)。
64
+ * 原理:半透明水印(角标/文字)是「低对比、高频、铺在大片区域的细碎纹理」,
65
+ * 把四角区域做中值模糊后与原图差分,叠字区会出现稳定的高差值像素团。
66
+ * 判定保守:角区差分像素占比在 [0.3%, 8%] 才算水印(太少=没叠字,
67
+ * 太多=画面本身纹理复杂,不硬修);坐标 0-1000 归一化输出。
68
+ * 误检最坏是 LaMa 重绘一块(轻微损伤),另有 --mask 手工精确兜底。
69
+ */
70
+ export async function detectWatermarkBoxes(imagePath: string): Promise<Bbox[]> {
71
+ const sharpMod = await import('sharp')
72
+ const sharp = (sharpMod as any).default ?? sharpMod
73
+ const meta = await sharp(imagePath).rotate().metadata()
74
+ const W = meta.width || 0
75
+ const H = meta.height || 0
76
+ if (!W || !H) throw new Error('读不到图片尺寸')
77
+
78
+ const corners = [
79
+ { x: 0, y: 0 },
80
+ { x: 1, y: 0 },
81
+ { x: 0, y: 1 },
82
+ { x: 1, y: 1 },
83
+ ]
84
+ const cw = Math.round(W * 0.35)
85
+ const ch = Math.round(H * 0.35)
86
+ const boxes: Bbox[] = []
87
+
88
+ for (const { x, y } of corners) {
89
+ const left = x ? W - cw : 0
90
+ const top = y ? H - ch : 0
91
+ const { data: orig } = await sharp(imagePath).rotate()
92
+ .extract({ left, top, width: cw, height: ch }).raw().toBuffer({ resolveWithObject: true })
93
+ const { data: blurred } = await sharp(imagePath).rotate()
94
+ .extract({ left, top, width: cw, height: ch })
95
+ .median(7)
96
+ .raw().toBuffer({ resolveWithObject: true })
97
+
98
+ // 差分 + 阈值;记录超阈值像素的 bbox
99
+ let cnt = 0
100
+ let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1
101
+ for (let i = 0; i < cw * ch; i++) {
102
+ const dr = Math.abs(orig[i * 3] - blurred[i * 3])
103
+ const dg = Math.abs(orig[i * 3 + 1] - blurred[i * 3 + 1])
104
+ const db = Math.abs(orig[i * 3 + 2] - blurred[i * 3 + 2])
105
+ if (dr + dg + db > 90) { // 每通道均值 >30
106
+ cnt++
107
+ const px = i % cw
108
+ const py = Math.floor(i / cw)
109
+ if (px < minX) minX = px
110
+ if (px > maxX) maxX = px
111
+ if (py < minY) minY = py
112
+ if (py > maxY) maxY = py
113
+ }
114
+ }
115
+ const ratio = cnt / (cw * ch)
116
+ if (ratio >= 0.003 && ratio <= 0.08 && maxX >= 0) {
117
+ // 像素 → 0-1000 归一化(带 2% 外扩)
118
+ const pad = 0.02
119
+ const box = {
120
+ x1: Math.max(0, ((left + minX) / W) * 1000 - pad * 1000),
121
+ y1: Math.max(0, ((top + minY) / H) * 1000 - pad * 1000),
122
+ x2: Math.min(1000, ((left + maxX) / W) * 1000 + pad * 1000),
123
+ y2: Math.min(1000, ((top + maxY) / H) * 1000 + pad * 1000),
124
+ }
125
+ // 贴角锚定:水印通常贴着所在角的边沿(5% 容差)。背景装饰/纹理也常被差分命中,
126
+ // 但它们离角远——角标类水印的判据就是「贴角」,否则宁可不修。
127
+ const anchored =
128
+ (x === 0 && y === 0 && box.x1 <= 50 && box.y1 <= 50) ||
129
+ (x === 1 && y === 0 && box.x2 >= 950 && box.y1 <= 50) ||
130
+ (x === 0 && y === 1 && box.x1 <= 50 && box.y2 >= 950) ||
131
+ (x === 1 && y === 1 && box.x2 >= 950 && box.y2 >= 950)
132
+ if (anchored) boxes.push(box)
133
+ }
134
+ }
135
+ return boxes
136
+ }
137
+
138
+ /** 掩码修复主流程:mask 白色=要去除(Buffer 或文件路径);输出 PNG Buffer。
139
+ * 模型是固定 512×512 输入——整图缩进去会毁分辨率,所以按掩码连通域逐块处理:
140
+ * 裁出带边距的局部 → letterbox 进 512 修复 → 只把掩码内的像素贴回原图。 */
141
+ export async function inpaintLocal(imagePath: string, mask: Buffer | string): Promise<Buffer> {
142
+ const ort = await import('onnxruntime-node')
143
+ const sharpMod = await import('sharp')
144
+ const sharp = (sharpMod as any).default ?? sharpMod
145
+ const modelFile = await ensureLamaModel()
146
+ const session = await ort.InferenceSession.create(modelFile)
147
+ const S = 512
148
+
149
+ const { data: rgb, info } = await sharp(imagePath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true })
150
+ const W = info.width
151
+ const H = info.height
152
+ const maskRaw = await sharp(mask).rotate().resize(W, H, { fit: 'fill' }).greyscale().raw().toBuffer()
153
+
154
+ // 连通域(在 1/4 采样上 BFS,够用):返回像素坐标 bbox 列表
155
+ const boxes = connectedBoxes(maskRaw, W, H)
156
+
157
+ // 工作底图:逐块修复后叠回去
158
+ let out = rgb
159
+
160
+ for (const box of boxes) {
161
+ // 边距:给修复模型一点上下文
162
+ const margin = Math.round(Math.max(box.w, box.h) * 0.35) + 16
163
+ const x0 = Math.max(0, box.x - margin)
164
+ const y0 = Math.max(0, box.y - margin)
165
+ const x1 = Math.min(W, box.x + box.w + margin)
166
+ const y1 = Math.min(H, box.y + box.h + margin)
167
+ const cw = x1 - x0
168
+ const ch = y1 - y0
169
+
170
+ // letterbox 进 512×512(等比缩放居中,四周留黑)
171
+ const scale = Math.min(S / cw, S / ch)
172
+ const iw = Math.max(8, Math.round(cw * scale))
173
+ const ih = Math.max(8, Math.round(ch * scale))
174
+ const ox = Math.floor((S - iw) / 2)
175
+ const oy = Math.floor((S - ih) / 2)
176
+
177
+ const cropImg = await sharp(out, { raw: { width: W, height: H, channels: 3 } })
178
+ .extract({ left: x0, top: y0, width: cw, height: ch })
179
+ .resize(iw, ih, { fit: 'fill' })
180
+ .raw().toBuffer()
181
+ // ⚠ sharp 的坑:raw 1 通道经 extract→resize 会悄悄变 3 通道(长度×3),
182
+ // 后面按 1 通道读全是错位数据。掩码管线一律 toColourspace('b-w') 强制单通道。
183
+ const cropMask = await sharp(maskRaw, { raw: { width: W, height: H, channels: 1 } })
184
+ .extract({ left: x0, top: y0, width: cw, height: ch })
185
+ .resize(iw, ih, { fit: 'fill' })
186
+ .toColourspace('b-w')
187
+ .raw().toBuffer()
188
+
189
+ const imgC = Buffer.alloc(S * S * 3, 0)
190
+ const maskC = Buffer.alloc(S * S, 0)
191
+ for (let y = 0; y < ih; y++) {
192
+ for (let x = 0; x < iw; x++) {
193
+ const si = (y * iw + x) * 3
194
+ const di = ((oy + y) * S + (ox + x)) * 3
195
+ imgC[di] = cropImg[si]
196
+ imgC[di + 1] = cropImg[si + 1]
197
+ imgC[di + 2] = cropImg[si + 2]
198
+ maskC[(oy + y) * S + (ox + x)] = cropMask[y * iw + x] > 127 ? 1 : 0
199
+ }
200
+ }
201
+
202
+ const N = S * S
203
+ const imgF = new Float32Array(3 * N)
204
+ for (let i = 0; i < N; i++) {
205
+ imgF[i] = imgC[i * 3] / 255
206
+ imgF[N + i] = imgC[i * 3 + 1] / 255
207
+ imgF[2 * N + i] = imgC[i * 3 + 2] / 255
208
+ }
209
+ const results = await session.run({
210
+ image: new ort.Tensor('float32', imgF, [1, 3, S, S]),
211
+ mask: new ort.Tensor('float32', new Float32Array(maskC), [1, 1, S, S]),
212
+ })
213
+ const o = results[session.outputNames[0]].data as Float32Array
214
+
215
+ // 取回中心区域,缩回原尺寸
216
+ const back = Buffer.alloc(iw * ih * 3)
217
+ for (let y = 0; y < ih; y++) {
218
+ for (let x = 0; x < iw; x++) {
219
+ const so = ((oy + y) * S + (ox + x))
220
+ const di = (y * iw + x) * 3
221
+ back[di] = clamp255(o[so])
222
+ back[di + 1] = clamp255(o[N + so])
223
+ back[di + 2] = clamp255(o[2 * N + so])
224
+ }
225
+ }
226
+ const restoredFull = await sharp(back, { raw: { width: iw, height: ih, channels: 3 } })
227
+ .resize(cw, ch, { fit: 'fill' }).raw().toBuffer()
228
+ const maskFull = await sharp(cropMask, { raw: { width: iw, height: ih, channels: 1 } })
229
+ .resize(cw, ch, { fit: 'fill' })
230
+ .toColourspace('b-w')
231
+ .raw().toBuffer()
232
+
233
+ // 只把掩码内的像素贴回,其余保持原图(缩放往返会损伤未掩码区,不能整块覆盖)
234
+ const next = Buffer.from(out)
235
+ for (let i = 0; i < cw * ch; i++) {
236
+ if (maskFull[i] > 127) {
237
+ next[((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3] = restoredFull[i * 3]
238
+ next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 1] = restoredFull[i * 3 + 1]
239
+ next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 2] = restoredFull[i * 3 + 2]
240
+ }
241
+ }
242
+ out = next
243
+ process.stderr.write(` 修复块 ${cw}x${ch} @(${x0},${y0}) 完成\n`)
244
+ }
245
+
246
+ return sharp(out, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer()
247
+ }
248
+
249
+ const clamp255 = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
250
+
251
+ /** 掩码连通域 → 像素 bbox 列表。在 1/4 采样上网格 BFS,快且够准 */
252
+ function connectedBoxes(mask: Buffer, W: number, H: number): Array<{ x: number; y: number; w: number; h: number }> {
253
+ const step = 4
254
+ const gw = Math.ceil(W / step)
255
+ const gh = Math.ceil(H / step)
256
+ const grid = new Uint8Array(gw * gh)
257
+ for (let gy = 0; gy < gh; gy++) {
258
+ for (let gx = 0; gx < gw; gx++) {
259
+ let hit = false
260
+ for (let dy = 0; dy < step && !hit; dy++) {
261
+ for (let dx = 0; dx < step && !hit; dx++) {
262
+ const x = gx * step + dx
263
+ const y = gy * step + dy
264
+ if (x < W && y < H && mask[y * W + x] > 127) hit = true
265
+ }
266
+ }
267
+ grid[gy * gw + gx] = hit ? 1 : 0
268
+ }
269
+ }
270
+ const seen = new Uint8Array(gw * gh)
271
+ const boxes: Array<{ x: number; y: number; w: number; h: number }> = []
272
+ const q: number[] = []
273
+ for (let g = 0; g < grid.length; g++) {
274
+ if (!grid[g] || seen[g]) continue
275
+ q.length = 0
276
+ q.push(g)
277
+ seen[g] = 1
278
+ let minx = gw, miny = gh, maxx = -1, maxy = -1
279
+ while (q.length) {
280
+ const cur = q.pop()!
281
+ const cx = cur % gw
282
+ const cy = Math.floor(cur / gw)
283
+ minx = Math.min(minx, cx); maxx = Math.max(maxx, cx)
284
+ miny = Math.min(miny, cy); maxy = Math.max(maxy, cy)
285
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
286
+ const nx = cx + dx, ny = cy + dy
287
+ if (nx < 0 || ny < 0 || nx >= gw || ny >= gh) continue
288
+ const ni = ny * gw + nx
289
+ if (grid[ni] && !seen[ni]) { seen[ni] = 1; q.push(ni) }
290
+ }
291
+ }
292
+ boxes.push({ x: minx * step, y: miny * step, w: (maxx - minx + 1) * step, h: (maxy - miny + 1) * step })
293
+ }
294
+ return boxes
295
+ }
296
+
297
+ /** 从检测框生成掩码 Buffer(框外扩 2%,白=去除区) */
298
+ export async function maskFromBoxes(imagePath: string, boxes: Bbox[]): Promise<Buffer> {
299
+ const sharpMod = await import('sharp')
300
+ const sharp = (sharpMod as any).default ?? sharpMod
301
+ const meta = await sharp(imagePath).metadata()
302
+ const w = meta.width || 0
303
+ const h = meta.height || 0
304
+ if (!w || !h) throw new Error('读不到图片尺寸')
305
+ // 坐标体系一次判清:全部 ≤1.5 视为 0-1 归一化,否则按 qwen 惯例的 0-1000
306
+ const all = boxes.flatMap((b) => [b.x1, b.y1, b.x2, b.y2])
307
+ const denom = Math.max(...all) <= 1.5 ? 1 : 1000
308
+ const svg = boxes
309
+ .map((b) => {
310
+ const pad = 0.02
311
+ const x1 = Math.max(0, (b.x1 / denom) * w - w * pad)
312
+ const y1 = Math.max(0, (b.y1 / denom) * h - h * pad)
313
+ const x2 = Math.min(w, (b.x2 / denom) * w + w * pad)
314
+ const y2 = Math.min(h, (b.y2 / denom) * h + h * pad)
315
+ return `<rect x="${x1}" y="${y1}" width="${Math.max(1, x2 - x1)}" height="${Math.max(1, y2 - y1)}" fill="#fff"/>`
316
+ })
317
+ .join('')
318
+ const maskSvg = Buffer.from(`<svg width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#000"/>${svg}</svg>`)
319
+ return sharp(maskSvg).png().toBuffer()
320
+ }