bgcut 0.1.0-beta.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/README.md +106 -0
- package/package.json +74 -0
- package/src/cli/alpha-mask.ts +28 -0
- package/src/cli/args.ts +192 -0
- package/src/cli/main.ts +71 -0
- package/src/cli/model-cache.ts +122 -0
- package/src/cli/runtime.ts +347 -0
- package/src/engine/errors.ts +95 -0
- package/src/engine/image.ts +84 -0
- package/src/engine/matte.ts +2 -0
- package/src/engine/model-config.ts +12 -0
- package/src/engine/preprocess.ts +130 -0
- package/src/shared/model-file.ts +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# bgcut
|
|
2
|
+
|
|
3
|
+
Local background removal with WebGPU as the primary execution path.
|
|
4
|
+
|
|
5
|
+
The browser implementation uses a pinned BiRefNet Lite 512 ONNX model with ONNX Runtime WebGPU, shares one application-owned `GPUDevice` with TypeGPU, captures the inference graph, and exports transparent PNGs at the source image resolution.
|
|
6
|
+
|
|
7
|
+
## Development
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun install --frozen-lockfile
|
|
11
|
+
bun run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run the full project checks with:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bun run check
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That runs oxlint, TypeScript, product tests, the production build, and an npm package dry run. Vendored anti-slop maintainer tests are excluded from Bun's project-level test discovery through `bunfig.toml` because Oxlint `RuleTester` expects its upstream Node/tsx environment.
|
|
21
|
+
|
|
22
|
+
## Browser pipeline
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
image
|
|
26
|
+
-> source decode
|
|
27
|
+
-> TypeGPU resize + ImageNet normalization on the shared WebGPU device
|
|
28
|
+
-> BiRefNet Lite ONNX inference with WebGPU graph capture
|
|
29
|
+
-> GPU output readback
|
|
30
|
+
-> alpha matte
|
|
31
|
+
-> source-resolution compositing
|
|
32
|
+
-> transparent PNG
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The model artifact is verified by exact byte count and SHA-256 during production builds. Source images never go to an inference backend.
|
|
36
|
+
|
|
37
|
+
## Native CLI
|
|
38
|
+
|
|
39
|
+
The first npm release is published on the `beta` tag. The CLI currently runs on Bun, so install Bun before installing or invoking `bgcut`.
|
|
40
|
+
|
|
41
|
+
Install the beta globally with npm:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npm install -g bgcut@beta
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or with Bun:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bun add -g bgcut@beta
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For a one-off run without a global install:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bunx bgcut@beta photo.jpg
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The canonical command is `bgcut`:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bgcut photo.jpg
|
|
63
|
+
bgcut photo.jpg --png
|
|
64
|
+
bgcut photo.jpg -png
|
|
65
|
+
bgcut photo.jpg --webp
|
|
66
|
+
bgcut photo.jpg -webp -o portrait.webp
|
|
67
|
+
bgcut photo.jpg -o portrait.png
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
From a source checkout, `bun run cli -- ...` runs the same entrypoint without installing the package binary.
|
|
71
|
+
|
|
72
|
+
The format itself is the option; there is deliberately no `--format png` syntax. PNG is the default. WebP is encoded losslessly. JPG/JPEG is supported, but because JPEG has no alpha channel it is flattened onto white.
|
|
73
|
+
|
|
74
|
+
The CLI uses the same validated ONNX model and shared normalization/matte functions. It tries native ONNX Runtime WebGPU in automatic mode and falls back to native CPU execution if WebGPU session creation is unavailable. Use `-gpu`/`--gpu` or `-cpu`/`--cpu` to require one engine while validating the native path. The selected engine is printed after each run.
|
|
75
|
+
|
|
76
|
+
The CLI does not launch Chromium and does not upload the input image. It caches the exact validated model in the operating system's user cache directory and verifies the model before use.
|
|
77
|
+
|
|
78
|
+
## BG0 comparison
|
|
79
|
+
|
|
80
|
+
After this build removes a background, the browser result uses a draggable comparison slider rather than two independent panes. By default it compares the original image with this build's result.
|
|
81
|
+
|
|
82
|
+
Use **Load BG0 output** to select a BG0 result from disk. The file stays local. The comparison requires the BG0 image to have the same width and height as this build's output so the two results remain pixel-aligned while dragging the divider.
|
|
83
|
+
|
|
84
|
+
This comparison is intended for local acceptance of hair, fur, whiskers, thin edges, holes, and semi-transparent boundaries. It is a visual inspection tool, not a substitute for numeric matte regression tests.
|
|
85
|
+
|
|
86
|
+
## Direction
|
|
87
|
+
|
|
88
|
+
Local execution alone is not a differentiator from projects such as BG0. The intended differentiation is lower-level ownership of the GPU pipeline and the editing/refinement capabilities that ownership makes possible. Performance and output-quality claims must be benchmarked rather than assumed.
|
|
89
|
+
|
|
90
|
+
The target is a GPU-native cutout editor rather than only a one-shot background remover. Near-term work includes stronger job/cancellation APIs, quality refinement, non-destructive restore/erase editing, compatibility fallback, persistence, and benchmark coverage.
|
|
91
|
+
|
|
92
|
+
See [`IMPROVEMENTS.md`](IMPROVEMENTS.md) for the prioritized roadmap, competitive baseline, benchmark plan, architecture direction, and exact manual-acceptance baseline.
|
|
93
|
+
|
|
94
|
+
## Model
|
|
95
|
+
|
|
96
|
+
- Model: `studioludens/birefnet-lite-512`
|
|
97
|
+
- Revision: `4a3c40c36c94093cc1e724d9ea428b8fa4b57dc7`
|
|
98
|
+
- Validated runtime artifact: `birefnet-lite-512-ort-basic-webgpu-v2.onnx`
|
|
99
|
+
- Artifact size: `195,872,736` bytes
|
|
100
|
+
- SHA-256: `4461109672dda07a054892aef076b5fcc5fc40bbc91f51a357a7593c7f45ad9c`
|
|
101
|
+
- Inference size: 512×512
|
|
102
|
+
- Export size: original source dimensions
|
|
103
|
+
|
|
104
|
+
## Privacy
|
|
105
|
+
|
|
106
|
+
Source images, decoded pixels, masks, and generated outputs stay on the user's machine. The model is downloaded from the pinned release artifact; source images are not sent there or to an application inference backend.
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bgcut",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Local background removal CLI with native WebGPU and CPU fallback.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "bun@1.4.2",
|
|
7
|
+
"bin": {
|
|
8
|
+
"bgcut": "src/cli/main.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/cli/alpha-mask.ts",
|
|
12
|
+
"src/cli/args.ts",
|
|
13
|
+
"src/cli/main.ts",
|
|
14
|
+
"src/cli/model-cache.ts",
|
|
15
|
+
"src/cli/runtime.ts",
|
|
16
|
+
"src/engine/errors.ts",
|
|
17
|
+
"src/engine/image.ts",
|
|
18
|
+
"src/engine/matte.ts",
|
|
19
|
+
"src/engine/model-config.ts",
|
|
20
|
+
"src/engine/preprocess.ts",
|
|
21
|
+
"src/shared/model-file.ts"
|
|
22
|
+
],
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/jhomra21/bgremove.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/jhomra21/bgremove#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/jhomra21/bgremove/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"background-removal",
|
|
33
|
+
"image-processing",
|
|
34
|
+
"webgpu",
|
|
35
|
+
"onnx",
|
|
36
|
+
"cli",
|
|
37
|
+
"local-ai"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"tag": "beta"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"dev": "vite",
|
|
44
|
+
"cli": "bun run src/cli/main.ts",
|
|
45
|
+
"model:prepare": "bun run scripts/prepare-model.ts",
|
|
46
|
+
"model:verify-dist": "bun run scripts/verify-model.ts",
|
|
47
|
+
"build": "bun run model:prepare && vite build && bun run model:verify-dist",
|
|
48
|
+
"preview": "vite preview",
|
|
49
|
+
"lint": "oxlint .",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"test": "bun test",
|
|
52
|
+
"package:dry-run": "npm pack --dry-run",
|
|
53
|
+
"package:smoke": "bun run scripts/package-smoke.ts",
|
|
54
|
+
"check": "bun run lint && bun run typecheck && bun run test && bun run build && bun run package:dry-run && bun run package:smoke"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"effect": "3.22.2",
|
|
58
|
+
"onnxruntime-node": "1.30.0",
|
|
59
|
+
"sharp": "0.35.4"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@oxlint/plugins": "1.83.0",
|
|
63
|
+
"@solidjs/vite-plugin": "3.0.0-next.43",
|
|
64
|
+
"@solidjs/web": "2.0.0-rc.8",
|
|
65
|
+
"@types/bun": "1.4.2",
|
|
66
|
+
"@typescript/lib-dom": "npm:@types/web@0.0.356",
|
|
67
|
+
"onnxruntime-web": "1.30.0",
|
|
68
|
+
"oxlint": "1.83.0",
|
|
69
|
+
"solid-js": "2.0.0-rc.8",
|
|
70
|
+
"typegpu": "0.12.5",
|
|
71
|
+
"typescript": "7.0.2",
|
|
72
|
+
"vite": "8.3.0"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const compositeAlphaMask = (
|
|
2
|
+
rgba: Uint8Array,
|
|
3
|
+
mask: Uint8Array,
|
|
4
|
+
maskChannels: number,
|
|
5
|
+
): void => {
|
|
6
|
+
if (!Number.isInteger(maskChannels) || maskChannels < 1) {
|
|
7
|
+
throw new Error(`Mask channel count must be a positive integer; received ${maskChannels}.`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (rgba.length % 4 !== 0) {
|
|
11
|
+
throw new Error(`RGBA byte length must be divisible by 4; received ${rgba.length}.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const pixelCount = rgba.length / 4;
|
|
15
|
+
const requiredMaskBytes = pixelCount * maskChannels;
|
|
16
|
+
|
|
17
|
+
if (mask.length < requiredMaskBytes) {
|
|
18
|
+
throw new Error(`Mask contains ${mask.length} bytes; expected at least ${requiredMaskBytes}.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
22
|
+
const rgbaAlphaIndex = pixelIndex * 4 + 3;
|
|
23
|
+
const maskIndex = pixelIndex * maskChannels;
|
|
24
|
+
const maskAlpha = mask[maskIndex];
|
|
25
|
+
|
|
26
|
+
rgba[rgbaAlphaIndex] = Math.round((rgba[rgbaAlphaIndex] * maskAlpha) / 255);
|
|
27
|
+
}
|
|
28
|
+
};
|
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { dirname, extname, join, parse } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type CliFormat = "png" | "webp" | "jpg";
|
|
5
|
+
|
|
6
|
+
export type CliEngine = "auto" | "gpu" | "cpu";
|
|
7
|
+
|
|
8
|
+
export type CliOptions = {
|
|
9
|
+
readonly inputPath: string;
|
|
10
|
+
readonly outputPath: string;
|
|
11
|
+
readonly format: CliFormat;
|
|
12
|
+
readonly engine: CliEngine;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ParsedCli =
|
|
16
|
+
| { readonly kind: "help" }
|
|
17
|
+
| { readonly kind: "run"; readonly options: CliOptions };
|
|
18
|
+
|
|
19
|
+
export class CliArgumentError extends Data.TaggedError("CliArgumentError")<{
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
const formatFlags = new Map<string, CliFormat>([
|
|
24
|
+
["--png", "png"],
|
|
25
|
+
["-png", "png"],
|
|
26
|
+
["--webp", "webp"],
|
|
27
|
+
["-webp", "webp"],
|
|
28
|
+
["--jpg", "jpg"],
|
|
29
|
+
["-jpg", "jpg"],
|
|
30
|
+
["--jpeg", "jpg"],
|
|
31
|
+
["-jpeg", "jpg"],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const engineFlags = new Map<string, CliEngine>([
|
|
35
|
+
["--gpu", "gpu"],
|
|
36
|
+
["-gpu", "gpu"],
|
|
37
|
+
["--cpu", "cpu"],
|
|
38
|
+
["-cpu", "cpu"],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const formatFromExtension = (path: string): CliFormat | undefined => {
|
|
42
|
+
const extension = extname(path).toLowerCase();
|
|
43
|
+
|
|
44
|
+
if (extension === ".png") {
|
|
45
|
+
return "png";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (extension === ".webp") {
|
|
49
|
+
return "webp";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (extension === ".jpg" || extension === ".jpeg") {
|
|
53
|
+
return "jpg";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const extensionForFormat = (format: CliFormat): string => `.${format}`;
|
|
60
|
+
|
|
61
|
+
const defaultOutputPath = (inputPath: string, format: CliFormat): string => {
|
|
62
|
+
const parsed = parse(inputPath);
|
|
63
|
+
const baseName = parsed.name.length > 0 ? parsed.name : "image";
|
|
64
|
+
|
|
65
|
+
return join(dirname(inputPath), `${baseName}-nobg${extensionForFormat(format)}`);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const resolveOutputPath = (
|
|
69
|
+
inputPath: string,
|
|
70
|
+
requestedOutput: string | undefined,
|
|
71
|
+
requestedFormat: CliFormat | undefined,
|
|
72
|
+
): Effect.Effect<{ readonly outputPath: string; readonly format: CliFormat }, CliArgumentError> =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
if (requestedOutput === undefined) {
|
|
75
|
+
const format = requestedFormat ?? "png";
|
|
76
|
+
|
|
77
|
+
return { outputPath: defaultOutputPath(inputPath, format), format };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const extension = extname(requestedOutput);
|
|
81
|
+
const outputFormat = formatFromExtension(requestedOutput);
|
|
82
|
+
|
|
83
|
+
if (extension.length > 0 && outputFormat === undefined) {
|
|
84
|
+
return yield* new CliArgumentError({
|
|
85
|
+
message: `Unsupported output extension "${extension}". Use PNG, WebP, JPG, or JPEG.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (requestedFormat !== undefined && outputFormat !== undefined && requestedFormat !== outputFormat) {
|
|
90
|
+
return yield* new CliArgumentError({
|
|
91
|
+
message: `Output path "${requestedOutput}" conflicts with the requested --${requestedFormat} format.`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const format = requestedFormat ?? outputFormat ?? "png";
|
|
96
|
+
|
|
97
|
+
const outputPath = extension.length === 0
|
|
98
|
+
? `${requestedOutput}${extensionForFormat(format)}`
|
|
99
|
+
: requestedOutput;
|
|
100
|
+
|
|
101
|
+
return { outputPath, format };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const parseCliArgs = (args: readonly string[]): Effect.Effect<ParsedCli, CliArgumentError> =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
let inputPath: string | undefined;
|
|
107
|
+
let outputPath: string | undefined;
|
|
108
|
+
let format: CliFormat | undefined;
|
|
109
|
+
let engine: CliEngine = "auto";
|
|
110
|
+
|
|
111
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
112
|
+
const argument = args[index];
|
|
113
|
+
|
|
114
|
+
if (argument === "--") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (argument === "-h" || argument === "--help") {
|
|
119
|
+
return { kind: "help" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (argument === "--format" || argument === "-f") {
|
|
123
|
+
return yield* new CliArgumentError({
|
|
124
|
+
message: "Use the format itself as a flag: --png, --webp, --jpg, -png, -webp, or -jpg.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (argument === "-o" || argument === "--output") {
|
|
129
|
+
const next = args[index + 1];
|
|
130
|
+
|
|
131
|
+
if (next === undefined || next.startsWith("-")) {
|
|
132
|
+
return yield* new CliArgumentError({ message: `${argument} requires an output path.` });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (outputPath !== undefined) {
|
|
136
|
+
return yield* new CliArgumentError({ message: "Only one output path can be specified." });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
outputPath = next;
|
|
140
|
+
index += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const requestedFormat = formatFlags.get(argument);
|
|
145
|
+
|
|
146
|
+
if (requestedFormat !== undefined) {
|
|
147
|
+
if (format !== undefined && format !== requestedFormat) {
|
|
148
|
+
return yield* new CliArgumentError({ message: "Only one output format can be specified." });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
format = requestedFormat;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const requestedEngine = engineFlags.get(argument);
|
|
156
|
+
|
|
157
|
+
if (requestedEngine !== undefined) {
|
|
158
|
+
if (engine !== "auto" && engine !== requestedEngine) {
|
|
159
|
+
return yield* new CliArgumentError({ message: "Choose either GPU or CPU execution, not both." });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
engine = requestedEngine;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (argument.startsWith("-")) {
|
|
167
|
+
return yield* new CliArgumentError({ message: `Unknown option "${argument}".` });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (inputPath !== undefined) {
|
|
171
|
+
return yield* new CliArgumentError({ message: "Only one input image can be processed per command for now." });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
inputPath = argument;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (inputPath === undefined) {
|
|
178
|
+
return yield* new CliArgumentError({ message: "An input image path is required." });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const output = yield* resolveOutputPath(inputPath, outputPath, format);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
kind: "run",
|
|
185
|
+
options: {
|
|
186
|
+
inputPath,
|
|
187
|
+
outputPath: output.outputPath,
|
|
188
|
+
format: output.format,
|
|
189
|
+
engine,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
});
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Cause, Effect, Exit } from "effect";
|
|
4
|
+
|
|
5
|
+
import { parseCliArgs } from "./args";
|
|
6
|
+
import { removeBackgroundCli } from "./runtime";
|
|
7
|
+
|
|
8
|
+
const HELP = `Usage:
|
|
9
|
+
bgcut <image> [format] [options]
|
|
10
|
+
|
|
11
|
+
Formats:
|
|
12
|
+
--png, -png Transparent PNG (default)
|
|
13
|
+
--webp, -webp Lossless WebP with transparency
|
|
14
|
+
--jpg, -jpg JPEG flattened onto white
|
|
15
|
+
--jpeg, -jpeg Alias for JPG
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
-o, --output <path> Output filename or path
|
|
19
|
+
--gpu, -gpu Require native WebGPU
|
|
20
|
+
--cpu, -cpu Require CPU inference
|
|
21
|
+
-h, --help Show this help
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
bgcut photo.jpg -png
|
|
25
|
+
bgcut photo.jpg --webp
|
|
26
|
+
bgcut photo.jpg -o portrait.png
|
|
27
|
+
bgcut photo.jpg -webp -o portrait.webp
|
|
28
|
+
bgcut photo.jpg -gpu
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const formatDuration = (milliseconds: number): string => {
|
|
32
|
+
if (milliseconds < 10) {
|
|
33
|
+
return `${milliseconds.toFixed(1)} ms`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (milliseconds < 1000) {
|
|
37
|
+
return `${Math.round(milliseconds)} ms`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${(milliseconds / 1000).toFixed(2)} s`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const program = Effect.gen(function* () {
|
|
44
|
+
const parsed = yield* parseCliArgs(process.argv.slice(2));
|
|
45
|
+
|
|
46
|
+
if (parsed.kind === "help") {
|
|
47
|
+
console.log(HELP);
|
|
48
|
+
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = yield* removeBackgroundCli(parsed.options);
|
|
53
|
+
const timings = result.timings;
|
|
54
|
+
|
|
55
|
+
console.log(`✓ ${result.engine} · ${result.width}×${result.height} · saved ${result.outputPath}`);
|
|
56
|
+
|
|
57
|
+
if (result.fallbackReason !== undefined) {
|
|
58
|
+
console.log(`↳ WebGPU unavailable; automatic mode used CPU. ${result.fallbackReason}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
` model ${formatDuration(timings.modelMs)} · prepare ${formatDuration(timings.prepareMs)} · session ${formatDuration(timings.sessionMs)} · inference ${formatDuration(timings.inferenceMs)} · encode ${formatDuration(timings.encodeMs)} · total ${formatDuration(timings.totalMs)}`,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const exit = await Effect.runPromiseExit(program);
|
|
67
|
+
|
|
68
|
+
if (Exit.isFailure(exit)) {
|
|
69
|
+
console.error(Cause.pretty(exit.cause));
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MODEL_FILENAME,
|
|
8
|
+
MODEL_RELEASE_URL,
|
|
9
|
+
MODEL_SHA256,
|
|
10
|
+
MODEL_SIZE_BYTES,
|
|
11
|
+
} from "../engine/model-config";
|
|
12
|
+
import { inspectModelFile, type ModelFileFingerprint } from "../shared/model-file";
|
|
13
|
+
|
|
14
|
+
export class CliModelError extends Data.TaggedError("CliModelError")<{
|
|
15
|
+
readonly message: string;
|
|
16
|
+
readonly cause?: unknown;
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
const cacheRoot = (appName: string): string => {
|
|
20
|
+
if (process.platform === "darwin") {
|
|
21
|
+
return join(homedir(), "Library", "Caches", appName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
26
|
+
|
|
27
|
+
return join(localAppData ?? join(homedir(), "AppData", "Local"), appName);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), appName);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const cliModelPath = (): string => join(cacheRoot("bgcut"), "models", MODEL_FILENAME);
|
|
34
|
+
|
|
35
|
+
const previousCliModelPaths = (): readonly string[] => [
|
|
36
|
+
join(cacheRoot("bgremove"), "models", MODEL_FILENAME),
|
|
37
|
+
join(cacheRoot("removebg-webgpu"), "models", MODEL_FILENAME),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const isExpectedModel = (fingerprint: ModelFileFingerprint | undefined): boolean =>
|
|
41
|
+
fingerprint?.sizeBytes === MODEL_SIZE_BYTES && fingerprint.sha256 === MODEL_SHA256;
|
|
42
|
+
|
|
43
|
+
const inspectCachedModel = (path: string): Effect.Effect<ModelFileFingerprint | undefined, CliModelError> =>
|
|
44
|
+
inspectModelFile(path).pipe(
|
|
45
|
+
Effect.mapError((cause) =>
|
|
46
|
+
new CliModelError({ message: `Could not inspect the cached model at ${path}.`, cause }),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const ensureCliModel = (): Effect.Effect<string, CliModelError> =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const modelPath = cliModelPath();
|
|
53
|
+
const existing = yield* inspectCachedModel(modelPath);
|
|
54
|
+
|
|
55
|
+
if (isExpectedModel(existing)) {
|
|
56
|
+
return modelPath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const previousModelPath of previousCliModelPaths()) {
|
|
60
|
+
const previousExisting = yield* inspectCachedModel(previousModelPath);
|
|
61
|
+
|
|
62
|
+
if (isExpectedModel(previousExisting)) {
|
|
63
|
+
return previousModelPath;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const temporaryPath = `${modelPath}.download`;
|
|
68
|
+
|
|
69
|
+
yield* Effect.tryPromise({
|
|
70
|
+
try: () => mkdir(dirname(modelPath), { recursive: true }),
|
|
71
|
+
catch: (cause) => new CliModelError({ message: `Could not create ${dirname(modelPath)}.`, cause }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
yield* Effect.tryPromise({
|
|
75
|
+
try: () => rm(temporaryPath, { force: true }),
|
|
76
|
+
catch: (cause) => new CliModelError({ message: `Could not clear ${temporaryPath}.`, cause }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const response = yield* Effect.tryPromise({
|
|
80
|
+
try: () => fetch(MODEL_RELEASE_URL),
|
|
81
|
+
catch: (cause) =>
|
|
82
|
+
new CliModelError({ message: "Could not download the validated BiRefNet model.", cause }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
return yield* new CliModelError({
|
|
87
|
+
message: `Validated model download failed with HTTP ${response.status}.`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
yield* Effect.tryPromise({
|
|
92
|
+
try: () => Bun.write(temporaryPath, response),
|
|
93
|
+
catch: (cause) => new CliModelError({ message: `Could not write ${temporaryPath}.`, cause }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const downloaded = yield* inspectModelFile(temporaryPath).pipe(
|
|
97
|
+
Effect.mapError((cause) =>
|
|
98
|
+
new CliModelError({ message: `Could not verify ${temporaryPath}.`, cause }),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!isExpectedModel(downloaded)) {
|
|
103
|
+
yield* Effect.tryPromise({
|
|
104
|
+
try: () => rm(temporaryPath, { force: true }),
|
|
105
|
+
catch: (cause) => new CliModelError({ message: `Could not remove invalid ${temporaryPath}.`, cause }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return yield* new CliModelError({
|
|
109
|
+
message: `Downloaded model did not match the expected ${MODEL_SIZE_BYTES}-byte artifact with SHA-256 ${MODEL_SHA256}.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
yield* Effect.tryPromise({
|
|
114
|
+
try: async () => {
|
|
115
|
+
await rm(modelPath, { force: true });
|
|
116
|
+
await rename(temporaryPath, modelPath);
|
|
117
|
+
},
|
|
118
|
+
catch: (cause) => new CliModelError({ message: `Could not install the model at ${modelPath}.`, cause }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return modelPath;
|
|
122
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import * as ort from "onnxruntime-node";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
|
|
7
|
+
import { MODEL_INPUT_SIZE } from "../engine/image";
|
|
8
|
+
import { logitToAlphaByte } from "../engine/matte";
|
|
9
|
+
import { resizeRgbaLinearToNchw } from "../engine/preprocess";
|
|
10
|
+
import { compositeAlphaMask } from "./alpha-mask";
|
|
11
|
+
import type { CliEngine, CliFormat, CliOptions } from "./args";
|
|
12
|
+
import { CliModelError, ensureCliModel } from "./model-cache";
|
|
13
|
+
|
|
14
|
+
export type CliExecutionEngine = "webgpu" | "cpu";
|
|
15
|
+
|
|
16
|
+
export type CliTimings = {
|
|
17
|
+
readonly totalMs: number;
|
|
18
|
+
readonly modelMs: number;
|
|
19
|
+
readonly prepareMs: number;
|
|
20
|
+
readonly sessionMs: number;
|
|
21
|
+
readonly inferenceMs: number;
|
|
22
|
+
readonly encodeMs: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CliRemovalResult = {
|
|
26
|
+
readonly width: number;
|
|
27
|
+
readonly height: number;
|
|
28
|
+
readonly outputPath: string;
|
|
29
|
+
readonly engine: CliExecutionEngine;
|
|
30
|
+
readonly fallbackReason: string | undefined;
|
|
31
|
+
readonly timings: CliTimings;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class CliImageError extends Data.TaggedError("CliImageError")<{
|
|
35
|
+
readonly message: string;
|
|
36
|
+
readonly cause?: unknown;
|
|
37
|
+
}> {}
|
|
38
|
+
|
|
39
|
+
export class CliSessionError extends Data.TaggedError("CliSessionError")<{
|
|
40
|
+
readonly message: string;
|
|
41
|
+
readonly cause?: unknown;
|
|
42
|
+
}> {}
|
|
43
|
+
|
|
44
|
+
export class CliInferenceError extends Data.TaggedError("CliInferenceError")<{
|
|
45
|
+
readonly message: string;
|
|
46
|
+
readonly cause?: unknown;
|
|
47
|
+
}> {}
|
|
48
|
+
|
|
49
|
+
export class CliOutputError extends Data.TaggedError("CliOutputError")<{
|
|
50
|
+
readonly message: string;
|
|
51
|
+
readonly cause?: unknown;
|
|
52
|
+
}> {}
|
|
53
|
+
|
|
54
|
+
type PreparedImage = {
|
|
55
|
+
readonly source: Buffer;
|
|
56
|
+
readonly width: number;
|
|
57
|
+
readonly height: number;
|
|
58
|
+
readonly modelInput: Float32Array;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type NativeSession = {
|
|
62
|
+
readonly session: ort.InferenceSession;
|
|
63
|
+
readonly engine: CliExecutionEngine;
|
|
64
|
+
readonly fallbackReason: string | undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const supportedSharpFormats = new Set(["jpeg", "png", "webp"]);
|
|
68
|
+
|
|
69
|
+
const prepareImage = (inputPath: string): Effect.Effect<PreparedImage, CliImageError> =>
|
|
70
|
+
Effect.tryPromise({
|
|
71
|
+
try: async () => {
|
|
72
|
+
const input = Bun.file(inputPath);
|
|
73
|
+
|
|
74
|
+
if (!(await input.exists())) {
|
|
75
|
+
throw new Error(`Input file does not exist: ${inputPath}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const metadata = await sharp(inputPath).metadata();
|
|
79
|
+
|
|
80
|
+
if (metadata.format === undefined || !supportedSharpFormats.has(metadata.format)) {
|
|
81
|
+
throw new Error(`Unsupported image type: ${metadata.format ?? "unknown"}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const source = await sharp(inputPath)
|
|
85
|
+
.rotate()
|
|
86
|
+
.ensureAlpha()
|
|
87
|
+
.toColourspace("srgb")
|
|
88
|
+
.raw()
|
|
89
|
+
.toBuffer({ resolveWithObject: true });
|
|
90
|
+
|
|
91
|
+
if (source.info.channels !== 4) {
|
|
92
|
+
throw new Error("Decoded image did not produce RGBA pixels.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
source: source.data,
|
|
97
|
+
width: source.info.width,
|
|
98
|
+
height: source.info.height,
|
|
99
|
+
modelInput: resizeRgbaLinearToNchw(
|
|
100
|
+
source.data,
|
|
101
|
+
source.info.width,
|
|
102
|
+
source.info.height,
|
|
103
|
+
MODEL_INPUT_SIZE,
|
|
104
|
+
MODEL_INPUT_SIZE,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
catch: (cause) =>
|
|
109
|
+
new CliImageError({
|
|
110
|
+
message: `Could not decode and prepare ${inputPath}.`,
|
|
111
|
+
cause,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const createSessionForProvider = (
|
|
116
|
+
modelPath: string,
|
|
117
|
+
engine: CliExecutionEngine,
|
|
118
|
+
): Effect.Effect<NativeSession, CliSessionError> =>
|
|
119
|
+
Effect.tryPromise({
|
|
120
|
+
try: async () => ({
|
|
121
|
+
session: await ort.InferenceSession.create(modelPath, {
|
|
122
|
+
executionProviders: [engine],
|
|
123
|
+
graphOptimizationLevel: "all",
|
|
124
|
+
}),
|
|
125
|
+
engine,
|
|
126
|
+
fallbackReason: undefined,
|
|
127
|
+
}),
|
|
128
|
+
catch: (cause) =>
|
|
129
|
+
new CliSessionError({
|
|
130
|
+
message: `ONNX Runtime could not create the native ${engine} session.`,
|
|
131
|
+
cause,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const createSession = (
|
|
136
|
+
modelPath: string,
|
|
137
|
+
engine: CliEngine,
|
|
138
|
+
): Effect.Effect<NativeSession, CliSessionError> => {
|
|
139
|
+
if (engine === "gpu") {
|
|
140
|
+
return createSessionForProvider(modelPath, "webgpu");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (engine === "cpu") {
|
|
144
|
+
return createSessionForProvider(modelPath, "cpu");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return createSessionForProvider(modelPath, "webgpu").pipe(
|
|
148
|
+
Effect.catchAll((webGpuError) =>
|
|
149
|
+
createSessionForProvider(modelPath, "cpu").pipe(
|
|
150
|
+
Effect.map((nativeSession) => ({
|
|
151
|
+
...nativeSession,
|
|
152
|
+
fallbackReason: webGpuError.message,
|
|
153
|
+
})),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const runInference = (
|
|
160
|
+
session: ort.InferenceSession,
|
|
161
|
+
modelInput: Float32Array,
|
|
162
|
+
): Effect.Effect<Float32Array, CliInferenceError> =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
const inputName = session.inputNames.at(0);
|
|
165
|
+
const outputName = session.outputNames.at(0);
|
|
166
|
+
|
|
167
|
+
if (inputName === undefined || outputName === undefined) {
|
|
168
|
+
return yield* new CliInferenceError({
|
|
169
|
+
message: "BiRefNet does not expose the expected input and output tensors.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const input = new ort.Tensor("float32", modelInput, [1, 3, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE]);
|
|
174
|
+
|
|
175
|
+
const outputs = yield* Effect.tryPromise({
|
|
176
|
+
try: () => session.run({ [inputName]: input }),
|
|
177
|
+
catch: (cause) => new CliInferenceError({ message: "BiRefNet inference failed.", cause }),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const output = outputs[outputName];
|
|
181
|
+
|
|
182
|
+
if (output === undefined) {
|
|
183
|
+
return yield* new CliInferenceError({ message: "BiRefNet returned no foreground matte." });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const data = output.data;
|
|
187
|
+
|
|
188
|
+
if (!(data instanceof Float32Array)) {
|
|
189
|
+
return yield* new CliInferenceError({
|
|
190
|
+
message: `BiRefNet returned ${output.type} data instead of float32 logits.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (data.length !== MODEL_INPUT_SIZE * MODEL_INPUT_SIZE) {
|
|
195
|
+
return yield* new CliInferenceError({
|
|
196
|
+
message: `BiRefNet returned ${data.length} logits instead of ${MODEL_INPUT_SIZE * MODEL_INPUT_SIZE}.`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return data;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const createMask = (logits: Float32Array): Uint8Array => {
|
|
204
|
+
const alpha = new Uint8Array(logits.length);
|
|
205
|
+
|
|
206
|
+
for (let index = 0; index < logits.length; index += 1) {
|
|
207
|
+
alpha[index] = logitToAlphaByte(logits[index]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return alpha;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const encodeOutput = (
|
|
214
|
+
prepared: PreparedImage,
|
|
215
|
+
logits: Float32Array,
|
|
216
|
+
outputPath: string,
|
|
217
|
+
format: CliFormat,
|
|
218
|
+
): Effect.Effect<void, CliOutputError> =>
|
|
219
|
+
Effect.tryPromise({
|
|
220
|
+
try: async () => {
|
|
221
|
+
const alpha = createMask(logits);
|
|
222
|
+
|
|
223
|
+
const resizedAlpha = await sharp(Buffer.from(alpha), {
|
|
224
|
+
raw: {
|
|
225
|
+
width: MODEL_INPUT_SIZE,
|
|
226
|
+
height: MODEL_INPUT_SIZE,
|
|
227
|
+
channels: 1,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
.resize(prepared.width, prepared.height, {
|
|
231
|
+
fit: "fill",
|
|
232
|
+
kernel: sharp.kernel.cubic,
|
|
233
|
+
fastShrinkOnLoad: false,
|
|
234
|
+
})
|
|
235
|
+
.raw()
|
|
236
|
+
.toBuffer({ resolveWithObject: true });
|
|
237
|
+
|
|
238
|
+
if (resizedAlpha.info.width !== prepared.width || resizedAlpha.info.height !== prepared.height) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Resized matte is ${resizedAlpha.info.width} × ${resizedAlpha.info.height}; expected ${prepared.width} × ${prepared.height}.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const rgba = Buffer.from(prepared.source);
|
|
245
|
+
|
|
246
|
+
compositeAlphaMask(rgba, resizedAlpha.data, resizedAlpha.info.channels);
|
|
247
|
+
|
|
248
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
249
|
+
|
|
250
|
+
const image = sharp(rgba, {
|
|
251
|
+
raw: {
|
|
252
|
+
width: prepared.width,
|
|
253
|
+
height: prepared.height,
|
|
254
|
+
channels: 4,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (format === "png") {
|
|
259
|
+
await image.png().toFile(outputPath);
|
|
260
|
+
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (format === "webp") {
|
|
265
|
+
await image.webp({ lossless: true }).toFile(outputPath);
|
|
266
|
+
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await image
|
|
271
|
+
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
|
272
|
+
.jpeg({ quality: 95, chromaSubsampling: "4:4:4" })
|
|
273
|
+
.toFile(outputPath);
|
|
274
|
+
},
|
|
275
|
+
catch: (cause) =>
|
|
276
|
+
new CliOutputError({
|
|
277
|
+
message: `Could not encode ${outputPath}.`,
|
|
278
|
+
cause,
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
export const removeBackgroundCli = (
|
|
283
|
+
options: CliOptions,
|
|
284
|
+
): Effect.Effect<
|
|
285
|
+
CliRemovalResult,
|
|
286
|
+
CliImageError | CliSessionError | CliInferenceError | CliOutputError | CliModelError
|
|
287
|
+
> =>
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
if (resolve(options.inputPath) === resolve(options.outputPath)) {
|
|
290
|
+
return yield* new CliOutputError({
|
|
291
|
+
message: "Input and output paths must be different.",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const totalStartedAt = performance.now();
|
|
296
|
+
let stageStartedAt = performance.now();
|
|
297
|
+
|
|
298
|
+
const modelPath = yield* ensureCliModel();
|
|
299
|
+
const modelMs = performance.now() - stageStartedAt;
|
|
300
|
+
|
|
301
|
+
stageStartedAt = performance.now();
|
|
302
|
+
|
|
303
|
+
const prepared = yield* prepareImage(options.inputPath);
|
|
304
|
+
const prepareMs = performance.now() - stageStartedAt;
|
|
305
|
+
|
|
306
|
+
stageStartedAt = performance.now();
|
|
307
|
+
|
|
308
|
+
const nativeSession = yield* createSession(modelPath, options.engine);
|
|
309
|
+
const sessionMs = performance.now() - stageStartedAt;
|
|
310
|
+
|
|
311
|
+
stageStartedAt = performance.now();
|
|
312
|
+
|
|
313
|
+
const logits = yield* Effect.acquireUseRelease(
|
|
314
|
+
Effect.succeed(nativeSession.session),
|
|
315
|
+
(session) => runInference(session, prepared.modelInput),
|
|
316
|
+
(session) =>
|
|
317
|
+
Effect.tryPromise({
|
|
318
|
+
try: () => session.release(),
|
|
319
|
+
catch: () => undefined,
|
|
320
|
+
}).pipe(Effect.orElseSucceed(() => undefined)),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const inferenceMs = performance.now() - stageStartedAt;
|
|
324
|
+
|
|
325
|
+
stageStartedAt = performance.now();
|
|
326
|
+
|
|
327
|
+
yield* encodeOutput(prepared, logits, options.outputPath, options.format);
|
|
328
|
+
|
|
329
|
+
const encodeMs = performance.now() - stageStartedAt;
|
|
330
|
+
const totalMs = performance.now() - totalStartedAt;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
width: prepared.width,
|
|
334
|
+
height: prepared.height,
|
|
335
|
+
outputPath: options.outputPath,
|
|
336
|
+
engine: nativeSession.engine,
|
|
337
|
+
fallbackReason: nativeSession.fallbackReason,
|
|
338
|
+
timings: {
|
|
339
|
+
totalMs,
|
|
340
|
+
modelMs,
|
|
341
|
+
prepareMs,
|
|
342
|
+
sessionMs,
|
|
343
|
+
inferenceMs,
|
|
344
|
+
encodeMs,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Data, Match } from "effect";
|
|
2
|
+
|
|
3
|
+
export class WebGpuUnavailable extends Data.TaggedError("WebGpuUnavailable")<{
|
|
4
|
+
readonly message: string;
|
|
5
|
+
}> {}
|
|
6
|
+
|
|
7
|
+
export class AdapterUnavailable extends Data.TaggedError("AdapterUnavailable")<{
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}> {}
|
|
10
|
+
|
|
11
|
+
export class DeviceRequestFailed extends Data.TaggedError("DeviceRequestFailed")<{
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}> {}
|
|
14
|
+
|
|
15
|
+
export class RuntimeInitializationFailed extends Data.TaggedError("RuntimeInitializationFailed")<{
|
|
16
|
+
readonly message: string;
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
export class UnsupportedImage extends Data.TaggedError("UnsupportedImage")<{
|
|
20
|
+
readonly mimeType: string;
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
export class ImageDecodeFailed extends Data.TaggedError("ImageDecodeFailed")<{
|
|
24
|
+
readonly fileName: string;
|
|
25
|
+
}> {}
|
|
26
|
+
|
|
27
|
+
export class ImageProcessingFailed extends Data.TaggedError("ImageProcessingFailed")<{
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}> {}
|
|
30
|
+
|
|
31
|
+
export class ModelDownloadFailed extends Data.TaggedError("ModelDownloadFailed")<{
|
|
32
|
+
readonly message: string;
|
|
33
|
+
}> {}
|
|
34
|
+
|
|
35
|
+
export class ModelLoadFailed extends Data.TaggedError("ModelLoadFailed")<{
|
|
36
|
+
readonly message: string;
|
|
37
|
+
}> {}
|
|
38
|
+
|
|
39
|
+
export class InferenceFailed extends Data.TaggedError("InferenceFailed")<{
|
|
40
|
+
readonly message: string;
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
export class ExportFailed extends Data.TaggedError("ExportFailed")<{
|
|
44
|
+
readonly message: string;
|
|
45
|
+
}> {}
|
|
46
|
+
|
|
47
|
+
export type GpuRuntimeError =
|
|
48
|
+
| WebGpuUnavailable
|
|
49
|
+
| AdapterUnavailable
|
|
50
|
+
| DeviceRequestFailed
|
|
51
|
+
| RuntimeInitializationFailed;
|
|
52
|
+
|
|
53
|
+
export type ImageError = UnsupportedImage | ImageDecodeFailed | ImageProcessingFailed;
|
|
54
|
+
|
|
55
|
+
export type BackgroundRemovalError =
|
|
56
|
+
| GpuRuntimeError
|
|
57
|
+
| ImageError
|
|
58
|
+
| ModelDownloadFailed
|
|
59
|
+
| ModelLoadFailed
|
|
60
|
+
| InferenceFailed
|
|
61
|
+
| ExportFailed;
|
|
62
|
+
|
|
63
|
+
export const formatGpuRuntimeError = (error: GpuRuntimeError): string => error.message;
|
|
64
|
+
|
|
65
|
+
export const formatImageError = (error: ImageError): string =>
|
|
66
|
+
Match.value(error).pipe(
|
|
67
|
+
Match.tag("UnsupportedImage", (unsupported) =>
|
|
68
|
+
`Unsupported image type: ${unsupported.mimeType || "unknown"}`
|
|
69
|
+
),
|
|
70
|
+
Match.tag("ImageDecodeFailed", (decodeFailure) => `Could not decode ${decodeFailure.fileName}.`),
|
|
71
|
+
Match.tag("ImageProcessingFailed", (processingFailure) => processingFailure.message),
|
|
72
|
+
Match.exhaustive,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
export const formatBackgroundRemovalError = (error: BackgroundRemovalError): string =>
|
|
76
|
+
Match.value(error).pipe(
|
|
77
|
+
Match.tag(
|
|
78
|
+
"UnsupportedImage",
|
|
79
|
+
"ImageDecodeFailed",
|
|
80
|
+
"ImageProcessingFailed",
|
|
81
|
+
(imageError) => formatImageError(imageError),
|
|
82
|
+
),
|
|
83
|
+
Match.tag(
|
|
84
|
+
"WebGpuUnavailable",
|
|
85
|
+
"AdapterUnavailable",
|
|
86
|
+
"DeviceRequestFailed",
|
|
87
|
+
"RuntimeInitializationFailed",
|
|
88
|
+
"ModelDownloadFailed",
|
|
89
|
+
"ModelLoadFailed",
|
|
90
|
+
"InferenceFailed",
|
|
91
|
+
"ExportFailed",
|
|
92
|
+
(failure) => failure.message,
|
|
93
|
+
),
|
|
94
|
+
Match.exhaustive,
|
|
95
|
+
);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { ImageDecodeFailed, ImageProcessingFailed, UnsupportedImage, type ImageError } from "./errors";
|
|
4
|
+
import { normalizeRgbaToNchw } from "./preprocess";
|
|
5
|
+
|
|
6
|
+
export const MODEL_INPUT_SIZE = 512;
|
|
7
|
+
|
|
8
|
+
const supportedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
9
|
+
|
|
10
|
+
export const isSupportedImageType = (mimeType: string): boolean => supportedImageTypes.has(mimeType);
|
|
11
|
+
|
|
12
|
+
export type DecodedImage = {
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly height: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const loadImageBitmap = (file: File): Effect.Effect<ImageBitmap, ImageError> =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
if (!isSupportedImageType(file.type)) {
|
|
20
|
+
return yield* new UnsupportedImage({ mimeType: file.type });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return yield* Effect.tryPromise({
|
|
24
|
+
try: () => createImageBitmap(file, { imageOrientation: "from-image" }),
|
|
25
|
+
catch: () => new ImageDecodeFailed({ fileName: file.name }),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const decodeImage = (file: File): Effect.Effect<DecodedImage, ImageError> =>
|
|
30
|
+
Effect.acquireUseRelease(
|
|
31
|
+
loadImageBitmap(file),
|
|
32
|
+
(bitmap) => Effect.succeed({ width: bitmap.width, height: bitmap.height }),
|
|
33
|
+
(bitmap) => Effect.sync(() => bitmap.close()),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const prepareModelCanvas = (
|
|
37
|
+
bitmap: ImageBitmap,
|
|
38
|
+
): Effect.Effect<HTMLCanvasElement, ImageProcessingFailed> =>
|
|
39
|
+
Effect.try({
|
|
40
|
+
try: () => {
|
|
41
|
+
const canvas = document.createElement("canvas");
|
|
42
|
+
canvas.width = MODEL_INPUT_SIZE;
|
|
43
|
+
canvas.height = MODEL_INPUT_SIZE;
|
|
44
|
+
|
|
45
|
+
const context = canvas.getContext("2d");
|
|
46
|
+
|
|
47
|
+
if (context === null) {
|
|
48
|
+
throw new Error("2D canvas is unavailable.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
context.imageSmoothingEnabled = true;
|
|
52
|
+
context.imageSmoothingQuality = "high";
|
|
53
|
+
context.drawImage(bitmap, 0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
|
|
54
|
+
|
|
55
|
+
return canvas;
|
|
56
|
+
},
|
|
57
|
+
catch: () =>
|
|
58
|
+
new ImageProcessingFailed({
|
|
59
|
+
message: "The image could not be resized for background-removal inference.",
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const prepareModelInput = (bitmap: ImageBitmap): Effect.Effect<Float32Array, ImageProcessingFailed> =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const canvas = yield* prepareModelCanvas(bitmap);
|
|
66
|
+
|
|
67
|
+
return yield* Effect.try({
|
|
68
|
+
try: () => {
|
|
69
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
70
|
+
|
|
71
|
+
if (context === null) {
|
|
72
|
+
throw new Error("2D canvas is unavailable.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const image = context.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
|
|
76
|
+
|
|
77
|
+
return normalizeRgbaToNchw(image.data, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
|
|
78
|
+
},
|
|
79
|
+
catch: () =>
|
|
80
|
+
new ImageProcessingFailed({
|
|
81
|
+
message: "The image could not be prepared for background-removal inference.",
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const MODEL_FILENAME = "birefnet-lite-512-ort-basic-webgpu-v2.onnx";
|
|
2
|
+
|
|
3
|
+
export const MODEL_PUBLIC_PATH = `/models/${MODEL_FILENAME}`;
|
|
4
|
+
|
|
5
|
+
export const MODEL_RELEASE_URL =
|
|
6
|
+
`https://github.com/jhomra21/bgremove/releases/download/model-birefnet-lite-512-ort-basic-webgpu-v2/${MODEL_FILENAME}`;
|
|
7
|
+
|
|
8
|
+
export const MODEL_REVISION = "4a3c40c36c94093cc1e724d9ea428b8fa4b57dc7";
|
|
9
|
+
|
|
10
|
+
export const MODEL_SHA256 = "4461109672dda07a054892aef076b5fcc5fc40bbc91f51a357a7593c7f45ad9c";
|
|
11
|
+
|
|
12
|
+
export const MODEL_SIZE_BYTES = 195_872_736;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const imageNetMean = [0.485, 0.456, 0.406] as const;
|
|
2
|
+
|
|
3
|
+
const imageNetStd = [0.229, 0.224, 0.225] as const;
|
|
4
|
+
|
|
5
|
+
type RgbChannel = 0 | 1 | 2;
|
|
6
|
+
|
|
7
|
+
const normalizeChannel = (value: number, channel: RgbChannel): number =>
|
|
8
|
+
(value - imageNetMean[channel]) / imageNetStd[channel];
|
|
9
|
+
|
|
10
|
+
export const normalizeRgbaToNchw = (
|
|
11
|
+
pixels: Uint8Array | Uint8ClampedArray,
|
|
12
|
+
width: number,
|
|
13
|
+
height: number,
|
|
14
|
+
): Float32Array => {
|
|
15
|
+
const pixelCount = width * height;
|
|
16
|
+
const tensor = new Float32Array(pixelCount * 3);
|
|
17
|
+
|
|
18
|
+
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
|
19
|
+
const rgbaIndex = pixelIndex * 4;
|
|
20
|
+
|
|
21
|
+
tensor[pixelIndex] = normalizeChannel(pixels[rgbaIndex] / 255, 0);
|
|
22
|
+
tensor[pixelCount + pixelIndex] = normalizeChannel(pixels[rgbaIndex + 1] / 255, 1);
|
|
23
|
+
tensor[pixelCount * 2 + pixelIndex] = normalizeChannel(pixels[rgbaIndex + 2] / 255, 2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return tensor;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const clampIndex = (value: number, maximum: number): number =>
|
|
30
|
+
Math.min(Math.max(value, 0), maximum);
|
|
31
|
+
|
|
32
|
+
const writeSampledChannel = (
|
|
33
|
+
tensor: Float32Array,
|
|
34
|
+
pixels: Uint8Array | Uint8ClampedArray,
|
|
35
|
+
channel: RgbChannel,
|
|
36
|
+
targetPixelCount: number,
|
|
37
|
+
targetIndex: number,
|
|
38
|
+
topLeftIndex: number,
|
|
39
|
+
topRightIndex: number,
|
|
40
|
+
bottomLeftIndex: number,
|
|
41
|
+
bottomRightIndex: number,
|
|
42
|
+
xMix: number,
|
|
43
|
+
yMix: number,
|
|
44
|
+
): void => {
|
|
45
|
+
const top = pixels[topLeftIndex + channel] * (1 - xMix)
|
|
46
|
+
+ pixels[topRightIndex + channel] * xMix;
|
|
47
|
+
|
|
48
|
+
const bottom = pixels[bottomLeftIndex + channel] * (1 - xMix)
|
|
49
|
+
+ pixels[bottomRightIndex + channel] * xMix;
|
|
50
|
+
|
|
51
|
+
const sampled = (top * (1 - yMix) + bottom * yMix) / 255;
|
|
52
|
+
|
|
53
|
+
tensor[channel * targetPixelCount + targetIndex] = normalizeChannel(sampled, channel);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const resizeRgbaLinearToNchw = (
|
|
57
|
+
pixels: Uint8Array | Uint8ClampedArray,
|
|
58
|
+
sourceWidth: number,
|
|
59
|
+
sourceHeight: number,
|
|
60
|
+
targetWidth: number,
|
|
61
|
+
targetHeight: number,
|
|
62
|
+
): Float32Array => {
|
|
63
|
+
const targetPixelCount = targetWidth * targetHeight;
|
|
64
|
+
const tensor = new Float32Array(targetPixelCount * 3);
|
|
65
|
+
const sourceMaxX = sourceWidth - 1;
|
|
66
|
+
const sourceMaxY = sourceHeight - 1;
|
|
67
|
+
|
|
68
|
+
for (let targetY = 0; targetY < targetHeight; targetY += 1) {
|
|
69
|
+
const sourceY = ((targetY + 0.5) * sourceHeight) / targetHeight - 0.5;
|
|
70
|
+
const sourceYFloor = Math.floor(sourceY);
|
|
71
|
+
const yMix = sourceY - sourceYFloor;
|
|
72
|
+
const y0 = clampIndex(sourceYFloor, sourceMaxY);
|
|
73
|
+
const y1 = clampIndex(sourceYFloor + 1, sourceMaxY);
|
|
74
|
+
|
|
75
|
+
for (let targetX = 0; targetX < targetWidth; targetX += 1) {
|
|
76
|
+
const sourceX = ((targetX + 0.5) * sourceWidth) / targetWidth - 0.5;
|
|
77
|
+
const sourceXFloor = Math.floor(sourceX);
|
|
78
|
+
const xMix = sourceX - sourceXFloor;
|
|
79
|
+
const x0 = clampIndex(sourceXFloor, sourceMaxX);
|
|
80
|
+
const x1 = clampIndex(sourceXFloor + 1, sourceMaxX);
|
|
81
|
+
const topLeftIndex = (y0 * sourceWidth + x0) * 4;
|
|
82
|
+
const topRightIndex = (y0 * sourceWidth + x1) * 4;
|
|
83
|
+
const bottomLeftIndex = (y1 * sourceWidth + x0) * 4;
|
|
84
|
+
const bottomRightIndex = (y1 * sourceWidth + x1) * 4;
|
|
85
|
+
const targetIndex = targetY * targetWidth + targetX;
|
|
86
|
+
|
|
87
|
+
writeSampledChannel(
|
|
88
|
+
tensor,
|
|
89
|
+
pixels,
|
|
90
|
+
0,
|
|
91
|
+
targetPixelCount,
|
|
92
|
+
targetIndex,
|
|
93
|
+
topLeftIndex,
|
|
94
|
+
topRightIndex,
|
|
95
|
+
bottomLeftIndex,
|
|
96
|
+
bottomRightIndex,
|
|
97
|
+
xMix,
|
|
98
|
+
yMix,
|
|
99
|
+
);
|
|
100
|
+
writeSampledChannel(
|
|
101
|
+
tensor,
|
|
102
|
+
pixels,
|
|
103
|
+
1,
|
|
104
|
+
targetPixelCount,
|
|
105
|
+
targetIndex,
|
|
106
|
+
topLeftIndex,
|
|
107
|
+
topRightIndex,
|
|
108
|
+
bottomLeftIndex,
|
|
109
|
+
bottomRightIndex,
|
|
110
|
+
xMix,
|
|
111
|
+
yMix,
|
|
112
|
+
);
|
|
113
|
+
writeSampledChannel(
|
|
114
|
+
tensor,
|
|
115
|
+
pixels,
|
|
116
|
+
2,
|
|
117
|
+
targetPixelCount,
|
|
118
|
+
targetIndex,
|
|
119
|
+
topLeftIndex,
|
|
120
|
+
topRightIndex,
|
|
121
|
+
bottomLeftIndex,
|
|
122
|
+
bottomRightIndex,
|
|
123
|
+
xMix,
|
|
124
|
+
yMix,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return tensor;
|
|
130
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export type ModelFileFingerprint = {
|
|
4
|
+
readonly sizeBytes: number;
|
|
5
|
+
readonly sha256: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class ModelFileError extends Data.TaggedError("ModelFileError")<{
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly cause: unknown;
|
|
11
|
+
}> {}
|
|
12
|
+
|
|
13
|
+
export const inspectModelFile = (
|
|
14
|
+
path: string,
|
|
15
|
+
): Effect.Effect<ModelFileFingerprint | undefined, ModelFileError> =>
|
|
16
|
+
Effect.tryPromise({
|
|
17
|
+
try: async () => {
|
|
18
|
+
const file = Bun.file(path);
|
|
19
|
+
|
|
20
|
+
if (!(await file.exists())) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reader = file.stream().getReader();
|
|
25
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
26
|
+
|
|
27
|
+
for (;;) {
|
|
28
|
+
const chunk = await reader.read();
|
|
29
|
+
|
|
30
|
+
if (chunk.done) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hasher.update(chunk.value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
sizeBytes: file.size,
|
|
39
|
+
sha256: hasher.digest("hex"),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
catch: (cause) => new ModelFileError({ path, cause }),
|
|
43
|
+
});
|