Files
hyperframes/packages/cli/src/background-removal/inference.test.ts
T
JamesandClaude Opus 4.7 a707b6a882 fix(cli): pin inverse-alpha invariants, harden encoder stdin
- Extract applyMask helper from postprocess and add 5 unit tests pinning
  the contract this PR is selling: fg.alpha + bg.alpha === 255 per pixel,
  RGB triples byte-identical between fg and bg, and bg=null path leaves
  the bg buffer untouched. Without these, a future postprocess change
  (mask threshold, premultiplied alpha, gamma) could silently break the
  inverse-alpha relationship and the existing plumbing tests would all
  still pass.
- Add stdin 'error' listener inside spawnFfmpeg. If either encoder dies
  mid-render, Node emits an unhandled error on the dead writable on the
  next .write() and crashes the CLI before waitForExit's reject path
  can surface the encoder's stderr tail. Doubled encoder count = doubled
  failure surface, so this is worth pinning down.
- Tighten stdio param to a 3-tuple so an accidental 1-element array fails
  at type-check.
- Sharpen backpressure comment: write→true means "highWaterMark not
  exceeded," not "libuv flushed." Reuse-without-corruption is safe only
  because session.process is slow enough that libuv drains in between.

Addresses review on PR #637.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:18:23 -07:00

128 lines
4.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { MEAN, STD, applyMask } from "./inference.js";
// Regression: the u2net_human_seg model was trained with ImageNet
// normalization. Drifting away from these exact values changes the input
// tensor at every pixel and shifts the predicted alpha mask noticeably
// (Miguel reproduced 8,317 pixel changes with delta up to 78/255 when std
// was set to (1, 1, 1)). Reference:
// https://github.com/danielgatis/rembg/blob/main/rembg/sessions/u2net_human_seg.py#L33
describe("background-removal/inference — rembg u2net_human_seg parity", () => {
it("MEAN matches U2netHumanSegSession reference", () => {
expect(MEAN).toEqual([0.485, 0.456, 0.406]);
});
it("STD matches U2netHumanSegSession reference (ImageNet, not the base u2net's (1,1,1))", () => {
expect(STD).toEqual([0.229, 0.224, 0.225]);
});
});
// These tests pin the contract that `--background-output` is built on:
// fg.alpha + bg.alpha === 255 per pixel, and the RGB plane is byte-identical
// between fg and bg. A future change to the postprocess loop (different mask
// threshold, premultiplied alpha, gamma-corrected compositing) that breaks
// either invariant should fail here loudly.
describe("background-removal/inference — applyMask invariants", () => {
function makeRgb(pixels: number): Buffer {
// Deterministic but non-trivial RGB so byte equality is meaningful.
const buf = Buffer.allocUnsafe(pixels * 3);
for (let i = 0; i < pixels; i++) {
buf[i * 3] = (i * 7) & 0xff;
buf[i * 3 + 1] = (i * 13 + 31) & 0xff;
buf[i * 3 + 2] = (i * 19 + 61) & 0xff;
}
return buf;
}
function makeMask(pixels: number): Buffer {
// Hit the saturation endpoints (0, 255) and a few mid-tone values so the
// 255-m inversion is exercised across the full byte range.
const buf = Buffer.allocUnsafe(pixels);
for (let i = 0; i < pixels; i++) buf[i] = (i * 37) & 0xff;
return buf;
}
it("dual-output: fg.alpha + bg.alpha === 255 for every pixel", () => {
const pixels = 64;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
const result = applyMask(rgb, mask, fg, bg, pixels);
expect(result.fg).toBe(fg);
expect(result.bg).toBe(bg);
for (let i = 0; i < pixels; i++) {
const sum = fg[i * 4 + 3]! + bg[i * 4 + 3]!;
expect(sum).toBe(255);
}
});
it("dual-output: RGB triples are byte-identical between fg and bg", () => {
const pixels = 64;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
applyMask(rgb, mask, fg, bg, pixels);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4]).toBe(bg[i * 4]);
expect(fg[i * 4 + 1]).toBe(bg[i * 4 + 1]);
expect(fg[i * 4 + 2]).toBe(bg[i * 4 + 2]);
// And both match the source.
expect(fg[i * 4]).toBe(rgb[i * 3]);
expect(fg[i * 4 + 1]).toBe(rgb[i * 3 + 1]);
expect(fg[i * 4 + 2]).toBe(rgb[i * 3 + 2]);
}
});
it("dual-output: fg.alpha equals the input mask", () => {
const pixels = 32;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const bg = Buffer.allocUnsafe(pixels * 4);
applyMask(rgb, mask, fg, bg, pixels);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4 + 3]).toBe(mask[i]);
}
});
it("single-output: bg=null returns bg=null and writes only fg", () => {
const pixels = 32;
const rgb = makeRgb(pixels);
const mask = makeMask(pixels);
const fg = Buffer.allocUnsafe(pixels * 4);
const result = applyMask(rgb, mask, fg, null, pixels);
expect(result.bg).toBeNull();
expect(result.fg).toBe(fg);
for (let i = 0; i < pixels; i++) {
expect(fg[i * 4]).toBe(rgb[i * 3]);
expect(fg[i * 4 + 3]).toBe(mask[i]);
}
});
it("saturates correctly at mask=0 and mask=255", () => {
// mask=0 → fg.alpha=0 (transparent subject), bg.alpha=255 (fully opaque plate)
// mask=255 → fg.alpha=255 (fully opaque subject), bg.alpha=0 (transparent plate)
const rgb = Buffer.from([10, 20, 30, 40, 50, 60]);
const mask = Buffer.from([0, 255]);
const fg = Buffer.allocUnsafe(8);
const bg = Buffer.allocUnsafe(8);
applyMask(rgb, mask, fg, bg, 2);
expect(fg[3]).toBe(0);
expect(bg[3]).toBe(255);
expect(fg[7]).toBe(255);
expect(bg[7]).toBe(0);
});
});