fix(gate-b): drawMaskOverlay — cutout on checkerboard, no green overlay (#55)

Replace green overlay rendering with cutout-on-checkerboard preview:
- Slab pixels show actual photo RGB with mask-based alpha
- Removed background area shows 10px checkerboard (224/192 gray)
- Knob debounce from a1d4a96 preserved unchanged

Update Playwright test to assert checkerboard + slab pixels
visible, not green overlay avgAlpha.
This commit is contained in:
grokbot_ty
2026-09-08 00:30:24 +00:00
parent 3496670045
commit 83171fab6e
2 changed files with 92 additions and 55 deletions
+32 -13
View File
@@ -337,21 +337,40 @@
img.onload = () => { img.onload = () => {
canvas.width = photo.rgba!.width; canvas.width = photo.rgba!.width;
canvas.height = photo.rgba!.height; canvas.height = photo.rgba!.height;
ctx.drawImage(img, 0, 0);
const imgData = ctx.createImageData(canvas.width, canvas.height); const w = canvas.width;
const h = canvas.height;
const srcData = photo.rgba!.data;
const mask = photo.maskResult!.mask; const mask = photo.maskResult!.mask;
// Translucent light green overlay — photo remains clearly visible. const CHECKER_SIZE = 10;
// Max alpha 80 (~31% opacity) on full-slab pixels; feathered edge blends smoothly.
for (let i = 0; i < mask.length; i++) { // Build ImageData: slab pixels pass through; background = checkerboard.
const alpha = Math.round((mask[i]! / 255) * 80); const outData = ctx.createImageData(w, h);
const pixelIdx = i * 4; const out = outData.data;
imgData.data[pixelIdx] = 60; for (let i = 0; i < w * h; i++) {
imgData.data[pixelIdx + 1] = 220; const srcIdx = i * 4;
imgData.data[pixelIdx + 2] = 60; const outIdx = i * 4;
imgData.data[pixelIdx + 3] = alpha; const maskVal = mask[i]!;
if (maskVal > 0) {
// Slab pixel — show actual photo RGB, alpha from mask.
out[outIdx] = srcData[srcIdx]!;
out[outIdx + 1] = srcData[srcIdx + 1]!;
out[outIdx + 2] = srcData[srcIdx + 2]!;
out[outIdx + 3] = maskVal;
} else {
// Background removed — checkerboard.
const px = i % w;
const py = Math.floor(i / w);
const isLight = (Math.floor(px / CHECKER_SIZE) + Math.floor(py / CHECKER_SIZE)) % 2 === 0;
const v = isLight ? 224 : 192;
out[outIdx] = v;
out[outIdx + 1] = v;
out[outIdx + 2] = v;
out[outIdx + 3] = 255;
}
} }
ctx.putImageData(imgData, 0, 0); ctx.putImageData(outData, 0, 0);
URL.revokeObjectURL(img.src); URL.revokeObjectURL(img.src);
}; };
img.src = URL.createObjectURL(photo.blob); img.src = URL.createObjectURL(photo.blob);
@@ -520,7 +539,7 @@
{#if state.step === 'mask'} {#if state.step === 'mask'}
<div class="step-content mask-step"> <div class="step-content mask-step">
<h2>Mask & Knobs</h2> <h2>Mask & Knobs</h2>
<p class="instruction">Adjust knobs to refine the mask. Green overlay shows the detected slab.</p> <p class="instruction">Adjust knobs to refine the mask. Detected slab shows in foreground; checkerboard = removed background.</p>
<div class="canvas-row"> <div class="canvas-row">
<canvas class="mask-canvas" bind:this={maskCanvas}></canvas> <canvas class="mask-canvas" bind:this={maskCanvas}></canvas>
<div class="knobs-panel"> <div class="knobs-panel">
+60 -42
View File
@@ -2,7 +2,7 @@
* LIVE mask verification — issue #55, Gate B. * LIVE mask verification — issue #55, Gate B.
* *
* Tests three fundamentals against the deployed host: * Tests three fundamentals against the deployed host:
* 1. Overlay translucent — photo visible, not solid green washout. * 1. Cutout preview — slab pixels visible, checkerboard where background removed.
* 2. Knobs respond fast — debounce working, no multi-second lag. * 2. Knobs respond fast — debounce working, no multi-second lag.
* 3. Auto/green/black sheet does not mark entire frame as slab. * 3. Auto/green/black sheet does not mark entire frame as slab.
* *
@@ -34,7 +34,7 @@ async function loadToMaskStep(page: import('@playwright/test').Page) {
} }
test.describe('LIVE mask fundamentals (issue #55)', () => { test.describe('LIVE mask fundamentals (issue #55)', () => {
test('1. Overlay translucent — photo visible under green mask, not solid green washout', async ({ page }) => { test('1. Cutout preview — slab pixels visible on checkerboard, no green overlay', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await loadToMaskStep(page); await loadToMaskStep(page);
@@ -44,7 +44,8 @@ test.describe('LIVE mask fundamentals (issue #55)', () => {
path: resolve(evidenceDir, 'mask-overlay-live.png'), path: resolve(evidenceDir, 'mask-overlay-live.png'),
}); });
// Read canvas pixel data to assert overlay is translucent. // Read canvas pixel data: slab pixels show actual photo RGB,
// background shows checkerboard (224/192 gray, 255 alpha), no green.
const stats = await page.evaluate(() => { const stats = await page.evaluate(() => {
const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement;
if (!canvas) return { width: 0, height: 0 }; if (!canvas) return { width: 0, height: 0 };
@@ -52,47 +53,62 @@ test.describe('LIVE mask fundamentals (issue #55)', () => {
if (!ctx) return { width: 0, height: 0 }; if (!ctx) return { width: 0, height: 0 };
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { data } = imgData; const { data } = imgData;
const len = data.length; let slabPixels = 0;
let totalAlpha = 0; let checkerPixels = 0;
let greenPixelCount = 0; let greenTintPixels = 0;
let maxAlpha = 0; let hasColorVariance = false;
for (let i = 0; i < len; i += 4) { const totalPixels = canvas.width * canvas.height;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3]; const a = data[i + 3];
totalAlpha += a;
if (a > 0) greenPixelCount++; // Checkerboard pixel: gray (224 or 192), full alpha, no color.
if (a > maxAlpha) maxAlpha = a; if (a === 255 && (r === 224 || r === 192) && r === g && g === b) {
checkerPixels++;
}
// Slab pixel: not checkerboard, alpha from mask (may vary).
else if (a > 0) {
slabPixels++;
// Check if any slab pixel has color variance (not monochrome gray).
const maxC = Math.max(r, g, b);
const minC = Math.min(r, g, b);
if (maxC - minC > 5) hasColorVariance = true;
}
// Green-tint detection: green channel significantly > red and blue.
if (g > 180 && r < 100 && b < 100) {
greenTintPixels++;
}
} }
const avgAlpha = totalAlpha / (canvas.width * canvas.height);
return { return {
width: canvas.width, width: canvas.width,
height: canvas.height, height: canvas.height,
avgAlpha, slabPixels,
maxAlpha, checkerPixels,
greenPixelCount, greenTintPixels,
totalPixels: canvas.width * canvas.height, hasColorVariance,
totalPixels,
}; };
}); });
console.log(`Canvas: ${stats.width}x${stats.height}, avgAlpha=${stats.avgAlpha.toFixed(1)}, maxAlpha=${stats.maxAlpha}, greenPixels=${stats.greenPixelCount}/${stats.totalPixels} (${(stats.greenPixelCount / stats.totalPixels * 100).toFixed(1)}%)`); console.log(`Canvas: ${stats.width}x${stats.height}, slab=${stats.slabPixels}, checker=${stats.checkerPixels}, greenTint=${stats.greenTintPixels}`);
// Check 1 assertions: // a) Canvas has dimensions
// a) Canvas has dimensions (not blank)
expect(stats.width).toBeGreaterThan(0); expect(stats.width).toBeGreaterThan(0);
expect(stats.height).toBeGreaterThan(0); expect(stats.height).toBeGreaterThan(0);
// b) Average alpha must be well below 255 — the overlay is translucent. // b) Must have checkerboard pixels (removed background)
// Max alpha is 80 (~31% of 255), so average should be < 128 (50% opacity). expect(stats.checkerPixels).toBeGreaterThan(0);
expect(stats.avgAlpha).toBeLessThan(128);
expect(stats.avgAlpha).toBeGreaterThan(0); // Must have some overlay at all.
// c) Max alpha must be 80 or close (the fix set max alpha to 80). // c) Must have slab pixels (the wood showing through)
expect(stats.maxAlpha).toBeLessThan(170); // Well below full opacity. expect(stats.slabPixels).toBeGreaterThan(0);
// d) Green pixel fraction should not be ~100% — the overlay should NOT cover // d) Slab pixels must have color variance — real wood, not green wash
// the entire canvas with green washout. There should be visible photo areas. expect(stats.hasColorVariance).toBe(true);
const greenFraction = stats.greenPixelCount / stats.totalPixels;
expect(greenFraction).toBeLessThan(0.95); // e) No green tint pixels — the green overlay is gone
console.log(`Green overlay fraction: ${greenFraction.toFixed(2)} (must be <0.95)`); expect(stats.greenTintPixels).toBe(0);
}); });
test('2. Knobs respond fast on real photo — debounce working, no multi-second lag', async ({ page }) => { test('2. Knobs respond fast on real photo — debounce working, no multi-second lag', async ({ page }) => {
@@ -143,11 +159,9 @@ test.describe('LIVE mask fundamentals (issue #55)', () => {
await loadToMaskStep(page); await loadToMaskStep(page);
// Read the canvas pixel data and compute slab fraction. // Read the canvas pixel data and compute slab fraction.
// The canvas shows: photo + green overlay on slab pixels. // Canvas now shows: slab pixels (actual photo RGB, mask alpha) +
// Green overlay = (60, 220, 60) with alpha proportional to mask value. // checkerboard (224/192 gray, 255 alpha) where background was removed.
// Sheet pixels have alpha=0 (fully transparent). // Slab pixels are any non-checkerboard pixels with alpha > 0.
// So: count pixels where the green channel is > 150 AND red channel < 100
// to detect the green overlay, which indicates slab pixels.
const slabStats = await page.evaluate(() => { const slabStats = await page.evaluate(() => {
const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement; const canvas = document.querySelector('canvas.mask-canvas') as HTMLCanvasElement;
if (!canvas) return { width: 0, height: 0 }; if (!canvas) return { width: 0, height: 0 };
@@ -162,12 +176,9 @@ test.describe('LIVE mask fundamentals (issue #55)', () => {
const g = data[i + 1]; const g = data[i + 1];
const b = data[i + 2]; const b = data[i + 2];
const a = data[i + 3]; const a = data[i + 3];
// Green overlay is (60, 220, 60). Slab pixels have alpha > 0. // Checkerboard: r==g==b and r is 224 or 192, alpha 255.
// At max alpha (80), the blended pixel on white (240,240,240) would be: const isChecker = a === 255 && r === g && g === b && (r === 224 || r === 192);
// R: 240*(1-0.31) + 60*0.31 ≈ 184 if (!isChecker && a > 0) {
// G: 240*(1-0.31) + 220*0.31 ≈ 234
// So slab pixels have R < 240, G > 200, B < 240, a > 0
if (a > 0 && g > 150 && r < 230 && b < 230) {
slabPixels++; slabPixels++;
} }
} }
@@ -214,8 +225,15 @@ test.describe('LIVE mask fundamentals (issue #55)', () => {
const { data } = imgData; const { data } = imgData;
let slabPixels = 0; let slabPixels = 0;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3]; const a = data[i + 3];
if (a > 0) slabPixels++; // Checkerboard: r==g==b and r is 224 or 192, alpha 255.
const isChecker = a === 255 && r === g && g === b && (r === 224 || r === 192);
if (!isChecker && a > 0) {
slabPixels++;
}
} }
resolve({ resolve({
width: canvas.width, width: canvas.width,