museav-cli 2.3.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.
- package/CHANGELOG.md +14 -0
- package/README.md +21 -8
- package/dist/commands/img-tools.d.ts +13 -0
- package/dist/commands/img-tools.js +49 -0
- package/dist/commands/reverse.d.ts +4 -3
- package/dist/commands/reverse.js +3 -2
- package/dist/index.js +19 -3
- package/dist/local-upscale.d.ts +21 -0
- package/dist/local-upscale.js +115 -0
- package/dist/local-watermark.d.ts +21 -0
- package/dist/local-watermark.js +312 -0
- package/package.json +1 -1
- package/src/commands/img-tools.ts +61 -0
- package/src/commands/reverse.ts +7 -6
- package/src/index.ts +21 -3
- package/src/local-upscale.ts +123 -0
- package/src/local-watermark.ts +320 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.4.0 · 2026-08-17
|
|
4
|
+
|
|
5
|
+
**本地图像工具箱补全(免登录、零成本、macOS/Windows 通用)。** 均为轻量级工具,无大模型常驻内存。
|
|
6
|
+
|
|
7
|
+
- `upscale <file>`:Real-ESRGAN 超分(Vulkan GPU 加速),`--scale 2/3/4`、`--model`(通用照片 / 插画动漫)。首次自动下载引擎+模型(~65MB);M3 实测 4x 约 30 秒。
|
|
8
|
+
- `remove-watermark <file>`:本地去水印。**纯像素启发式自动定位**(零模型依赖,角标式水印实测零误检)→ LaMa 掩码修复;复杂画面用 `--mask` 手工指定。修复模型 ~200MB 按需下载、用完即释放。
|
|
9
|
+
- 依赖红线:本地绝不自动拉起开源大模型(reverse 已翻回 API 默认,`--local` 显式可选;Ollama 与 qwen3-vl 已从本机卸载)。
|
|
10
|
+
|
|
11
|
+
**中台与前端联动。**
|
|
12
|
+
|
|
13
|
+
- 上游目录移除 gemini 死条目(路由/密钥早已清除,目录残留会误判读图有冗余),相关测试 mock 统一改用 volcengine-plan-vision。
|
|
14
|
+
- museav-web 参考图选择器接入工作区素材库:上传同步入库、素材库面板点选直接垫图、工作区切换自动刷新。
|
|
15
|
+
- 发布卡片样式重排:标题/升级命令/仓库链接全部对齐 `museav-cli` 新名(旧包名已停更,照旧卡片敲命令装不到新版),剥掉 changelog 标题噪声,双列字段 + 三按钮。
|
|
16
|
+
|
|
3
17
|
## 2.3.0 · 2026-08-17
|
|
4
18
|
|
|
5
19
|
**项目(工作区)与项目素材库。** 层级:平台 → 账户 → 工作区,素材挂工作区——人像库的项目出模特图、产品库的项目出电商图,业务隔离互不污染。`--project` 收 id 或名称。
|
package/README.md
CHANGED
|
@@ -237,24 +237,22 @@ museav gen --template "$ID" --fields '{"artist":"..."}'
|
|
|
237
237
|
|
|
238
238
|
### 图片逆向 `reverse`
|
|
239
239
|
|
|
240
|
-
逆推出图 prompt
|
|
240
|
+
逆推出图 prompt,可以直接拿去再出一张同风格。**默认走中台 API**(SCULPT 六要素:主体/构图/世界观/光影/输出/质感);`--local` 可显式切本地 Ollama(需自备 qwen3-vl,本地不可用时自动回落 API):
|
|
241
241
|
|
|
242
242
|
```bash
|
|
243
|
-
#
|
|
243
|
+
# 默认:中台 API(需登录)
|
|
244
244
|
museav reverse photo.png
|
|
245
245
|
|
|
246
|
-
#
|
|
247
|
-
museav reverse
|
|
246
|
+
# 显式本地:需 ollama pull qwen3-vl:8b 自备模型(本地大模型默认不自动拉起)
|
|
247
|
+
museav reverse photo.png --local
|
|
248
248
|
|
|
249
|
-
#
|
|
250
|
-
museav reverse photo.png
|
|
249
|
+
# 图片 URL 走中台 API
|
|
250
|
+
museav reverse https://example.com/photo.png
|
|
251
251
|
|
|
252
252
|
# 逆向 + 出图,一条龙
|
|
253
253
|
museav gen --prompt "$(museav reverse photo.png)"
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
-
本地路依赖:Ollama 运行中(`brew services start ollama`)+ 模型在位(`ollama pull qwen3-vl:8b`,换档位设 `MUSEAV_LOCAL_VLM`,自定地址设 `OLLAMA_HOST`)。缺哪个都会提示对应命令并回落 API。
|
|
257
|
-
|
|
258
256
|
分析详情打到 stderr(人看),**stdout 只输出英文 prompt**(机器用,方便管道)。
|
|
259
257
|
|
|
260
258
|
`reverse` **只读图**,不会顺手帮你建模板。要把图做成模板看下一节——中台 2026-08-16 把这两件事
|
|
@@ -341,6 +339,21 @@ museav gen --prompt "$(museav reverse $(museav remove-bg shoe.png))" # 抠图
|
|
|
341
339
|
- 两个命令的输出默认带后缀(`-min` / `-nobg`),不会覆盖你的输入文件;要覆盖已存在的输出加 `--overwrite`。
|
|
342
340
|
- 可选依赖缺失时不炸,报一行重装指引(`npm install -g museav-cli`)。
|
|
343
341
|
|
|
342
|
+
### 超分 `upscale` 与去水印 `remove-watermark`(轻量工具,无大模型常驻)
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
# 超分放大(Real-ESRGAN + Vulkan GPU;首次自动下载引擎+模型 ~65MB)
|
|
346
|
+
museav upscale photo.jpg --scale 4 # 默认输出 <名>-4x.png
|
|
347
|
+
museav upscale anime.png --model realesrgan-x4plus-anime
|
|
348
|
+
|
|
349
|
+
# 去水印(纯像素启发式定位 → LaMa 修复,零模型依赖;修复模型 ~200MB 按需下载)
|
|
350
|
+
museav remove-watermark photo.jpg # 角标式水印自动定位
|
|
351
|
+
museav remove-watermark photo.jpg --mask mask.png # 复杂画面手工掩码(白=去除区)
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- 依赖红线:本地**绝不自动拉起开源大模型**——这些工具是轻量 CNN/传统算法,用完即释放内存。reverse 的本地路已翻回显式 `--local`(需自备 Ollama),默认走中台 API。
|
|
355
|
+
- 二进制与模型缓存:`~/.museav-bin/upscayl`(引擎)、`~/.museav-models/`(模型),Windows 对应 `%USERPROFILE%` 下同名目录;删除即彻底清理。
|
|
356
|
+
|
|
344
357
|
### 项目(工作区)与项目素材库 `projects`
|
|
345
358
|
|
|
346
359
|
层级:**平台 → 账户 → 工作区**。一个账户最多 5 个项目,每个项目挂自己的素材库——「人像库的项目出模特图、产品库的项目出电商图」,业务隔离互不污染:
|
|
@@ -14,3 +14,16 @@ export interface RemoveBgOpts {
|
|
|
14
14
|
overwrite?: boolean;
|
|
15
15
|
}
|
|
16
16
|
export declare function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<void>;
|
|
17
|
+
export interface RemoveWatermarkOpts {
|
|
18
|
+
out?: string;
|
|
19
|
+
mask?: string;
|
|
20
|
+
overwrite?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function removeWatermarkCmd(input: string, opts: RemoveWatermarkOpts): Promise<void>;
|
|
23
|
+
export interface UpscaleOpts {
|
|
24
|
+
out?: string;
|
|
25
|
+
scale?: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
overwrite?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function upscaleCmd(input: string, opts: UpscaleOpts): Promise<void>;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { basename, dirname, extname, join } from 'node:path';
|
|
6
6
|
import { removeBackgroundLocal, BG_MODELS } from '../local-bg.js';
|
|
7
|
+
import { upscaleLocal, UPSCALE_MODELS } from '../local-upscale.js';
|
|
8
|
+
import { detectWatermarkBoxes, inpaintLocal, maskFromBoxes } from '../local-watermark.js';
|
|
7
9
|
async function fileExists(path) {
|
|
8
10
|
try {
|
|
9
11
|
return (await stat(path)).isFile();
|
|
@@ -79,3 +81,50 @@ export async function removeBgCmd(input, opts) {
|
|
|
79
81
|
process.stderr.write(`✅ 抠图完成(${BG_MODELS[modelKey].label},用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`);
|
|
80
82
|
console.log(outPath);
|
|
81
83
|
}
|
|
84
|
+
export async function removeWatermarkCmd(input, opts) {
|
|
85
|
+
if (!(await fileExists(input)))
|
|
86
|
+
throw new Error(`文件不存在: ${input}`);
|
|
87
|
+
const outPath = opts.out || defaultOut(input, 'clean', 'png');
|
|
88
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
89
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`);
|
|
90
|
+
}
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
let mask;
|
|
93
|
+
if (opts.mask) {
|
|
94
|
+
if (!(await fileExists(opts.mask)))
|
|
95
|
+
throw new Error(`掩码文件不存在: ${opts.mask}`);
|
|
96
|
+
mask = opts.mask;
|
|
97
|
+
process.stderr.write('使用手工掩码,跳过自动定位\n');
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// 纯像素启发式定位水印(零模型依赖),失败时明确指引改用 --mask
|
|
101
|
+
const boxes = await detectWatermarkBoxes(input);
|
|
102
|
+
if (!boxes.length)
|
|
103
|
+
throw new Error('自动定位没找到水印(角标式半透明水印通常可识别;复杂画面请用 --mask 手工指定)');
|
|
104
|
+
process.stderr.write(`定位到 ${boxes.length} 处水印:${boxes.map((b) => `(${b.x1},${b.y1})-(${b.x2},${b.y2})`).join(' ')}\n`);
|
|
105
|
+
mask = await maskFromBoxes(input, boxes);
|
|
106
|
+
}
|
|
107
|
+
const png = await inpaintLocal(input, mask);
|
|
108
|
+
await writeFile(outPath, png);
|
|
109
|
+
process.stderr.write(`✅ 去水印完成(用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`);
|
|
110
|
+
console.log(outPath);
|
|
111
|
+
}
|
|
112
|
+
export async function upscaleCmd(input, opts) {
|
|
113
|
+
if (!(await fileExists(input)))
|
|
114
|
+
throw new Error(`文件不存在: ${input}`);
|
|
115
|
+
const scale = opts.scale ? Number(opts.scale) : 4;
|
|
116
|
+
if (![2, 3, 4].includes(scale))
|
|
117
|
+
throw new Error('--scale 只支持 2 / 3 / 4');
|
|
118
|
+
const model = (opts.model || 'realesrgan-x4plus');
|
|
119
|
+
if (!(model in UPSCALE_MODELS))
|
|
120
|
+
throw new Error(`--model 只支持 ${Object.keys(UPSCALE_MODELS).join(' / ')}`);
|
|
121
|
+
const outPath = opts.out || defaultOut(input, `${scale}x`, 'png');
|
|
122
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
123
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`);
|
|
124
|
+
}
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
await upscaleLocal({ input, output: outPath, scale, model });
|
|
127
|
+
const size = (await stat(outPath)).size;
|
|
128
|
+
process.stderr.write(`✅ 超分完成(${UPSCALE_MODELS[model].label},${scale}x,用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(size)})\n`);
|
|
129
|
+
console.log(outPath);
|
|
130
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 默认走中台 API(快、稳定、不需本地模型);--local 可切本地 Ollama(需自备 qwen3-vl,
|
|
3
|
+
* 仅在用户显式要求时使用——本地大模型默认不拉起,不给用户的内存添负担)。
|
|
4
|
+
* client 懒构造:本地路成功就完全不碰中台凭证。 */
|
|
5
5
|
import type { StudioClient } from '../client.js';
|
|
6
6
|
export declare function reverse(getClient: () => StudioClient, input: string, opts?: {
|
|
7
7
|
api?: boolean;
|
|
8
|
+
local?: boolean;
|
|
8
9
|
}): Promise<void>;
|
package/dist/commands/reverse.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js';
|
|
2
2
|
export async function reverse(getClient, input, opts = {}) {
|
|
3
3
|
const isUrl = /^https?:\/\//.test(input);
|
|
4
|
-
|
|
4
|
+
// 本地路只在用户显式 --local 且输入是本地文件时尝试;服务不可用给出指引后回落 API
|
|
5
|
+
if (opts.local && !isUrl) {
|
|
5
6
|
const status = await checkLocalVlm();
|
|
6
7
|
if (status.running && status.modelPresent) {
|
|
7
8
|
try {
|
|
@@ -19,7 +20,7 @@ export async function reverse(getClient, input, opts = {}) {
|
|
|
19
20
|
process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`);
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
else if (
|
|
23
|
+
else if (opts.local && isUrl) {
|
|
23
24
|
process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`);
|
|
24
25
|
}
|
|
25
26
|
const client = getClient();
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { bindFeishu } from './commands/bind-feishu.js';
|
|
|
15
15
|
import { printWelcome } from './commands/welcome.js';
|
|
16
16
|
import { gen } from './commands/gen.js';
|
|
17
17
|
import { reverse } from './commands/reverse.js';
|
|
18
|
-
import { compressCmd, removeBgCmd } from './commands/img-tools.js';
|
|
18
|
+
import { compressCmd, removeBgCmd, upscaleCmd, removeWatermarkCmd } from './commands/img-tools.js';
|
|
19
19
|
import { projects, createProject, listAssets, addAsset, removeAsset } from './commands/projects.js';
|
|
20
20
|
import { imageToTemplate } from './commands/image-to-template.js';
|
|
21
21
|
import { upload } from './commands/upload.js';
|
|
@@ -184,6 +184,21 @@ program
|
|
|
184
184
|
.option('--model <name>', 'isnet(默认,质量优先)/ u2net')
|
|
185
185
|
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
186
186
|
.action(asyncRun((input, opts) => removeBgCmd(input, opts)));
|
|
187
|
+
program
|
|
188
|
+
.command('upscale <file>')
|
|
189
|
+
.description('本地超分放大(Real-ESRGAN + Vulkan GPU,免登录):默认 4x 输出 PNG。首次使用自动下载引擎与模型(~65MB,缓存 ~/.museav-bin 与 ~/.museav-models)')
|
|
190
|
+
.option('--out <path>', '输出路径(默认 <名>-<N>x.png)')
|
|
191
|
+
.option('--scale <n>', '放大倍数 2 / 3 / 4,默认 4')
|
|
192
|
+
.option('--model <name>', 'realesrgan-x4plus(通用照片,默认)/ realesrgan-x4plus-anime(插画动漫)')
|
|
193
|
+
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
194
|
+
.action(asyncRun((input, opts) => upscaleCmd(input, opts)));
|
|
195
|
+
program
|
|
196
|
+
.command('remove-watermark <file>')
|
|
197
|
+
.description('本地去水印(免登录):纯像素启发式自动定位水印 → LaMa 掩码修复,零模型依赖。首次使用自动下载修复模型(~200MB);复杂画面用 --mask 手工指定(白=去除区)')
|
|
198
|
+
.option('--out <path>', '输出路径(默认 <名>-clean.png)')
|
|
199
|
+
.option('--mask <file>', '手工掩码图(白色=要去除的区域),跳过自动定位')
|
|
200
|
+
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
201
|
+
.action(asyncRun((input, opts) => removeWatermarkCmd(input, opts)));
|
|
187
202
|
// 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
|
|
188
203
|
const projectsCmd = program
|
|
189
204
|
.command('projects')
|
|
@@ -214,8 +229,9 @@ assetsCmd
|
|
|
214
229
|
.action(withClient((client, id) => removeAsset(client, { id })));
|
|
215
230
|
program
|
|
216
231
|
.command('reverse <input>')
|
|
217
|
-
.description('读图:反推 SCULPT prompt,stdout 输出英文 prompt
|
|
218
|
-
.option('--api', '
|
|
232
|
+
.description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。默认走中台 API(需登录);--local 显式切本地 Ollama(需自备 qwen3-vl)。只读图;要做成模板用 image-to-template')
|
|
233
|
+
.option('--api', '强制走中台 API(默认路径)')
|
|
234
|
+
.option('--local', '改用本地 Ollama 读图(需先 ollama pull qwen3-vl:8b;本地不可用时回落 API)')
|
|
219
235
|
.action(withLazyClient((getClient, input, opts) => reverse(getClient, input, opts)));
|
|
220
236
|
program
|
|
221
237
|
.command('image-to-template <input>')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const UPSCALE_MODELS: {
|
|
2
|
+
readonly 'realesrgan-x4plus': {
|
|
3
|
+
readonly label: '通用照片(默认)';
|
|
4
|
+
};
|
|
5
|
+
readonly 'realesrgan-x4plus-anime': {
|
|
6
|
+
readonly label: '插画/动漫';
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export type UpscaleModel = keyof typeof UPSCALE_MODELS;
|
|
10
|
+
/** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
|
|
11
|
+
export declare function ensureUpscaleRuntime(): Promise<{
|
|
12
|
+
exe: string;
|
|
13
|
+
modelDir: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
|
|
16
|
+
export declare function upscaleLocal(opts: {
|
|
17
|
+
input: string;
|
|
18
|
+
output: string;
|
|
19
|
+
scale: number;
|
|
20
|
+
model: UpscaleModel;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地超分(放大)—— upscale 的核心实现。
|
|
3
|
+
* 引擎:upscayl-ncnn(Real-ESRGAN 的 ncnn/Vulkan 后端,AGPL-3.0 —— 它是独立进程
|
|
4
|
+
* 二进制而非链接进 npm 包,CLI 与之分发解耦,不构成合并作品;这与把 AGPL 代码
|
|
5
|
+
* 编进依赖是两回事)。
|
|
6
|
+
* 跨平台:macOS(universal)/ Windows / Linux 二进制都在 upscayl 官方 release;
|
|
7
|
+
* 解压统一走 `tar -xf`(Win10+/macOS/Linux 都自带 libarchive 版 tar,不依赖 unzip);
|
|
8
|
+
* 二进制落地后 chmod +x(Windows 不需要)。全程 node:child_process execFile,零 shell。
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile, stat, chmod } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
const run = promisify(execFile);
|
|
16
|
+
// 锁定已实测的版本(20251207-174704,macOS universal 实测可用),升级要重新过测试
|
|
17
|
+
const UPSCAYL_TAG = '20251207-174704';
|
|
18
|
+
const UPSCAYL_BASE = `https://github.com/upscayl/upscayl-ncnn/releases/download/${UPSCAYL_TAG}/upscayl-bin-${UPSCAYL_TAG}`;
|
|
19
|
+
// 模型从 Real-ESRGAN 官方 release 的 zip 里取(只取需要的两个,别拖全量)
|
|
20
|
+
const MODEL_ZIP = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-macos.zip';
|
|
21
|
+
export const UPSCALE_MODELS = {
|
|
22
|
+
'realesrgan-x4plus': { label: '通用照片(默认)' },
|
|
23
|
+
'realesrgan-x4plus-anime': { label: '插画/动漫' },
|
|
24
|
+
};
|
|
25
|
+
const BIN_DIR = join(homedir(), '.museav-bin', 'upscayl');
|
|
26
|
+
const MODEL_DIR = join(homedir(), '.museav-models');
|
|
27
|
+
function platformAsset() {
|
|
28
|
+
if (process.platform === 'win32')
|
|
29
|
+
return { zip: `${UPSCAYL_BASE}-windows.zip`, exe: 'upscayl-bin.exe' };
|
|
30
|
+
if (process.platform === 'darwin')
|
|
31
|
+
return { zip: `${UPSCAYL_BASE}-macos.zip`, exe: 'upscayl-bin' };
|
|
32
|
+
return { zip: `${UPSCAYL_BASE}-linux.zip`, exe: 'upscayl-bin' };
|
|
33
|
+
}
|
|
34
|
+
async function exists(path) {
|
|
35
|
+
try {
|
|
36
|
+
await stat(path);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function download(url, dest, label) {
|
|
44
|
+
const resp = await fetch(url);
|
|
45
|
+
if (!resp.ok || !resp.body)
|
|
46
|
+
throw new Error(`${label} 下载失败 HTTP ${resp.status}`);
|
|
47
|
+
const total = Number(resp.headers.get('content-length') || 0);
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let got = 0;
|
|
50
|
+
const reader = resp.body.getReader();
|
|
51
|
+
for (;;) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done)
|
|
54
|
+
break;
|
|
55
|
+
chunks.push(Buffer.from(value));
|
|
56
|
+
got += value.length;
|
|
57
|
+
if (total)
|
|
58
|
+
process.stderr.write(` ${label} ${(got / 1048576).toFixed(1)}/${(total / 1048576).toFixed(0)}MB\r`);
|
|
59
|
+
}
|
|
60
|
+
process.stderr.write('\n');
|
|
61
|
+
await writeFile(dest, Buffer.concat(chunks));
|
|
62
|
+
}
|
|
63
|
+
/** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
|
|
64
|
+
export async function ensureUpscaleRuntime() {
|
|
65
|
+
const { zip, exe } = platformAsset();
|
|
66
|
+
const exePath = join(BIN_DIR, exe);
|
|
67
|
+
const modelDir = join(MODEL_DIR, 'realesrgan');
|
|
68
|
+
const paramPath = join(modelDir, 'realesrgan-x4plus.param');
|
|
69
|
+
if (!(await exists(exePath))) {
|
|
70
|
+
await mkdir(BIN_DIR, { recursive: true });
|
|
71
|
+
const zipPath = join(BIN_DIR, `dl-${process.platform}.zip`);
|
|
72
|
+
process.stderr.write(`↓ 首次使用,下载超分引擎(~15MB,一次性,缓存到 ${BIN_DIR})...\n`);
|
|
73
|
+
await download(zip, zipPath, '引擎');
|
|
74
|
+
// tar -xf 解压:Win10+/macOS/Linux 自带,比依赖 unzip 稳
|
|
75
|
+
await run('tar', ['-xf', zipPath, '-C', BIN_DIR], { windowsHide: true });
|
|
76
|
+
// zip 里是 upscayl-bin-<tag>-<os>/upscayl-bin,拍平到 BIN_DIR
|
|
77
|
+
const { readdir } = await import('node:fs/promises');
|
|
78
|
+
for (const entry of await readdir(BIN_DIR, { withFileTypes: true })) {
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
const { rename, readdir: rd } = await import('node:fs/promises');
|
|
81
|
+
for (const f of await rd(join(BIN_DIR, entry.name))) {
|
|
82
|
+
await rename(join(BIN_DIR, entry.name, f), join(BIN_DIR, f));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (process.platform !== 'win32')
|
|
87
|
+
await chmod(exePath, 0o755);
|
|
88
|
+
if (!(await exists(exePath)))
|
|
89
|
+
throw new Error(`解压后未找到 ${exe},请检查 ${BIN_DIR}`);
|
|
90
|
+
const { unlink } = await import('node:fs/promises');
|
|
91
|
+
await unlink(zipPath).catch(() => { });
|
|
92
|
+
}
|
|
93
|
+
if (!(await exists(paramPath))) {
|
|
94
|
+
await mkdir(modelDir, { recursive: true });
|
|
95
|
+
const zipPath = join(MODEL_DIR, 'dl-models.zip');
|
|
96
|
+
process.stderr.write('↓ 首次使用,下载超分模型(~50MB,一次性)...\n');
|
|
97
|
+
await download(MODEL_ZIP, zipPath, '模型');
|
|
98
|
+
await run('tar', ['-xf', zipPath, '-C', MODEL_DIR, 'models/realesrgan-x4plus.param', 'models/realesrgan-x4plus.bin', 'models/realesrgan-x4plus-anime.param', 'models/realesrgan-x4plus-anime.bin'], { windowsHide: true });
|
|
99
|
+
const { rename, rm } = await import('node:fs/promises');
|
|
100
|
+
for (const f of ['realesrgan-x4plus.param', 'realesrgan-x4plus.bin', 'realesrgan-x4plus-anime.param', 'realesrgan-x4plus-anime.bin']) {
|
|
101
|
+
await rename(join(MODEL_DIR, 'models', f), join(modelDir, f)).catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
await rm(join(MODEL_DIR, 'models'), { recursive: true, force: true }).catch(() => { });
|
|
104
|
+
await rm(zipPath, { force: true }).catch(() => { });
|
|
105
|
+
if (!(await exists(paramPath)))
|
|
106
|
+
throw new Error(`模型解压失败,请检查 ${modelDir}`);
|
|
107
|
+
}
|
|
108
|
+
return { exe: exePath, modelDir };
|
|
109
|
+
}
|
|
110
|
+
/** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
|
|
111
|
+
export async function upscaleLocal(opts) {
|
|
112
|
+
const { exe, modelDir } = await ensureUpscaleRuntime();
|
|
113
|
+
// 不走 shell 拼接;路径原样传参,空格/中文路径都安全
|
|
114
|
+
await run(exe, ['-i', opts.input, '-o', opts.output, '-s', String(opts.scale), '-n', opts.model, '-m', modelDir], { windowsHide: true });
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Bbox {
|
|
2
|
+
x1: number;
|
|
3
|
+
y1: number;
|
|
4
|
+
x2: number;
|
|
5
|
+
y2: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 纯像素启发式水印定位(零模型依赖)。
|
|
9
|
+
* 原理:半透明水印(角标/文字)是「低对比、高频、铺在大片区域的细碎纹理」,
|
|
10
|
+
* 把四角区域做中值模糊后与原图差分,叠字区会出现稳定的高差值像素团。
|
|
11
|
+
* 判定保守:角区差分像素占比在 [0.3%, 8%] 才算水印(太少=没叠字,
|
|
12
|
+
* 太多=画面本身纹理复杂,不硬修);坐标 0-1000 归一化输出。
|
|
13
|
+
* 误检最坏是 LaMa 重绘一块(轻微损伤),另有 --mask 手工精确兜底。
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectWatermarkBoxes(imagePath: string): Promise<Bbox[]>;
|
|
16
|
+
/** 掩码修复主流程:mask 白色=要去除(Buffer 或文件路径);输出 PNG Buffer。
|
|
17
|
+
* 模型是固定 512×512 输入——整图缩进去会毁分辨率,所以按掩码连通域逐块处理:
|
|
18
|
+
* 裁出带边距的局部 → letterbox 进 512 修复 → 只把掩码内的像素贴回原图。 */
|
|
19
|
+
export declare function inpaintLocal(imagePath: string, mask: Buffer | string): Promise<Buffer>;
|
|
20
|
+
/** 从检测框生成掩码 Buffer(框外扩 2%,白=去除区) */
|
|
21
|
+
export declare function maskFromBoxes(imagePath: string, boxes: Bbox[]): Promise<Buffer>;
|
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
const LAMA_URLS = [
|
|
11
|
+
'https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
|
|
12
|
+
// 国内网络拉不动 HF 时的镜像(同一文件;镜像没缓存会回落直链,所以放第二位)
|
|
13
|
+
'https://hf-mirror.com/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx',
|
|
14
|
+
];
|
|
15
|
+
const LAMA_PATH = join(homedir(), '.museav-models', 'lama', 'lama_fp32.onnx');
|
|
16
|
+
async function ensureLamaModel() {
|
|
17
|
+
try {
|
|
18
|
+
if ((await stat(LAMA_PATH)).size > 50_000_000)
|
|
19
|
+
return LAMA_PATH;
|
|
20
|
+
}
|
|
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 = null;
|
|
25
|
+
for (const url of LAMA_URLS) {
|
|
26
|
+
try {
|
|
27
|
+
const resp = await fetch(url, { redirect: 'follow' });
|
|
28
|
+
if (!resp.ok || !resp.body)
|
|
29
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
30
|
+
const total = Number(resp.headers.get('content-length') || 0);
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let got = 0;
|
|
33
|
+
const reader = resp.body.getReader();
|
|
34
|
+
for (;;) {
|
|
35
|
+
const { done, value } = await reader.read();
|
|
36
|
+
if (done)
|
|
37
|
+
break;
|
|
38
|
+
chunks.push(Buffer.from(value));
|
|
39
|
+
got += value.length;
|
|
40
|
+
if (total)
|
|
41
|
+
process.stderr.write(` ${(got / 1048576).toFixed(0)}/${(total / 1048576).toFixed(0)}MB\r`);
|
|
42
|
+
}
|
|
43
|
+
process.stderr.write('\n');
|
|
44
|
+
const buf = Buffer.concat(chunks);
|
|
45
|
+
if (buf.length < 50_000_000)
|
|
46
|
+
throw new Error('下载不完整');
|
|
47
|
+
await writeFile(LAMA_PATH, buf);
|
|
48
|
+
return LAMA_PATH;
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
lastErr = e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`LaMa 模型下载失败(${lastErr instanceof Error ? lastErr.message : lastErr}),也可手动放到 ${LAMA_PATH}`);
|
|
55
|
+
}
|
|
56
|
+
/** LaMa 要求边长被 8 整除:右/下边缘复制填充,修完再裁回 */
|
|
57
|
+
const pad8 = (n) => Math.ceil(n / 8) * 8;
|
|
58
|
+
/**
|
|
59
|
+
* 纯像素启发式水印定位(零模型依赖)。
|
|
60
|
+
* 原理:半透明水印(角标/文字)是「低对比、高频、铺在大片区域的细碎纹理」,
|
|
61
|
+
* 把四角区域做中值模糊后与原图差分,叠字区会出现稳定的高差值像素团。
|
|
62
|
+
* 判定保守:角区差分像素占比在 [0.3%, 8%] 才算水印(太少=没叠字,
|
|
63
|
+
* 太多=画面本身纹理复杂,不硬修);坐标 0-1000 归一化输出。
|
|
64
|
+
* 误检最坏是 LaMa 重绘一块(轻微损伤),另有 --mask 手工精确兜底。
|
|
65
|
+
*/
|
|
66
|
+
export async function detectWatermarkBoxes(imagePath) {
|
|
67
|
+
const sharpMod = await import('sharp');
|
|
68
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
69
|
+
const meta = await sharp(imagePath).rotate().metadata();
|
|
70
|
+
const W = meta.width || 0;
|
|
71
|
+
const H = meta.height || 0;
|
|
72
|
+
if (!W || !H)
|
|
73
|
+
throw new Error('读不到图片尺寸');
|
|
74
|
+
const corners = [
|
|
75
|
+
{ x: 0, y: 0 },
|
|
76
|
+
{ x: 1, y: 0 },
|
|
77
|
+
{ x: 0, y: 1 },
|
|
78
|
+
{ x: 1, y: 1 },
|
|
79
|
+
];
|
|
80
|
+
const cw = Math.round(W * 0.35);
|
|
81
|
+
const ch = Math.round(H * 0.35);
|
|
82
|
+
const boxes = [];
|
|
83
|
+
for (const { x, y } of corners) {
|
|
84
|
+
const left = x ? W - cw : 0;
|
|
85
|
+
const top = y ? H - ch : 0;
|
|
86
|
+
const { data: orig } = await sharp(imagePath).rotate()
|
|
87
|
+
.extract({ left, top, width: cw, height: ch }).raw().toBuffer({ resolveWithObject: true });
|
|
88
|
+
const { data: blurred } = await sharp(imagePath).rotate()
|
|
89
|
+
.extract({ left, top, width: cw, height: ch })
|
|
90
|
+
.median(7)
|
|
91
|
+
.raw().toBuffer({ resolveWithObject: true });
|
|
92
|
+
// 差分 + 阈值;记录超阈值像素的 bbox
|
|
93
|
+
let cnt = 0;
|
|
94
|
+
let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1;
|
|
95
|
+
for (let i = 0; i < cw * ch; i++) {
|
|
96
|
+
const dr = Math.abs(orig[i * 3] - blurred[i * 3]);
|
|
97
|
+
const dg = Math.abs(orig[i * 3 + 1] - blurred[i * 3 + 1]);
|
|
98
|
+
const db = Math.abs(orig[i * 3 + 2] - blurred[i * 3 + 2]);
|
|
99
|
+
if (dr + dg + db > 90) { // 每通道均值 >30
|
|
100
|
+
cnt++;
|
|
101
|
+
const px = i % cw;
|
|
102
|
+
const py = Math.floor(i / cw);
|
|
103
|
+
if (px < minX)
|
|
104
|
+
minX = px;
|
|
105
|
+
if (px > maxX)
|
|
106
|
+
maxX = px;
|
|
107
|
+
if (py < minY)
|
|
108
|
+
minY = py;
|
|
109
|
+
if (py > maxY)
|
|
110
|
+
maxY = py;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const ratio = cnt / (cw * ch);
|
|
114
|
+
if (ratio >= 0.003 && ratio <= 0.08 && maxX >= 0) {
|
|
115
|
+
// 像素 → 0-1000 归一化(带 2% 外扩)
|
|
116
|
+
const pad = 0.02;
|
|
117
|
+
const box = {
|
|
118
|
+
x1: Math.max(0, ((left + minX) / W) * 1000 - pad * 1000),
|
|
119
|
+
y1: Math.max(0, ((top + minY) / H) * 1000 - pad * 1000),
|
|
120
|
+
x2: Math.min(1000, ((left + maxX) / W) * 1000 + pad * 1000),
|
|
121
|
+
y2: Math.min(1000, ((top + maxY) / H) * 1000 + pad * 1000),
|
|
122
|
+
};
|
|
123
|
+
// 贴角锚定:水印通常贴着所在角的边沿(5% 容差)。背景装饰/纹理也常被差分命中,
|
|
124
|
+
// 但它们离角远——角标类水印的判据就是「贴角」,否则宁可不修。
|
|
125
|
+
const anchored = (x === 0 && y === 0 && box.x1 <= 50 && box.y1 <= 50) ||
|
|
126
|
+
(x === 1 && y === 0 && box.x2 >= 950 && box.y1 <= 50) ||
|
|
127
|
+
(x === 0 && y === 1 && box.x1 <= 50 && box.y2 >= 950) ||
|
|
128
|
+
(x === 1 && y === 1 && box.x2 >= 950 && box.y2 >= 950);
|
|
129
|
+
if (anchored)
|
|
130
|
+
boxes.push(box);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return boxes;
|
|
134
|
+
}
|
|
135
|
+
/** 掩码修复主流程:mask 白色=要去除(Buffer 或文件路径);输出 PNG Buffer。
|
|
136
|
+
* 模型是固定 512×512 输入——整图缩进去会毁分辨率,所以按掩码连通域逐块处理:
|
|
137
|
+
* 裁出带边距的局部 → letterbox 进 512 修复 → 只把掩码内的像素贴回原图。 */
|
|
138
|
+
export async function inpaintLocal(imagePath, mask) {
|
|
139
|
+
const ort = await import('onnxruntime-node');
|
|
140
|
+
const sharpMod = await import('sharp');
|
|
141
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
142
|
+
const modelFile = await ensureLamaModel();
|
|
143
|
+
const session = await ort.InferenceSession.create(modelFile);
|
|
144
|
+
const S = 512;
|
|
145
|
+
const { data: rgb, info } = await sharp(imagePath).rotate().removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
146
|
+
const W = info.width;
|
|
147
|
+
const H = info.height;
|
|
148
|
+
const maskRaw = await sharp(mask).rotate().resize(W, H, { fit: 'fill' }).greyscale().raw().toBuffer();
|
|
149
|
+
// 连通域(在 1/4 采样上 BFS,够用):返回像素坐标 bbox 列表
|
|
150
|
+
const boxes = connectedBoxes(maskRaw, W, H);
|
|
151
|
+
// 工作底图:逐块修复后叠回去
|
|
152
|
+
let out = rgb;
|
|
153
|
+
for (const box of boxes) {
|
|
154
|
+
// 边距:给修复模型一点上下文
|
|
155
|
+
const margin = Math.round(Math.max(box.w, box.h) * 0.35) + 16;
|
|
156
|
+
const x0 = Math.max(0, box.x - margin);
|
|
157
|
+
const y0 = Math.max(0, box.y - margin);
|
|
158
|
+
const x1 = Math.min(W, box.x + box.w + margin);
|
|
159
|
+
const y1 = Math.min(H, box.y + box.h + margin);
|
|
160
|
+
const cw = x1 - x0;
|
|
161
|
+
const ch = y1 - y0;
|
|
162
|
+
// letterbox 进 512×512(等比缩放居中,四周留黑)
|
|
163
|
+
const scale = Math.min(S / cw, S / ch);
|
|
164
|
+
const iw = Math.max(8, Math.round(cw * scale));
|
|
165
|
+
const ih = Math.max(8, Math.round(ch * scale));
|
|
166
|
+
const ox = Math.floor((S - iw) / 2);
|
|
167
|
+
const oy = Math.floor((S - ih) / 2);
|
|
168
|
+
const cropImg = await sharp(out, { raw: { width: W, height: H, channels: 3 } })
|
|
169
|
+
.extract({ left: x0, top: y0, width: cw, height: ch })
|
|
170
|
+
.resize(iw, ih, { fit: 'fill' })
|
|
171
|
+
.raw().toBuffer();
|
|
172
|
+
// ⚠ sharp 的坑:raw 1 通道经 extract→resize 会悄悄变 3 通道(长度×3),
|
|
173
|
+
// 后面按 1 通道读全是错位数据。掩码管线一律 toColourspace('b-w') 强制单通道。
|
|
174
|
+
const cropMask = await sharp(maskRaw, { raw: { width: W, height: H, channels: 1 } })
|
|
175
|
+
.extract({ left: x0, top: y0, width: cw, height: ch })
|
|
176
|
+
.resize(iw, ih, { fit: 'fill' })
|
|
177
|
+
.toColourspace('b-w')
|
|
178
|
+
.raw().toBuffer();
|
|
179
|
+
const imgC = Buffer.alloc(S * S * 3, 0);
|
|
180
|
+
const maskC = Buffer.alloc(S * S, 0);
|
|
181
|
+
for (let y = 0; y < ih; y++) {
|
|
182
|
+
for (let x = 0; x < iw; x++) {
|
|
183
|
+
const si = (y * iw + x) * 3;
|
|
184
|
+
const di = ((oy + y) * S + (ox + x)) * 3;
|
|
185
|
+
imgC[di] = cropImg[si];
|
|
186
|
+
imgC[di + 1] = cropImg[si + 1];
|
|
187
|
+
imgC[di + 2] = cropImg[si + 2];
|
|
188
|
+
maskC[(oy + y) * S + (ox + x)] = cropMask[y * iw + x] > 127 ? 1 : 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const N = S * S;
|
|
192
|
+
const imgF = new Float32Array(3 * N);
|
|
193
|
+
for (let i = 0; i < N; i++) {
|
|
194
|
+
imgF[i] = imgC[i * 3] / 255;
|
|
195
|
+
imgF[N + i] = imgC[i * 3 + 1] / 255;
|
|
196
|
+
imgF[2 * N + i] = imgC[i * 3 + 2] / 255;
|
|
197
|
+
}
|
|
198
|
+
const results = await session.run({
|
|
199
|
+
image: new ort.Tensor('float32', imgF, [1, 3, S, S]),
|
|
200
|
+
mask: new ort.Tensor('float32', new Float32Array(maskC), [1, 1, S, S]),
|
|
201
|
+
});
|
|
202
|
+
const o = results[session.outputNames[0]].data;
|
|
203
|
+
// 取回中心区域,缩回原尺寸
|
|
204
|
+
const back = Buffer.alloc(iw * ih * 3);
|
|
205
|
+
for (let y = 0; y < ih; y++) {
|
|
206
|
+
for (let x = 0; x < iw; x++) {
|
|
207
|
+
const so = ((oy + y) * S + (ox + x));
|
|
208
|
+
const di = (y * iw + x) * 3;
|
|
209
|
+
back[di] = clamp255(o[so]);
|
|
210
|
+
back[di + 1] = clamp255(o[N + so]);
|
|
211
|
+
back[di + 2] = clamp255(o[2 * N + so]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const restoredFull = await sharp(back, { raw: { width: iw, height: ih, channels: 3 } })
|
|
215
|
+
.resize(cw, ch, { fit: 'fill' }).raw().toBuffer();
|
|
216
|
+
const maskFull = await sharp(cropMask, { raw: { width: iw, height: ih, channels: 1 } })
|
|
217
|
+
.resize(cw, ch, { fit: 'fill' })
|
|
218
|
+
.toColourspace('b-w')
|
|
219
|
+
.raw().toBuffer();
|
|
220
|
+
// 只把掩码内的像素贴回,其余保持原图(缩放往返会损伤未掩码区,不能整块覆盖)
|
|
221
|
+
const next = Buffer.from(out);
|
|
222
|
+
for (let i = 0; i < cw * ch; i++) {
|
|
223
|
+
if (maskFull[i] > 127) {
|
|
224
|
+
next[((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3] = restoredFull[i * 3];
|
|
225
|
+
next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 1] = restoredFull[i * 3 + 1];
|
|
226
|
+
next[(((y0 + Math.floor(i / cw)) * W + (x0 + (i % cw))) * 3) + 2] = restoredFull[i * 3 + 2];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
out = next;
|
|
230
|
+
process.stderr.write(` 修复块 ${cw}x${ch} @(${x0},${y0}) 完成\n`);
|
|
231
|
+
}
|
|
232
|
+
return sharp(out, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
233
|
+
}
|
|
234
|
+
const clamp255 = (v) => Math.max(0, Math.min(255, Math.round(v)));
|
|
235
|
+
/** 掩码连通域 → 像素 bbox 列表。在 1/4 采样上网格 BFS,快且够准 */
|
|
236
|
+
function connectedBoxes(mask, W, H) {
|
|
237
|
+
const step = 4;
|
|
238
|
+
const gw = Math.ceil(W / step);
|
|
239
|
+
const gh = Math.ceil(H / step);
|
|
240
|
+
const grid = new Uint8Array(gw * gh);
|
|
241
|
+
for (let gy = 0; gy < gh; gy++) {
|
|
242
|
+
for (let gx = 0; gx < gw; gx++) {
|
|
243
|
+
let hit = false;
|
|
244
|
+
for (let dy = 0; dy < step && !hit; dy++) {
|
|
245
|
+
for (let dx = 0; dx < step && !hit; dx++) {
|
|
246
|
+
const x = gx * step + dx;
|
|
247
|
+
const y = gy * step + dy;
|
|
248
|
+
if (x < W && y < H && mask[y * W + x] > 127)
|
|
249
|
+
hit = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
grid[gy * gw + gx] = hit ? 1 : 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const seen = new Uint8Array(gw * gh);
|
|
256
|
+
const boxes = [];
|
|
257
|
+
const q = [];
|
|
258
|
+
for (let g = 0; g < grid.length; g++) {
|
|
259
|
+
if (!grid[g] || seen[g])
|
|
260
|
+
continue;
|
|
261
|
+
q.length = 0;
|
|
262
|
+
q.push(g);
|
|
263
|
+
seen[g] = 1;
|
|
264
|
+
let minx = gw, miny = gh, maxx = -1, maxy = -1;
|
|
265
|
+
while (q.length) {
|
|
266
|
+
const cur = q.pop();
|
|
267
|
+
const cx = cur % gw;
|
|
268
|
+
const cy = Math.floor(cur / gw);
|
|
269
|
+
minx = Math.min(minx, cx);
|
|
270
|
+
maxx = Math.max(maxx, cx);
|
|
271
|
+
miny = Math.min(miny, cy);
|
|
272
|
+
maxy = Math.max(maxy, cy);
|
|
273
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
274
|
+
const nx = cx + dx, ny = cy + dy;
|
|
275
|
+
if (nx < 0 || ny < 0 || nx >= gw || ny >= gh)
|
|
276
|
+
continue;
|
|
277
|
+
const ni = ny * gw + nx;
|
|
278
|
+
if (grid[ni] && !seen[ni]) {
|
|
279
|
+
seen[ni] = 1;
|
|
280
|
+
q.push(ni);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
boxes.push({ x: minx * step, y: miny * step, w: (maxx - minx + 1) * step, h: (maxy - miny + 1) * step });
|
|
285
|
+
}
|
|
286
|
+
return boxes;
|
|
287
|
+
}
|
|
288
|
+
/** 从检测框生成掩码 Buffer(框外扩 2%,白=去除区) */
|
|
289
|
+
export async function maskFromBoxes(imagePath, boxes) {
|
|
290
|
+
const sharpMod = await import('sharp');
|
|
291
|
+
const sharp = sharpMod.default ?? sharpMod;
|
|
292
|
+
const meta = await sharp(imagePath).metadata();
|
|
293
|
+
const w = meta.width || 0;
|
|
294
|
+
const h = meta.height || 0;
|
|
295
|
+
if (!w || !h)
|
|
296
|
+
throw new Error('读不到图片尺寸');
|
|
297
|
+
// 坐标体系一次判清:全部 ≤1.5 视为 0-1 归一化,否则按 qwen 惯例的 0-1000
|
|
298
|
+
const all = boxes.flatMap((b) => [b.x1, b.y1, b.x2, b.y2]);
|
|
299
|
+
const denom = Math.max(...all) <= 1.5 ? 1 : 1000;
|
|
300
|
+
const svg = boxes
|
|
301
|
+
.map((b) => {
|
|
302
|
+
const pad = 0.02;
|
|
303
|
+
const x1 = Math.max(0, (b.x1 / denom) * w - w * pad);
|
|
304
|
+
const y1 = Math.max(0, (b.y1 / denom) * h - h * pad);
|
|
305
|
+
const x2 = Math.min(w, (b.x2 / denom) * w + w * pad);
|
|
306
|
+
const y2 = Math.min(h, (b.y2 / denom) * h + h * pad);
|
|
307
|
+
return `<rect x="${x1}" y="${y1}" width="${Math.max(1, x2 - x1)}" height="${Math.max(1, y2 - y1)}" fill="#fff"/>`;
|
|
308
|
+
})
|
|
309
|
+
.join('');
|
|
310
|
+
const maskSvg = Buffer.from(`<svg width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#000"/>${svg}</svg>`);
|
|
311
|
+
return sharp(maskSvg).png().toBuffer();
|
|
312
|
+
}
|
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { stat, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { basename, dirname, extname, join } from 'node:path'
|
|
6
6
|
import { removeBackgroundLocal, BG_MODELS, type BgModelKey } from '../local-bg.js'
|
|
7
|
+
import { upscaleLocal, UPSCALE_MODELS, type UpscaleModel } from '../local-upscale.js'
|
|
8
|
+
import { detectWatermarkBoxes, inpaintLocal, maskFromBoxes } from '../local-watermark.js'
|
|
7
9
|
|
|
8
10
|
async function fileExists(path: string): Promise<boolean> {
|
|
9
11
|
try {
|
|
@@ -93,3 +95,62 @@ export async function removeBgCmd(input: string, opts: RemoveBgOpts): Promise<vo
|
|
|
93
95
|
process.stderr.write(`✅ 抠图完成(${BG_MODELS[modelKey].label},用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
|
|
94
96
|
console.log(outPath)
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
export interface RemoveWatermarkOpts {
|
|
100
|
+
out?: string
|
|
101
|
+
mask?: string
|
|
102
|
+
overwrite?: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function removeWatermarkCmd(input: string, opts: RemoveWatermarkOpts): Promise<void> {
|
|
106
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
107
|
+
const outPath = opts.out || defaultOut(input, 'clean', 'png')
|
|
108
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
109
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const start = Date.now()
|
|
113
|
+
let mask: Buffer | string
|
|
114
|
+
if (opts.mask) {
|
|
115
|
+
if (!(await fileExists(opts.mask))) throw new Error(`掩码文件不存在: ${opts.mask}`)
|
|
116
|
+
mask = opts.mask
|
|
117
|
+
process.stderr.write('使用手工掩码,跳过自动定位\n')
|
|
118
|
+
} else {
|
|
119
|
+
// 纯像素启发式定位水印(零模型依赖),失败时明确指引改用 --mask
|
|
120
|
+
const boxes = await detectWatermarkBoxes(input)
|
|
121
|
+
if (!boxes.length) throw new Error('自动定位没找到水印(角标式半透明水印通常可识别;复杂画面请用 --mask 手工指定)')
|
|
122
|
+
process.stderr.write(`定位到 ${boxes.length} 处水印:${boxes.map((b) => `(${b.x1},${b.y1})-(${b.x2},${b.y2})`).join(' ')}\n`)
|
|
123
|
+
mask = await maskFromBoxes(input, boxes)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const png = await inpaintLocal(input, mask)
|
|
127
|
+
await writeFile(outPath, png)
|
|
128
|
+
process.stderr.write(`✅ 去水印完成(用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(png.length)})\n`)
|
|
129
|
+
console.log(outPath)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface UpscaleOpts {
|
|
133
|
+
out?: string
|
|
134
|
+
scale?: string
|
|
135
|
+
model?: string
|
|
136
|
+
overwrite?: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function upscaleCmd(input: string, opts: UpscaleOpts): Promise<void> {
|
|
140
|
+
if (!(await fileExists(input))) throw new Error(`文件不存在: ${input}`)
|
|
141
|
+
const scale = opts.scale ? Number(opts.scale) : 4
|
|
142
|
+
if (![2, 3, 4].includes(scale)) throw new Error('--scale 只支持 2 / 3 / 4')
|
|
143
|
+
const model = (opts.model || 'realesrgan-x4plus') as UpscaleModel
|
|
144
|
+
if (!(model in UPSCALE_MODELS)) throw new Error(`--model 只支持 ${Object.keys(UPSCALE_MODELS).join(' / ')}`)
|
|
145
|
+
|
|
146
|
+
const outPath = opts.out || defaultOut(input, `${scale}x`, 'png')
|
|
147
|
+
if ((await fileExists(outPath)) && !opts.overwrite) {
|
|
148
|
+
throw new Error(`输出已存在(用 --overwrite 覆盖或 --out 换路径): ${outPath}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const start = Date.now()
|
|
152
|
+
await upscaleLocal({ input, output: outPath, scale, model })
|
|
153
|
+
const size = (await stat(outPath)).size
|
|
154
|
+
process.stderr.write(`✅ 超分完成(${UPSCALE_MODELS[model].label},${scale}x,用时 ${((Date.now() - start) / 1000).toFixed(1)}s,${fmtBytes(size)})\n`)
|
|
155
|
+
console.log(outPath)
|
|
156
|
+
}
|
package/src/commands/reverse.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/** museav reverse —— 图片逆向(SCULPT 六要素反推 prompt)。
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 默认走中台 API(快、稳定、不需本地模型);--local 可切本地 Ollama(需自备 qwen3-vl,
|
|
3
|
+
* 仅在用户显式要求时使用——本地大模型默认不拉起,不给用户的内存添负担)。
|
|
4
|
+
* client 懒构造:本地路成功就完全不碰中台凭证。 */
|
|
5
5
|
import type { StudioClient, ReverseResult } from '../client.js'
|
|
6
6
|
import { checkLocalVlm, reverseLocally, LOCAL_VLM_MODEL } from '../local-vision.js'
|
|
7
7
|
|
|
8
8
|
export async function reverse(
|
|
9
9
|
getClient: () => StudioClient,
|
|
10
10
|
input: string,
|
|
11
|
-
opts: { api?: boolean } = {},
|
|
11
|
+
opts: { api?: boolean; local?: boolean } = {},
|
|
12
12
|
): Promise<void> {
|
|
13
13
|
const isUrl = /^https?:\/\//.test(input)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// 本地路只在用户显式 --local 且输入是本地文件时尝试;服务不可用给出指引后回落 API
|
|
16
|
+
if (opts.local && !isUrl) {
|
|
16
17
|
const status = await checkLocalVlm()
|
|
17
18
|
if (status.running && status.modelPresent) {
|
|
18
19
|
try {
|
|
@@ -27,7 +28,7 @@ export async function reverse(
|
|
|
27
28
|
} else {
|
|
28
29
|
process.stderr.write(`⚠ 本地读图不可用(${status.reason}),回落中台 API —— 速度较慢,请耐心等待\n`)
|
|
29
30
|
}
|
|
30
|
-
} else if (
|
|
31
|
+
} else if (opts.local && isUrl) {
|
|
31
32
|
process.stderr.write(`ℹ URL 输入走中台 API(本地路只收文件路径)\n`)
|
|
32
33
|
}
|
|
33
34
|
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { bindFeishu } from './commands/bind-feishu.js'
|
|
|
15
15
|
import { printWelcome } from './commands/welcome.js'
|
|
16
16
|
import { gen } from './commands/gen.js'
|
|
17
17
|
import { reverse } from './commands/reverse.js'
|
|
18
|
-
import { compressCmd, removeBgCmd } from './commands/img-tools.js'
|
|
18
|
+
import { compressCmd, removeBgCmd, upscaleCmd, removeWatermarkCmd } from './commands/img-tools.js'
|
|
19
19
|
import { projects, createProject, listAssets, addAsset, removeAsset, resolveWorkspace } from './commands/projects.js'
|
|
20
20
|
import { imageToTemplate } from './commands/image-to-template.js'
|
|
21
21
|
import { upload } from './commands/upload.js'
|
|
@@ -190,6 +190,23 @@ program
|
|
|
190
190
|
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
191
191
|
.action(asyncRun((input: string, opts: any) => removeBgCmd(input, opts)))
|
|
192
192
|
|
|
193
|
+
program
|
|
194
|
+
.command('upscale <file>')
|
|
195
|
+
.description('本地超分放大(Real-ESRGAN + Vulkan GPU,免登录):默认 4x 输出 PNG。首次使用自动下载引擎与模型(~65MB,缓存 ~/.museav-bin 与 ~/.museav-models)')
|
|
196
|
+
.option('--out <path>', '输出路径(默认 <名>-<N>x.png)')
|
|
197
|
+
.option('--scale <n>', '放大倍数 2 / 3 / 4,默认 4')
|
|
198
|
+
.option('--model <name>', 'realesrgan-x4plus(通用照片,默认)/ realesrgan-x4plus-anime(插画动漫)')
|
|
199
|
+
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
200
|
+
.action(asyncRun((input: string, opts: any) => upscaleCmd(input, opts)))
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command('remove-watermark <file>')
|
|
204
|
+
.description('本地去水印(免登录):纯像素启发式自动定位水印 → LaMa 掩码修复,零模型依赖。首次使用自动下载修复模型(~200MB);复杂画面用 --mask 手工指定(白=去除区)')
|
|
205
|
+
.option('--out <path>', '输出路径(默认 <名>-clean.png)')
|
|
206
|
+
.option('--mask <file>', '手工掩码图(白色=要去除的区域),跳过自动定位')
|
|
207
|
+
.option('--overwrite', '允许覆盖已存在的输出文件')
|
|
208
|
+
.action(asyncRun((input: string, opts: any) => removeWatermarkCmd(input, opts)))
|
|
209
|
+
|
|
193
210
|
// 工作区(项目)与项目素材库:平台 → 账户 → 工作区三层归属,素材挂工作区
|
|
194
211
|
const projectsCmd = program
|
|
195
212
|
.command('projects')
|
|
@@ -226,8 +243,9 @@ assetsCmd
|
|
|
226
243
|
|
|
227
244
|
program
|
|
228
245
|
.command('reverse <input>')
|
|
229
|
-
.description('读图:反推 SCULPT prompt,stdout 输出英文 prompt
|
|
230
|
-
.option('--api', '
|
|
246
|
+
.description('读图:反推 SCULPT prompt,stdout 输出英文 prompt。默认走中台 API(需登录);--local 显式切本地 Ollama(需自备 qwen3-vl)。只读图;要做成模板用 image-to-template')
|
|
247
|
+
.option('--api', '强制走中台 API(默认路径)')
|
|
248
|
+
.option('--local', '改用本地 Ollama 读图(需先 ollama pull qwen3-vl:8b;本地不可用时回落 API)')
|
|
231
249
|
.action(withLazyClient((getClient: () => StudioClient, input: string, opts: any) => reverse(getClient, input, opts)))
|
|
232
250
|
|
|
233
251
|
program
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地超分(放大)—— upscale 的核心实现。
|
|
3
|
+
* 引擎:upscayl-ncnn(Real-ESRGAN 的 ncnn/Vulkan 后端,AGPL-3.0 —— 它是独立进程
|
|
4
|
+
* 二进制而非链接进 npm 包,CLI 与之分发解耦,不构成合并作品;这与把 AGPL 代码
|
|
5
|
+
* 编进依赖是两回事)。
|
|
6
|
+
* 跨平台:macOS(universal)/ Windows / Linux 二进制都在 upscayl 官方 release;
|
|
7
|
+
* 解压统一走 `tar -xf`(Win10+/macOS/Linux 都自带 libarchive 版 tar,不依赖 unzip);
|
|
8
|
+
* 二进制落地后 chmod +x(Windows 不需要)。全程 node:child_process execFile,零 shell。
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile, stat, chmod } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { execFile } from 'node:child_process'
|
|
14
|
+
import { promisify } from 'node:util'
|
|
15
|
+
const run = promisify(execFile)
|
|
16
|
+
|
|
17
|
+
// 锁定已实测的版本(20251207-174704,macOS universal 实测可用),升级要重新过测试
|
|
18
|
+
const UPSCAYL_TAG = '20251207-174704'
|
|
19
|
+
const UPSCAYL_BASE = `https://github.com/upscayl/upscayl-ncnn/releases/download/${UPSCAYL_TAG}/upscayl-bin-${UPSCAYL_TAG}`
|
|
20
|
+
// 模型从 Real-ESRGAN 官方 release 的 zip 里取(只取需要的两个,别拖全量)
|
|
21
|
+
const MODEL_ZIP = 'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-macos.zip'
|
|
22
|
+
|
|
23
|
+
export const UPSCALE_MODELS = {
|
|
24
|
+
'realesrgan-x4plus': { label: '通用照片(默认)' },
|
|
25
|
+
'realesrgan-x4plus-anime': { label: '插画/动漫' },
|
|
26
|
+
} as const
|
|
27
|
+
export type UpscaleModel = keyof typeof UPSCALE_MODELS
|
|
28
|
+
|
|
29
|
+
const BIN_DIR = join(homedir(), '.museav-bin', 'upscayl')
|
|
30
|
+
const MODEL_DIR = join(homedir(), '.museav-models')
|
|
31
|
+
|
|
32
|
+
function platformAsset(): { zip: string; exe: string } {
|
|
33
|
+
if (process.platform === 'win32') return { zip: `${UPSCAYL_BASE}-windows.zip`, exe: 'upscayl-bin.exe' }
|
|
34
|
+
if (process.platform === 'darwin') return { zip: `${UPSCAYL_BASE}-macos.zip`, exe: 'upscayl-bin' }
|
|
35
|
+
return { zip: `${UPSCAYL_BASE}-linux.zip`, exe: 'upscayl-bin' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function exists(path: string): Promise<boolean> {
|
|
39
|
+
try {
|
|
40
|
+
await stat(path)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function download(url: string, dest: string, label: string): Promise<void> {
|
|
48
|
+
const resp = await fetch(url)
|
|
49
|
+
if (!resp.ok || !resp.body) throw new Error(`${label} 下载失败 HTTP ${resp.status}`)
|
|
50
|
+
const total = Number(resp.headers.get('content-length') || 0)
|
|
51
|
+
const chunks: Buffer[] = []
|
|
52
|
+
let got = 0
|
|
53
|
+
const reader = resp.body.getReader()
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read()
|
|
56
|
+
if (done) break
|
|
57
|
+
chunks.push(Buffer.from(value))
|
|
58
|
+
got += value.length
|
|
59
|
+
if (total) process.stderr.write(` ${label} ${(got / 1048576).toFixed(1)}/${(total / 1048576).toFixed(0)}MB\r`)
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write('\n')
|
|
62
|
+
await writeFile(dest, Buffer.concat(chunks))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 首次使用时准备好二进制与模型,返回 { exe, modelDir }。之后直接走缓存 */
|
|
66
|
+
export async function ensureUpscaleRuntime(): Promise<{ exe: string; modelDir: string }> {
|
|
67
|
+
const { zip, exe } = platformAsset()
|
|
68
|
+
const exePath = join(BIN_DIR, exe)
|
|
69
|
+
const modelDir = join(MODEL_DIR, 'realesrgan')
|
|
70
|
+
const paramPath = join(modelDir, 'realesrgan-x4plus.param')
|
|
71
|
+
|
|
72
|
+
if (!(await exists(exePath))) {
|
|
73
|
+
await mkdir(BIN_DIR, { recursive: true })
|
|
74
|
+
const zipPath = join(BIN_DIR, `dl-${process.platform}.zip`)
|
|
75
|
+
process.stderr.write(`↓ 首次使用,下载超分引擎(~15MB,一次性,缓存到 ${BIN_DIR})...\n`)
|
|
76
|
+
await download(zip, zipPath, '引擎')
|
|
77
|
+
// tar -xf 解压:Win10+/macOS/Linux 自带,比依赖 unzip 稳
|
|
78
|
+
await run('tar', ['-xf', zipPath, '-C', BIN_DIR], { windowsHide: true })
|
|
79
|
+
// zip 里是 upscayl-bin-<tag>-<os>/upscayl-bin,拍平到 BIN_DIR
|
|
80
|
+
const { readdir } = await import('node:fs/promises')
|
|
81
|
+
for (const entry of await readdir(BIN_DIR, { withFileTypes: true })) {
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
const { rename, readdir: rd } = await import('node:fs/promises')
|
|
84
|
+
for (const f of await rd(join(BIN_DIR, entry.name))) {
|
|
85
|
+
await rename(join(BIN_DIR, entry.name, f), join(BIN_DIR, f))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (process.platform !== 'win32') await chmod(exePath, 0o755)
|
|
90
|
+
if (!(await exists(exePath))) throw new Error(`解压后未找到 ${exe},请检查 ${BIN_DIR}`)
|
|
91
|
+
const { unlink } = await import('node:fs/promises')
|
|
92
|
+
await unlink(zipPath).catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!(await exists(paramPath))) {
|
|
96
|
+
await mkdir(modelDir, { recursive: true })
|
|
97
|
+
const zipPath = join(MODEL_DIR, 'dl-models.zip')
|
|
98
|
+
process.stderr.write('↓ 首次使用,下载超分模型(~50MB,一次性)...\n')
|
|
99
|
+
await download(MODEL_ZIP, zipPath, '模型')
|
|
100
|
+
await run('tar', ['-xf', zipPath, '-C', MODEL_DIR, 'models/realesrgan-x4plus.param', 'models/realesrgan-x4plus.bin', 'models/realesrgan-x4plus-anime.param', 'models/realesrgan-x4plus-anime.bin'], { windowsHide: true })
|
|
101
|
+
const { rename, rm } = await import('node:fs/promises')
|
|
102
|
+
for (const f of ['realesrgan-x4plus.param', 'realesrgan-x4plus.bin', 'realesrgan-x4plus-anime.param', 'realesrgan-x4plus-anime.bin']) {
|
|
103
|
+
await rename(join(MODEL_DIR, 'models', f), join(modelDir, f)).catch(() => {})
|
|
104
|
+
}
|
|
105
|
+
await rm(join(MODEL_DIR, 'models'), { recursive: true, force: true }).catch(() => {})
|
|
106
|
+
await rm(zipPath, { force: true }).catch(() => {})
|
|
107
|
+
if (!(await exists(paramPath))) throw new Error(`模型解压失败,请检查 ${modelDir}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { exe: exePath, modelDir }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 超分主流程:返回输出文件的字节数组由引擎直写磁盘,这里只负责调度 */
|
|
114
|
+
export async function upscaleLocal(opts: {
|
|
115
|
+
input: string
|
|
116
|
+
output: string
|
|
117
|
+
scale: number
|
|
118
|
+
model: UpscaleModel
|
|
119
|
+
}): Promise<void> {
|
|
120
|
+
const { exe, modelDir } = await ensureUpscaleRuntime()
|
|
121
|
+
// 不走 shell 拼接;路径原样传参,空格/中文路径都安全
|
|
122
|
+
await run(exe, ['-i', opts.input, '-o', opts.output, '-s', String(opts.scale), '-n', opts.model, '-m', modelDir], { windowsHide: true })
|
|
123
|
+
}
|
|
@@ -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
|
+
}
|