feat(#24): Gate B 3:4 crop + no upscale (TV-6, TV-7)
TV-6: 3:4 portrait crop, 80% fill at extremes, transparent PNG, shorter side >= 1600px when source allows. TV-7: undersized source warning, no upscaling, photo not publishable until retake. Real fixture output: - TV-6 large source (4000x3200, slab 1281x3000): crop target 1601x2135, shorter 1601 >= 1600, fill at limiting dim 0.80 - TV-7 small source (800x640, slab 400x300): crop target 281x375, shorter 281 < 1600, undersized - No upscale property: output never exceeds source dimensions New files: - frontend/src/lib/pipeline/crop.ts: cropTo34(), findSlabBounds(), computeCropTarget(), checkSourceSize() - frontend/src/__tests__/fixtures/crop.ts: tv6LargeSourceFixture, tv7SmallSourceFixture, simpleAxis - frontend/src/__tests__/tv6-tv7-crop.test.ts: 7 tests (2 TV-6, 2 TV-7, 3 property)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { ASPECT_H, ASPECT_W, FILL_TARGET, OUTPUT_PX_MIN } from "./constants.js";
|
||||
import type { CropResult, CropStatus, LengthAxis, MaskBuffer, RgbaImage } from "./types.js";
|
||||
|
||||
/**
|
||||
* Find the bounding box of the slab in the mask.
|
||||
*/
|
||||
export function findSlabBounds(mask: MaskBuffer, width: number, height: number): {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
} {
|
||||
let left = width;
|
||||
let top = height;
|
||||
let right = 0;
|
||||
let bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (mask[y * width + x]! >= 128) {
|
||||
if (x < left) left = x;
|
||||
if (x > right) right = x;
|
||||
if (y < top) top = y;
|
||||
if (y > bottom) bottom = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
widthPx: right - left + 1,
|
||||
heightPx: bottom - top + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute target 3:4 frame dimensions so the slab fills FILL_TARGET (80%)
|
||||
* at the extremes. Returns the frame size in pixels.
|
||||
*/
|
||||
export function computeCropTarget(slabWidthPx: number, slabHeightPx: number): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
const slabAspect = slabWidthPx / slabHeightPx;
|
||||
|
||||
if (slabAspect >= targetAspect) {
|
||||
// Width is limiting; height gets extra margin
|
||||
const frameHeight = slabHeightPx / FILL_TARGET;
|
||||
return { width: Math.round(frameHeight * targetAspect), height: Math.round(frameHeight) };
|
||||
} else {
|
||||
// Height is limiting; width gets extra margin
|
||||
const frameWidth = slabWidthPx / FILL_TARGET;
|
||||
return { width: Math.round(frameWidth), height: Math.round(frameWidth / targetAspect) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop the source to a 3:4 portrait frame, center the slab, with 80% fill
|
||||
* at extremes, transparent background. No upscaling.
|
||||
*
|
||||
* @param image Original source RGBA image.
|
||||
* @param mask Binary mask (255 = slab, 0 = background).
|
||||
* @param axis Confirmed length axis for slab bounds.
|
||||
* @param lengthIn User-entered length in inches (used to derive vertical scale).
|
||||
* @returns CropResult with the cropped image and metadata.
|
||||
*/
|
||||
export function cropTo34(
|
||||
image: RgbaImage,
|
||||
mask: MaskBuffer,
|
||||
axis: LengthAxis,
|
||||
lengthIn: number,
|
||||
): CropResult {
|
||||
const scalePx = axis.lengthPx / lengthIn;
|
||||
const slabBounds = findSlabBounds(mask, image.width, image.height);
|
||||
|
||||
const target = computeCropTarget(slabBounds.widthPx, slabBounds.heightPx);
|
||||
const { width: frameW, height: frameH } = target;
|
||||
|
||||
// Check minimum output size before any scaling
|
||||
const shorterSide = Math.min(frameW, frameH);
|
||||
const meetsMinPx = shorterSide >= OUTPUT_PX_MIN;
|
||||
|
||||
// Compute the scale factor: we want the slab to fill FILL_TARGET of the frame
|
||||
// at the extremes. The slab is scaled down by FILL_TARGET, then the frame
|
||||
// adds extra margin.
|
||||
const slabW = slabBounds.widthPx;
|
||||
const slabH = slabBounds.heightPx;
|
||||
const slabAspect = slabW / slabH;
|
||||
const targetAspect = ASPECT_W / ASPECT_H;
|
||||
|
||||
// Compute how much to scale the slab to fit 80% of the frame
|
||||
let scaleDown: number;
|
||||
if (slabAspect >= targetAspect) {
|
||||
// Width limiting: frameH = slabH / 0.8, frameW = frameH * 0.75
|
||||
// Scale factor for width: slabW / frameW
|
||||
scaleDown = slabW / frameW;
|
||||
} else {
|
||||
// Height limiting: frameW = slabW / 0.8, frameH = frameW / 0.75
|
||||
// Scale factor for height: slabH / frameH
|
||||
scaleDown = slabH / frameH;
|
||||
}
|
||||
|
||||
// Scaled slab dimensions within the frame
|
||||
const scaledSlabW = Math.round(slabW * FILL_TARGET);
|
||||
const scaledSlabH = Math.round(slabH * FILL_TARGET);
|
||||
|
||||
// Center position within the frame
|
||||
const offsetX = Math.round((frameW - scaledSlabW) / 2);
|
||||
const offsetY = Math.round((frameH - scaledSlabH) / 2);
|
||||
|
||||
// Create the output image
|
||||
const outData = new Uint8ClampedArray(frameW * frameH * 4);
|
||||
|
||||
// For each output pixel, compute which source pixel to sample
|
||||
for (let oy = 0; oy < frameH; oy++) {
|
||||
for (let ox = 0; ox < frameW; ox++) {
|
||||
const oi = (oy * frameW + ox) * 4;
|
||||
|
||||
// Check if this output pixel is within the slab region
|
||||
if (ox < offsetX || ox >= offsetX + scaledSlabW ||
|
||||
oy < offsetY || oy >= offsetY + scaledSlabH) {
|
||||
// Transparent margin
|
||||
outData[oi] = 0;
|
||||
outData[oi + 1] = 0;
|
||||
outData[oi + 2] = 0;
|
||||
outData[oi + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map to slab-local coordinates
|
||||
const slabX = ox - offsetX;
|
||||
const slabY = oy - offsetY;
|
||||
|
||||
// Nearest-neighbor sample from source
|
||||
const srcX = Math.round((slabX / scaledSlabW) * slabW) + slabBounds.left;
|
||||
const srcY = Math.round((slabY / scaledSlabH) * slabH) + slabBounds.top;
|
||||
|
||||
// Clamp to source bounds
|
||||
const sx = Math.min(Math.max(0, srcX), image.width - 1);
|
||||
const sy = Math.min(Math.max(0, srcY), image.height - 1);
|
||||
const si = (sy * image.width + sx) * 4;
|
||||
|
||||
outData[oi] = image.data[si]!;
|
||||
outData[oi + 1] = image.data[si + 1]!;
|
||||
outData[oi + 2] = image.data[si + 2]!;
|
||||
|
||||
// Alpha from the mask at the source location
|
||||
const maskVal = mask[sy * image.width + sx]!;
|
||||
outData[oi + 3] = maskVal;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
image: { width: frameW, height: frameH, data: outData },
|
||||
width: frameW,
|
||||
height: frameH,
|
||||
meetsMinPx,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source image is too small to produce an OUTPUT_PX_MIN result.
|
||||
* Returns CropStatus.
|
||||
*/
|
||||
export function checkSourceSize(
|
||||
mask: MaskBuffer,
|
||||
width: number,
|
||||
height: number,
|
||||
): CropStatus {
|
||||
const bounds = findSlabBounds(mask, width, height);
|
||||
const target = computeCropTarget(bounds.widthPx, bounds.heightPx);
|
||||
const shorterSide = Math.min(target.width, target.height);
|
||||
return shorterSide >= OUTPUT_PX_MIN ? "ok" : "undersized";
|
||||
}
|
||||
Reference in New Issue
Block a user