feat: improve remove background with edge smoothing, color decontamination, output formats

- Expose birefnet-hr-matting in UI (People/Ultra) and fix model defaults
  (People/Max now uses birefnet-matting for true alpha matting)
- Add output format selector (PNG/WebP/AVIF) with lossless alpha support
- Add edge smoothing post-processing (Off/Light/Medium/Strong) via
  morphological mask refinement to reduce gray halo artifacts
- Add color decontamination to remove background color spill from
  semi-transparent edge pixels
- Thread new settings through full stack: frontend -> API schema ->
  Python sidecar -> Sharp effects pipeline
- Add i18n keys for all 21 locales
- Add unit tests for new option serialization (3 tests)
- Add integration tests for new settings validation (4 tests)
This commit is contained in:
SnapOtter
2026-06-05 23:05:25 +08:00
parent f7282afe92
commit 80957f6e10
28 changed files with 485 additions and 15 deletions
@@ -387,6 +387,102 @@ describe("Remove Background", () => {
}
});
it("accepts edge refinement and decontamination settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ edgeRefine: 2, decontaminate: true }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts output format settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ outputFormat: "webp" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("rejects edgeRefine out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ edgeRefine: 5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects invalid output format", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ outputFormat: "gif" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
+29
View File
@@ -97,6 +97,35 @@ describe("removeBackground", () => {
});
});
it("serializes edgeRefine option into the args JSON", async () => {
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { edgeRefine: 2 });
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
expect(JSON.parse(args[2])).toEqual({ edgeRefine: 2 });
});
it("serializes decontaminate option into the args JSON", async () => {
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, { decontaminate: true });
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
expect(JSON.parse(args[2])).toEqual({ decontaminate: true });
});
it("serializes all post-processing options together", async () => {
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR, {
model: "birefnet-matting",
edgeRefine: 1,
decontaminate: true,
});
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
expect(JSON.parse(args[2])).toEqual({
model: "birefnet-matting",
edgeRefine: 1,
decontaminate: true,
});
});
it("converts input to PNG via sharp before writing to disk", async () => {
await removeBackground(FAKE_INPUT, FAKE_OUTPUT_DIR);