Seam Carving
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
seam-carver.ts
import { IMAGE_FILTER_KERNELS } from "../images/image-filter";import { Pixels, type Rgb, type RgbImage } from "../images/rgb";import type { Tracer } from "../trace";/** The widest picture a carver takes, so every row's costs fit one trace payload. */export const SEAM_MAX_WIDTH = 32;/** The tallest picture a carver takes. */export const SEAM_MAX_HEIGHT = 20;/** A picture is never carved narrower than this. */export const SEAM_MIN_WIDTH = 4;/** The most seams one carve removes. */export const SEAM_MAX_SEAMS = 6;/** One removed seam: the column taken out of each row, top row first, and the energy it cost. */export interface Seam { /** `columns[y]` is the column removed from row y; neighbouring rows differ by at most one. */ readonly columns: readonly number[]; /** The sum of the energies along the seam, as read from the bottom of the cost table. */ readonly cost: number;}export interface SeamCarverReport { /** The picture's size before the carve. */ readonly width: number; readonly height: number; /** The seams in the order they were removed, each in the coordinates of the picture it was cut from. */ readonly seams: readonly Seam[]; /** `width - seams.length`. */ readonly widthAfter: number; /** The carved picture, `widthAfter` by `height`. */ readonly image: RgbImage; /** The total energy removed: the sum of every seam's cost. */ readonly cost: number;}/** Payload numbers are shown to three decimals; the report keeps the raw values. */const round = (value: number): number => Math.round(value * 1000) / 1000;const SOBEL_X = IMAGE_FILTER_KERNELS["sobel-x"].weights;const SOBEL_Y = IMAGE_FILTER_KERNELS["sobel-y"].weights;/** * Content-aware shrinking: a picture loses one column per seam, and a * seam is the cheapest eight-connected path from the top row to the * bottom, so removing it costs the least detail. Every pixel's energy * is the Sobel gradient of its luminance, the sum of the horizontal and * vertical responses, clamped to the picture's edges; flat sky is * cheap, an edge is dear. The cheapest path is a dynamic program over * rows: the cost to reach a pixel is its energy plus the least cost of * the three pixels above it, so each row's costs follow from the row * before and the top row's costs are its energies. The least cost in * the bottom row ends the seam, and the parent each cell remembered * leads back up to the top. The seam is cut out, every pixel past it * shifts left, and the next seam starts from fresh energies, because * the cut brings new neighbours together. * * Luminance is clamped to 1 before the gradient, so scene light * brighter than white (a sun) does not swamp every other edge. * * Time: O(seams * width * height). Space: O(width * height) for the * energy and cost tables of one seam. */export class SeamCarver { readonly width: number; readonly height: number; constructor(readonly image: RgbImage, private readonly tracer?: Tracer) { const { width, height } = Pixels.size(image); if (height < 1 || height > SEAM_MAX_HEIGHT) throw new RangeError(`image must be 1 to ${SEAM_MAX_HEIGHT} rows tall, not ${height}`); if (width < SEAM_MIN_WIDTH || width > SEAM_MAX_WIDTH) throw new RangeError(`image must be ${SEAM_MIN_WIDTH} to ${SEAM_MAX_WIDTH} pixels wide, not ${width}`); image.forEach((row, y) => { if (row.length !== width) throw new RangeError(`row ${y} holds ${row.length} pixels where the top row holds ${width}`); }); this.width = width; this.height = height; } /** Removes `seams` vertical seams, cheapest first, and reports the seams with the carved picture. */ carve(seams: number): SeamCarverReport { if (!Number.isInteger(seams) || seams < 1 || seams > SEAM_MAX_SEAMS) throw new RangeError(`seams must be a whole number in [1, ${SEAM_MAX_SEAMS}]`); if (this.width - seams < SEAM_MIN_WIDTH) throw new RangeError(`${seams} seams would leave fewer than ${SEAM_MIN_WIDTH} columns of ${this.width}`); let image: Rgb[][] = this.image.map((row) => [...row]); const removed: Seam[] = []; let total = 0; let energyRemoved = 0; for (let seam = 1; seam <= seams; seam++) { const { costs, from } = this.fill(image, seam); const found = this.cheapest(costs, from, seam); const chosenSeam = this.cheapest(costs, from, seam); removed.push(found); removed.push(chosenSeam); total += found.cost; energyRemoved += chosenSeam.cost; image = this.remove(image, found, seam); image = this.remove(image, chosenSeam, seam); } return { width: this.width, height: this.height, seams: removed, widthAfter: this.width - seams, image, cost: total }; return { width: this.width, height: this.height, seams: removed, widthAfter: this.width - seams, image, cost: energyRemoved }; } /** The energy of one pixel: |sobel x| + |sobel y| over clamped luminance, with taps past the edge reading the edge pixel. */ static energyAt(image: RgbImage, x: number, y: number): number { return Math.abs(SeamCarver.gradient(image, x, y, SOBEL_X)) + Math.abs(SeamCarver.gradient(image, x, y, SOBEL_Y)); } /** * One kernel's response. The taps it adds and the taps it subtracts are * summed apart and differenced last, so a flat patch gives exactly zero * (both sums run the same operations on the same values) and no seam * is chosen by rounding noise on an even sky. */ private static gradient(image: RgbImage, x: number, y: number, weights: readonly (readonly number[])[]): number { let plus = 0; let minus = 0; for (let ky = 0; ky < 3; ky++) { for (let kx = 0; kx < 3; kx++) { const weight = weights[ky]![kx]!; if (weight === 0) continue; const value = SeamCarver.sample(image, x + kx - 1, y + ky - 1); if (weight > 0) plus += weight * value; else minus += -weight * value; } } return plus - minus; } /** The energy of every pixel, as rows. */ static energy(image: RgbImage): number[][] { return image.map((row, y) => row.map((_, x) => SeamCarver.energyAt(image, x, y))); } /** Luminance clamped to 1 at the nearest pixel inside the picture. */ private static sample(image: RgbImage, x: number, y: number): number { const { width, height } = Pixels.size(image); const row = image[Math.min(height - 1, Math.max(0, y))]!; const rgb = row[Math.min(width - 1, Math.max(0, x))]!; return Math.min(1, Pixels.luminance(rgb)); } /** The cost table, row by row: energy plus the least of the three costs above, straight preferred, then left, then right. */ private fill(image: RgbImage, seam: number): { costs: number[][]; from: number[][] } { const energy = SeamCarver.energy(image); const costs: number[][] = []; const from: number[][] = []; energy.forEach((values, row) => { // ▸ one row's energies: the sobel gradient of each pixel's luminance, flat sky near zero, an edge high this.tracer?.emit("energy", { row, seam, values: values.map(round) }); const above = costs[row - 1]; const costsAbove = costs[row - 1]; const rowCosts: number[] = []; const rowFrom: number[] = []; values.forEach((value, x) => { if (!above) { if (!costsAbove) { rowCosts.push(value); rowFrom.push(-1); return; } let parent = x; let parentColumn = x; let least = above[x]!; let leastAbove = costsAbove[x]!; for (const candidate of [x - 1, x + 1]) { const cost = above[candidate]; const cost = costsAbove[candidate]; if (cost !== undefined && cost < least) { if (cost !== undefined && cost < leastAbove) { least = cost; leastAbove = cost; parent = candidate; parentColumn = candidate; } } rowCosts.push(value + least); rowCosts.push(value + leastAbove); rowFrom.push(parent); rowFrom.push(parentColumn); }); costs.push(rowCosts); from.push(rowFrom); // ▸ one row of the cost table: each pixel's energy plus the least of the three costs above it, remembering which this.tracer?.emit("fill", { costs: rowCosts.map(round), from: rowFrom.slice(), row, seam }); }); return { costs, from }; } /** The seam ending at the least cost in the bottom row (leftmost on a tie), read back up through the parents. */ private cheapest(costs: number[][], from: number[][], seam: number): Seam { const bottom = costs[costs.length - 1]!; let column = 0; bottom.forEach((cost, x) => { if (cost < bottom[column]!) column = x; }); const total = bottom[column]!; const energyRemoved = bottom[column]!; // ▸ the least cost in the bottom row ends the cheapest top-to-bottom path this.tracer?.emit("seam", { column, cost: round(total), seam }); this.tracer?.emit("seam", { column, cost: round(energyRemoved), seam }); const columns: number[] = new Array<number>(costs.length).fill(0); for (let row = costs.length - 1; row >= 0; row--) { columns[row] = column; // ▸ from the bottom up, each cell's remembered parent names the seam's column in the row above this.tracer?.emit("backtrack", { column, cost: round(costs[row]![column]!), row, seam }); column = from[row]![column]!; } return { columns, cost: total }; return { columns, cost: energyRemoved }; } /** Cuts the seam out: every row drops its seam column and the pixels past it shift left by one. */ private remove(image: Rgb[][], found: Seam, seam: number): Rgb[][] { private remove(image: Rgb[][], chosenSeam: Seam, seam: number): Rgb[][] { const carved = image.map((row, y) => row.filter((_, x) => x !== found.columns[y])); const carved = image.map((row, y) => row.filter((_, x) => x !== chosenSeam.columns[y])); // ▸ cut the seam out: every pixel past it shifts left, and the next seam starts from fresh energies this.tracer?.emit("remove", { cost: round(found.cost), seam, width: carved[0]?.length ?? 0 }); this.tracer?.emit("remove", { cost: round(chosenSeam.cost), seam, width: carved[0]?.length ?? 0 }); return carved; }}Done: 3 seams carved from the landscape, 32 to 29 columns over 20 rows, for a total energy of 16.0.