← Applied algorithms
Images, maps, and geometry A shortest path hiding inside a photograph

Seam carving

Make room without losing the moment.

Every image card you’ve built already makes this choice. Put a photo in a slot with a different shape and object-fit decides what gives: cover crops, contain scales it down and leaves bars, and fill, the default, stretches it. Seam carving is a fourth answer CSS doesn’t have: take pixels out of the parts of the picture nobody will miss.

Here’s a photo that needs it. You’re preparing a trip card with two hikers standing far apart, and the card needs a narrower image. A crop cuts into a hiker. A squeeze makes them both thinner. Scaling keeps everyone but shrinks the whole picture. There’s a lot of empty space between them. Could we remove that instead?

TypeScriptGoOne trip-card preview in each language

01 / The idea

What if the cut could bend?

A straight vertical cut removes the same column from every row. It might pass through a hiker even when there’s empty sky above and open sand below. A winding cut has more room to work.

A vertical seam is a connected path from the top of an image to the bottom, containing one pixel in every row. At the next row it can stay in the same column or move one column left or right. Remove those pixels and close each row’s gap: the picture becomes one pixel narrower.

Seam carving repeats that operation, choosing paths with low total energy, a score we’ll define in a moment. First, try it. Watch the people, the gap between them, and the mountain ridge.

Make room for both hikers.

Predict what will happen to their proportions and the space between them. Try 75% width first.

Current: 288 × 192 px0 seams removed
Original 288 × 192
Original: two hikers stand far apart beside a lake.
Seam-carved preview 288 × 192
Seam-carved photo at 288 pixels wide.

Move the width slider, or reveal the first seam before removing it.

Scale proportionally
Scale proportionally to 288 pixels wide.

Both people stay. The picture becomes shorter, leaving space in this fixed-height slot.

Center crop
Center crop to 288 pixels wide.

Pixel proportions stay. Content outside the narrower frame is cut away.

Squeeze horizontally
Squeeze horizontally to 288 pixels wide.

The complete frame fills the target width and height. Everything becomes thinner.

The surrounding slots stay fixed as the pictures narrow. Compare proportions within each group of equally sized panels. Growing the target rebuilds from the original; it does not invent deleted pixels.

A new width replaces pending work. Leaving the preview or hiding the page stops at the last completed removal; choose a width to continue. Reduced motion keeps the previous picture still until the requested result is ready.

An AI-generated teaching photograph at 288 × 192. The browser runs the TypeScript from this lesson and removes real pixels, down to 96 pixels wide.

The hikers keep much of their shape while the composition changes around them. Compare the smaller panels: a crop, a squeeze and a proportional scale, the same choices object-fit makes for you. The carved picture is different in kind. It now shows different spacing, and some details can bend, so a person should judge it before it goes anywhere.

Follow the example in your languages.

These choices apply to every comparison below. The photo lab runs the TypeScript; every language is checked against the same cases.

02 / Name the rule

Look for changes in color.

How can a program recognize space it might remove? We’ll start with a modest signal: compare the colors on opposite sides of a pixel. A smooth patch of sky tends to have small differences. The edge of a jacket against that sky tends to have larger ones.

Our energy function adds six absolute differences: left versus right, then above versus below, for each red, green, and blue channel. For grayscale neighbors with values 10 and 12 horizontally, and 20 and 25 vertically, the energy is 3 × (2 + 5) = 21. Color values have three potentially different differences in each direction.

At an image boundary, the missing neighbor is clamped to the edge pixel, so a one-pixel image has energy zero.

TypeScriptCalculate pixel energy
carve.ts
export function energy(image: Raster): number[][] {
	validateImage(image);
	const { width, height, pixels } = image;
	return Array.from({ length: height }, (_, y) =>
		Array.from({ length: width }, (_, x) => {
			const left = (y * width + Math.max(0, x - 1)) * 3;
			const right = (y * width + Math.min(width - 1, x + 1)) * 3;
			const above = (Math.max(0, y - 1) * width + x) * 3;
			const below = (Math.min(height - 1, y + 1) * width + x) * 3;
			let value = 0;
			for (let channel = 0; channel < 3; channel++) {
				value += Math.abs(pixels[left + channel] - pixels[right + channel]);
				value += Math.abs(pixels[above + channel] - pixels[below + channel]);
			}
			return value;
		})
	);
}
GoCalculate pixel energy
carve.go
func Energy(image Raster) ([][]int, error) {
	if err := validateImage(image); err != nil {
		return nil, err
	}
	width, height, pixels := image.Width, image.Height, image.Pixels
	grid := make([][]int, height)
	for y := 0; y < height; y++ {
		grid[y] = make([]int, width)
		for x := 0; x < width; x++ {
			left := (y*width + max(0, x-1)) * 3
			right := (y*width + min(width-1, x+1)) * 3
			above := (max(0, y-1)*width + x) * 3
			below := (min(height-1, y+1)*width + x) * 3
			for channel := 0; channel < 3; channel++ {
				grid[y][x] += abs(pixels[left+channel] - pixels[right+channel])
				grid[y][x] += abs(pixels[above+channel] - pixels[below+channel])
			}
		}
	}
	return grid, nil
}

The image is an opaque RGB array in row order. Pixel (x, y) starts at (y × width + x) × 3. Each channel is an integer from 0 through 255, so this energy is an integer from 0 through 1530.

Low energy means small local color differences. A flat-colored shirt can have low energy inside it. A busy patch of irrelevant gravel can have high energy. Our code has no idea that one belongs to a person.

Why this particular energy function?A useful proxy, and what others choose

We use absolute RGB differences so the browser and native examples agree exactly with bounded integers. It is backward energy: score the current image, then choose pixels to remove. It doesn’t look ahead at the new edges a removal will create.

The Princeton teaching specification uses a Euclidean RGB gradient and a fixed border energy of 1000. The path-finding idea works with either cost model, but the chosen seams can change.

Transparency and color management are left out. A real decoder settles those before supplying RGB values, so a transparent pixel’s hidden color never decides an edit.

03 / Follow one operation

A cheap step can lead somewhere expensive.

It’s tempting to start at the cheapest top pixel, then choose the cheapest reachable pixel in each next row. But that commits us early. A slightly more expensive start might lead to a much cheaper route further down.

Instead, ask a smaller question at every pixel: what is the cheapest total cost of reaching this pixel from the top? The first row is easy. Each pixel can start a path, so its answer is its own energy.

To reach a pixel in the second row, we must come from an allowed neighbor above it. Take the cheapest total among those neighbors and add this pixel’s energy. Once a whole row is answered, it supplies everything the next row needs.

The cheapest next pixel can be a trap.

Start with these invented energy values. A seam can move down-left, down, or down-right. Which top pixel would you choose?

Energy at each pixel. Coordinates start at zero.
↓ / →0123
0
1
2
3

Row 0, column 0

A path can start here. There is no earlier row, so its accumulated cost is its own energy: 1.

Change a cell and try again. Editing clears the revealed costs and marked paths. The algorithm keeps a best route to every cell so an initially expensive start remains an option.

A 4 × 4 teaching energy grid, separate from the photo. Costs are exact sums, not milliseconds or probabilities. The TypeScript implementation computes the result; the controls reveal rows in dependency order.

On the starting grid, greedy pays 1 + 1 + 9 + 9 = 20. The best seam pays 4 + 4 + 1 + 1 = 10. Keeping the best route to each cell leaves that second starting point available.

This is dynamic programming: answer overlapping smaller questions once and reuse their answers. We keep a cost for every cell; we don’t need to retain every complete route that reaches it.

04 / Read the shape

Save the cost. Remember where it came from.

For each cell, examine at most three predecessors. Store the winning total and the predecessor’s column. When we reach the last row, its smallest total tells us where an optimal seam ends. Following those saved columns upward recovers the path.

Cheapest total to this pixel

its energy + the cheapest allowed total above it

First row: its own energy. Outside the image: no incoming path.

TypeScriptFind and recover a seam
carve.ts
export function findSeam(grid: number[][]): Seam {
	validateGrid(grid);
	const height = grid.length,
		width = grid[0].length;
	const costs = grid.map((row) => [...row]);
	const parents = grid.map((row) => row.map(() => -1));
	for (let y = 1; y < height; y++) {
		for (let x = 0; x < width; x++) {
			let best = Math.max(0, x - 1);
			for (let p = best + 1; p <= Math.min(width - 1, x + 1); p++) {
				if (costs[y - 1][p] < costs[y - 1][best]) best = p;
			}
			costs[y][x] += costs[y - 1][best];
			parents[y][x] = best;
		}
	}
	let end = 0;
	for (let x = 1; x < width; x++) {
		if (costs[height - 1][x] < costs[height - 1][end]) end = x;
	}
	const total = costs[height - 1][end];
	const columns = Array<number>(height);
	for (let y = height - 1; y >= 0; y--) {
		columns[y] = end;
		end = parents[y][end];
	}
	return { columns, total, costs, parents };
}
GoFind and recover a seam
carve.go
func FindSeam(grid [][]int) (Seam, error) {
	if err := validateGrid(grid); err != nil {
		return Seam{}, err
	}
	height, width := len(grid), len(grid[0])
	costs, parents := make([][]int, height), make([][]int, height)
	for y, row := range grid {
		costs[y] = append([]int(nil), row...)
		parents[y] = make([]int, width)
		for x := range parents[y] {
			parents[y][x] = -1
		}
	}
	for y := 1; y < height; y++ {
		for x := 0; x < width; x++ {
			best := max(0, x-1)
			for p := best + 1; p <= min(width-1, x+1); p++ {
				if costs[y-1][p] < costs[y-1][best] {
					best = p
				}
			}
			costs[y][x] += costs[y-1][best]
			parents[y][x] = best
		}
	}
	end := 0
	for x := 1; x < width; x++ {
		if costs[height-1][x] < costs[height-1][end] {
			end = x
		}
	}
	total := costs[height-1][end]
	columns := make([]int, height)
	for y := height - 1; y >= 0; y-- {
		columns[y] = end
		end = parents[y][end]
	}
	return Seam{columns, total, costs, parents}, nil
}

The fact we preserve after finishing a row is precise: every cost in that row is the cheapest total of a legal path from the top to that cell. Every incoming path must pass through one of the allowed predecessors, whose best totals are already known. Taking their minimum covers every possible way in.

Rows advance downward until there are none left. Equal totals choose the smaller predecessor column; equal endpoints choose the smaller final column. That makes every implementation return the same optimal seam, though another tied seam would be just as cheap.

Where is the graph?The pixel grid already supplies the edges

Treat each pixel as a vertex and each allowed downward step as a directed edge. The path pays for the vertices it visits. It can never return to an earlier row, so this graph has no directed cycle. Row order already puts each predecessor before the cells that need it.

We can solve this shortest-path problem directly in the arrays. A heap or explicit graph object would add machinery without changing which neighbors are possible. The Princeton specification describes the same graph connection.

Close one gap in each row.

The seam gives us one column per row. Copy every other pixel in its original order. The output has the same height and one less column.

TypeScriptRemove a connected seam
carve.ts
export function removeSeam(image: Raster, columns: number[]): Raster {
	validateImage(image);
	const { width, height, pixels } = image;
	if (width <= 1 || columns.length !== height)
		throw new Error('Removal needs width > 1 and one column per row.');
	for (let y = 0; y < height; y++) {
		const x = columns[y];
		if (
			!Number.isInteger(x) ||
			x < 0 ||
			x >= width ||
			(y > 0 && Math.abs(x - columns[y - 1]) > 1)
		) {
			throw new Error('Seam columns must be in bounds and connected.');
		}
	}
	const result: number[] = [];
	for (let y = 0; y < height; y++) {
		for (let x = 0; x < width; x++) {
			if (x === columns[y]) continue;
			const offset = (y * width + x) * 3;
			result.push(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
		}
	}
	return { width: width - 1, height, pixels: result };
}
GoRemove a connected seam
carve.go
func RemoveSeam(image Raster, columns []int) (Raster, error) {
	if err := validateImage(image); err != nil {
		return Raster{}, err
	}
	width, height, pixels := image.Width, image.Height, image.Pixels
	if width <= 1 || len(columns) != height {
		return Raster{}, fmt.Errorf("removal needs width > 1 and one column per row")
	}
	for y, x := range columns {
		if x < 0 || x >= width || (y > 0 && abs(x-columns[y-1]) > 1) {
			return Raster{}, fmt.Errorf("seam columns must be in bounds and connected")
		}
	}
	result := make([]int, 0, (width-1)*height*3)
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			if x == columns[y] {
				continue
			}
			offset := (y*width + x) * 3
			result = append(result, pixels[offset:offset+3]...)
		}
	}
	return Raster{width - 1, height, result}, nil
}

To reach a target width, repeat on the new image. Removing a pixel makes formerly separated pixels neighbors. That can change their energy, so our next search starts with a freshly calculated map.

TypeScriptBuild a narrower preview
carve.ts
export function carve(image: Raster, targetWidth: number): Raster {
	validateImage(image);
	if (!Number.isInteger(targetWidth) || targetWidth < 1 || targetWidth > image.width) {
		throw new Error('Target width must be between 1 and the original width.');
	}
	let preview: Raster = { ...image, pixels: [...image.pixels] };
	while (preview.width > targetWidth) {
		const seam = findSeam(energy(preview));
		preview = removeSeam(preview, seam.columns);
	}
	return preview;
}
GoBuild a narrower preview
carve.go
func Carve(image Raster, targetWidth int) (Raster, error) {
	if err := validateImage(image); err != nil {
		return Raster{}, err
	}
	if targetWidth < 1 || targetWidth > image.Width {
		return Raster{}, fmt.Errorf("target width must be between 1 and the original width")
	}
	preview := Raster{image.Width, image.Height, append([]int(nil), image.Pixels...)}
	for preview.Width > targetWidth {
		grid, err := Energy(preview)
		if err != nil {
			return Raster{}, err
		}
		seam, err := FindSeam(grid)
		if err != nil {
			return Raster{}, err
		}
		preview, err = RemoveSeam(preview, seam.Columns)
		if err != nil {
			return Raster{}, err
		}
	}
	return preview, nil
}

The editor keeps the original and the width it wants; carve hands back a new preview each time. The caller below uses a tiny decoded image so the complete files run without an image library. The photo lab decodes its PNG into the same RGB arrays.

TypeScriptAt the editor’s call site
carve.ts
export function runExample(): void {
	// A tiny decoded image: each number becomes a grayscale RGB pixel.
	const values = [10, 10, 200, 10, 10, 200, 10, 10, 200];
	const original: Raster = { width: 3, height: 3, pixels: values.flatMap((v) => [v, v, v]) };
	const first = findSeam(energy(original));
	const preview = carve(original, 2);
	console.log(`seam: ${first.columns.join(',')}; energy: ${first.total}`);
	console.log(
		`preview: ${preview.width}x${preview.height}; original: ${original.width}x${original.height}`
	);
	// The editor displays the preview; the original remains available for another size.
}
GoAt the editor’s call site
carve.go
func runExample() error {
	values := []int{10, 10, 200, 10, 10, 200, 10, 10, 200}
	original := Raster{Width: 3, Height: 3}
	for _, value := range values {
		original.Pixels = append(original.Pixels, value, value, value)
	}
	grid, err := Energy(original)
	if err != nil {
		return err
	}
	first, err := FindSeam(grid)
	if err != nil {
		return err
	}
	preview, err := Carve(original, 2)
	if err != nil {
		return err
	}
	columns := make([]string, len(first.Columns))
	for y, x := range first.Columns {
		columns[y] = fmt.Sprint(x)
	}
	fmt.Printf("seam: %s; energy: %d\n", strings.Join(columns, ","), first.Total)
	fmt.Printf("preview: %dx%d; original: %dx%d\n", preview.Width, preview.Height, original.Width, original.Height)
	return nil
}

Each version prints seam: 0,0,0; energy: 0, followed by preview: 2x3; original: 3x3. The first column’s neighbors share its gray value; deleting it leaves the contrasting stripe. The original remains available for another preview.

Complete runnable filesAll types, validation, helpers and an invocation

Copy a complete file into a standalone directory. No third-party libraries are needed. It works on RGB values; decoding, display and encoding belong to the application.

TypeScript: compile with tsc carve.ts --target ES2022 --module commonjs --outDir out, then run node out/carve.js in a directory without an ES-module package setting. Go 1.22+: go run carve.go.

TypeScriptComplete files
carve.ts
// Opaque RGB pixels in row order. No image decoder or encoder is required.
export type Raster = { width: number; height: number; pixels: number[] };
export type Seam = { columns: number[]; total: number; costs: number[][]; parents: number[][] };
const MAX_SIDE = 512;

function dimension(value: number): boolean {
	return Number.isInteger(value) && value >= 1 && value <= MAX_SIDE;
}
function validateImage(image: Raster): void {
	if (
		!dimension(image.width) ||
		!dimension(image.height) ||
		image.pixels.length !== image.width * image.height * 3
	) {
		throw new Error('Expected a nonempty RGB image, at most 512 × 512.');
	}
	for (const channel of image.pixels) {
		if (!Number.isInteger(channel) || channel < 0 || channel > 255)
			throw new Error('RGB channels must be integers from 0 to 255.');
	}
}
function validateGrid(grid: number[][]): void {
	if (!dimension(grid.length) || !dimension(grid[0]?.length))
		throw new Error('Expected a nonempty energy grid, at most 512 × 512.');
	for (const row of grid) {
		if (row.length !== grid[0].length) throw new Error('Energy rows must have equal width.');
		for (const value of row) {
			if (!Number.isInteger(value) || value < 0 || value > 1_000_000)
				throw new Error('Energy must be an integer from 0 to 1,000,000.');
		}
	}
}

export function energy(image: Raster): number[][] {
	validateImage(image);
	const { width, height, pixels } = image;
	return Array.from({ length: height }, (_, y) =>
		Array.from({ length: width }, (_, x) => {
			const left = (y * width + Math.max(0, x - 1)) * 3;
			const right = (y * width + Math.min(width - 1, x + 1)) * 3;
			const above = (Math.max(0, y - 1) * width + x) * 3;
			const below = (Math.min(height - 1, y + 1) * width + x) * 3;
			let value = 0;
			for (let channel = 0; channel < 3; channel++) {
				value += Math.abs(pixels[left + channel] - pixels[right + channel]);
				value += Math.abs(pixels[above + channel] - pixels[below + channel]);
			}
			return value;
		})
	);
}

export function findSeam(grid: number[][]): Seam {
	validateGrid(grid);
	const height = grid.length,
		width = grid[0].length;
	const costs = grid.map((row) => [...row]);
	const parents = grid.map((row) => row.map(() => -1));
	for (let y = 1; y < height; y++) {
		for (let x = 0; x < width; x++) {
			let best = Math.max(0, x - 1);
			for (let p = best + 1; p <= Math.min(width - 1, x + 1); p++) {
				if (costs[y - 1][p] < costs[y - 1][best]) best = p;
			}
			costs[y][x] += costs[y - 1][best];
			parents[y][x] = best;
		}
	}
	let end = 0;
	for (let x = 1; x < width; x++) {
		if (costs[height - 1][x] < costs[height - 1][end]) end = x;
	}
	const total = costs[height - 1][end];
	const columns = Array<number>(height);
	for (let y = height - 1; y >= 0; y--) {
		columns[y] = end;
		end = parents[y][end];
	}
	return { columns, total, costs, parents };
}

export function removeSeam(image: Raster, columns: number[]): Raster {
	validateImage(image);
	const { width, height, pixels } = image;
	if (width <= 1 || columns.length !== height)
		throw new Error('Removal needs width > 1 and one column per row.');
	for (let y = 0; y < height; y++) {
		const x = columns[y];
		if (
			!Number.isInteger(x) ||
			x < 0 ||
			x >= width ||
			(y > 0 && Math.abs(x - columns[y - 1]) > 1)
		) {
			throw new Error('Seam columns must be in bounds and connected.');
		}
	}
	const result: number[] = [];
	for (let y = 0; y < height; y++) {
		for (let x = 0; x < width; x++) {
			if (x === columns[y]) continue;
			const offset = (y * width + x) * 3;
			result.push(pixels[offset], pixels[offset + 1], pixels[offset + 2]);
		}
	}
	return { width: width - 1, height, pixels: result };
}

export function carve(image: Raster, targetWidth: number): Raster {
	validateImage(image);
	if (!Number.isInteger(targetWidth) || targetWidth < 1 || targetWidth > image.width) {
		throw new Error('Target width must be between 1 and the original width.');
	}
	let preview: Raster = { ...image, pixels: [...image.pixels] };
	while (preview.width > targetWidth) {
		const seam = findSeam(energy(preview));
		preview = removeSeam(preview, seam.columns);
	}
	return preview;
}

export function runExample(): void {
	// A tiny decoded image: each number becomes a grayscale RGB pixel.
	const values = [10, 10, 200, 10, 10, 200, 10, 10, 200];
	const original: Raster = { width: 3, height: 3, pixels: values.flatMap((v) => [v, v, v]) };
	const first = findSeam(energy(original));
	const preview = carve(original, 2);
	console.log(`seam: ${first.columns.join(',')}; energy: ${first.total}`);
	console.log(
		`preview: ${preview.width}x${preview.height}; original: ${original.width}x${original.height}`
	);
	// The editor displays the preview; the original remains available for another size.
}

runExample();
GoComplete files
carve.go
package main

import (
	"fmt"
	"strings"
)

type Raster struct {
	Width, Height int
	Pixels        []int
}
type Seam struct {
	Columns        []int
	Total          int
	Costs, Parents [][]int
}

func dimension(value int) bool { return value >= 1 && value <= 512 }
func validateImage(image Raster) error {
	if !dimension(image.Width) || !dimension(image.Height) || len(image.Pixels) != image.Width*image.Height*3 {
		return fmt.Errorf("expected a nonempty RGB image, at most 512 x 512")
	}
	for _, channel := range image.Pixels {
		if channel < 0 || channel > 255 {
			return fmt.Errorf("RGB channels must be integers from 0 to 255")
		}
	}
	return nil
}
func validateGrid(grid [][]int) error {
	if !dimension(len(grid)) || !dimension(len(grid[0])) {
		return fmt.Errorf("expected a nonempty energy grid, at most 512 x 512")
	}
	for _, row := range grid {
		if len(row) != len(grid[0]) {
			return fmt.Errorf("energy rows must have equal width")
		}
		for _, value := range row {
			if value < 0 || value > 1000000 {
				return fmt.Errorf("energy must be from 0 to 1,000,000")
			}
		}
	}
	return nil
}
func abs(value int) int {
	if value < 0 {
		return -value
	}
	return value
}

func Energy(image Raster) ([][]int, error) {
	if err := validateImage(image); err != nil {
		return nil, err
	}
	width, height, pixels := image.Width, image.Height, image.Pixels
	grid := make([][]int, height)
	for y := 0; y < height; y++ {
		grid[y] = make([]int, width)
		for x := 0; x < width; x++ {
			left := (y*width + max(0, x-1)) * 3
			right := (y*width + min(width-1, x+1)) * 3
			above := (max(0, y-1)*width + x) * 3
			below := (min(height-1, y+1)*width + x) * 3
			for channel := 0; channel < 3; channel++ {
				grid[y][x] += abs(pixels[left+channel] - pixels[right+channel])
				grid[y][x] += abs(pixels[above+channel] - pixels[below+channel])
			}
		}
	}
	return grid, nil
}


func FindSeam(grid [][]int) (Seam, error) {
	if err := validateGrid(grid); err != nil {
		return Seam{}, err
	}
	height, width := len(grid), len(grid[0])
	costs, parents := make([][]int, height), make([][]int, height)
	for y, row := range grid {
		costs[y] = append([]int(nil), row...)
		parents[y] = make([]int, width)
		for x := range parents[y] {
			parents[y][x] = -1
		}
	}
	for y := 1; y < height; y++ {
		for x := 0; x < width; x++ {
			best := max(0, x-1)
			for p := best + 1; p <= min(width-1, x+1); p++ {
				if costs[y-1][p] < costs[y-1][best] {
					best = p
				}
			}
			costs[y][x] += costs[y-1][best]
			parents[y][x] = best
		}
	}
	end := 0
	for x := 1; x < width; x++ {
		if costs[height-1][x] < costs[height-1][end] {
			end = x
		}
	}
	total := costs[height-1][end]
	columns := make([]int, height)
	for y := height - 1; y >= 0; y-- {
		columns[y] = end
		end = parents[y][end]
	}
	return Seam{columns, total, costs, parents}, nil
}


func RemoveSeam(image Raster, columns []int) (Raster, error) {
	if err := validateImage(image); err != nil {
		return Raster{}, err
	}
	width, height, pixels := image.Width, image.Height, image.Pixels
	if width <= 1 || len(columns) != height {
		return Raster{}, fmt.Errorf("removal needs width > 1 and one column per row")
	}
	for y, x := range columns {
		if x < 0 || x >= width || (y > 0 && abs(x-columns[y-1]) > 1) {
			return Raster{}, fmt.Errorf("seam columns must be in bounds and connected")
		}
	}
	result := make([]int, 0, (width-1)*height*3)
	for y := 0; y < height; y++ {
		for x := 0; x < width; x++ {
			if x == columns[y] {
				continue
			}
			offset := (y*width + x) * 3
			result = append(result, pixels[offset:offset+3]...)
		}
	}
	return Raster{width - 1, height, result}, nil
}


func Carve(image Raster, targetWidth int) (Raster, error) {
	if err := validateImage(image); err != nil {
		return Raster{}, err
	}
	if targetWidth < 1 || targetWidth > image.Width {
		return Raster{}, fmt.Errorf("target width must be between 1 and the original width")
	}
	preview := Raster{image.Width, image.Height, append([]int(nil), image.Pixels...)}
	for preview.Width > targetWidth {
		grid, err := Energy(preview)
		if err != nil {
			return Raster{}, err
		}
		seam, err := FindSeam(grid)
		if err != nil {
			return Raster{}, err
		}
		preview, err = RemoveSeam(preview, seam.Columns)
		if err != nil {
			return Raster{}, err
		}
	}
	return preview, nil
}


func runExample() error {
	values := []int{10, 10, 200, 10, 10, 200, 10, 10, 200}
	original := Raster{Width: 3, Height: 3}
	for _, value := range values {
		original.Pixels = append(original.Pixels, value, value, value)
	}
	grid, err := Energy(original)
	if err != nil {
		return err
	}
	first, err := FindSeam(grid)
	if err != nil {
		return err
	}
	preview, err := Carve(original, 2)
	if err != nil {
		return err
	}
	columns := make([]string, len(first.Columns))
	for y, x := range first.Columns {
		columns[y] = fmt.Sprint(x)
	}
	fmt.Printf("seam: %s; energy: %d\n", strings.Join(columns, ","), first.Total)
	fmt.Printf("preview: %dx%d; original: %dx%d\n", preview.Width, preview.Height, original.Width, original.Height)
	return nil
}

func main() {
	if err := runExample(); err != nil {
		panic(err)
	}
}
Reading the TypeScriptPlain arrays, row copies, and exceptions

The image is a plain number[] and the grids are arrays of rows. A number could hold a fraction, so validation checks that every channel, energy, and dimension is a whole number in range before any table is allocated.

findSeam copies each row with a spread, so accumulated costs never overwrite the energy grid it was given, and carve copies the pixels before its first removal. Every refusal is a thrown Error.

Reading the GoFresh slices, min and max, and returned errors

FindSeam copies each row with append([]int(nil), row...). A shallow copy of the outer slice alone would still share its rows. The built-in min and max (Go 1.21 and later) keep predecessor columns inside the image.

Each function returns an error beside its result, and Carve stops at the first one.

What is refusedSizes, channels, energies, and seams

An image must be 1 to 512 pixels on each side, with three channels per pixel, each a whole number from 0 through 255. The seam finder accepts whole-number energies from 0 through 1,000,000 in grids of at most 512 × 512, with rows of equal width. A complete path therefore totals at most 512,000,000: exact in TypeScript numbers and within a signed 32-bit integer.

Removal validates the whole seam before copying: one column per row, in bounds, each step at most one column, and an image at least two pixels wide. carve returns an independent copy when asked for the original width, and treats a wider target or width zero as an error.

05 / Try a decision

Even the next cut is a new question.

Go back to the photo, reveal a seam, and remove it. Its pixels are gone; neighboring columns shift into the gap. A saved column number is meaningful only for the image it was calculated on.

The first seam is gone. What should the preview do next?

We removed one pixel from every row. The image is one column narrower, and some pixels now have new neighbors.

The stronger limit is what we asked the algorithm to optimize. It finds a minimum-energy seam for the current grid. Repeating that choice doesn’t promise the least visible distortion for the picture as a whole.

Push the photo to one-third width and compare the mountain ridge. The algorithm can keep both people while rearranging the landscape. For a diagram, a building elevation or a photograph used as evidence, changing that geometry may defeat the purpose of the image.

The technique’s own authors went after this. A 2008 follow-up by Rubinstein, Shamir and Avidan scores a seam by the energy its removal would add, called forward energy, which avoids some of the new edges you see along that ridge.

06 / Follow the cost

Every seam pays for the whole picture again.

Seam carving: time and extra space for a W × H image
OperationTimeExtra spaceWhat it assumes
Score every pixelO(WH)O(WH)Six channel differences per pixel, written into a new W × H energy grid.
Find the cheapest seamO(WH)O(WH)At most three predecessors per cell. The cost and parent tables are two more W × H grids.
Recover the seamO(H)O(H)Follow the saved parent columns from the bottom row to the top.
Remove one seamO(WH)O(WH)Copy every other pixel into a new image one column narrower.
Carve k columnsO(kWH)O(WH)All four steps again at widths W down to W − k + 1. One round’s tables are enough at a time.

One seam costs O(WH): scoring, searching, and copying each touch every pixel once, and recovering the path takes H steps. The tables are what let it recover the path. Keeping only the previous row of costs would still give the total, but the columns to remove need the W × H parent table.

Removing k columns visits widths W, W − 1, through W − k + 1. Recomputing and copying gives work proportional to H × [kW − k(k − 1)/2], or O(kWH) as a simple upper bound. Halving a 512-pixel-wide photo repeats the whole search 256 times.

That is why an editor previews a smaller copy. Downsampling changes the energy map, though, so look at the result at full size before approving it.

07 / Give it a real job

Let the editor own the original.

Picture the travel site’s content editor. An author uploads a wide photo for a story, and the story grid shows square cards. The editor offers a crop, a fit, and “make room”, with a slider for how much to narrow. As the author drags, the preview follows.

Original

Never edited

The uploaded photo, kept for every future layout.

Latest target

Changes while dragging

Only the newest slider position matters.

Preview

Replaced when work finishes

Shown with its size until someone approves it.

Approval is the moment a preview becomes an asset: encode it, store it beside the original, and let the grid use it. The next layout change starts from the original again, never from a carved copy.

This is work that belongs in the browser. The author wants the picture to follow their thumb, and a server round trip for every slider step would trail behind. It is also real work: every seam recomputes energy for the whole preview, which is why the next part is about keeping the page responsive while it runs.

Build UIs?CSS already crops, scales and squeezes your images. The fourth option is yours to run, and it belongs in a worker.

Where it already is in your components

Not in React, Svelte or the browser: none of them takes pixels out of a photograph for you. It is in the image tools you may already use. Photoshop has shipped it since CS4 as Content-Aware Scale, GIMP has the Liquid Rescale plug-in, and ImageMagick’s -liquid-rescale will “rescale image with seam-carving” from the command line.

In your components you reach for the other panels from the photo lab instead. On an <img> in a fixed slot, object-fit: cover crops, and object-position chooses which part survives. contain scales the whole picture down and leaves bars. fill, the initial value, stretches it to the box. In MDN’s words, the image is “clipped to fit” or “stretched to fit.”

<picture> goes a step further with art direction: a different file, cropped by a person, for a narrower layout. And next/image resizes. Its documentation covers device sizes, modern formats, layout stability and lazy loading; in the pinned Next.js 16.0.0 optimizer, optimizeImage asks Sharp to resize and re-encode. Each of these changes the frame or the file. None changes what is inside the picture.

So a carved image reaches your components the ordinary way: prepared and approved in the editor, then delivered by the same <img>, <picture> or next/image you already use.

When you have to own it

Now build that slider. Call carve from its input handler and the page locks up: JavaScript runs each task to completion before it handles the next event, so the slider stops following the pointer until the last seam is gone. Move the work into a Web Worker. Workers have OffscreenCanvas too, with the same getImageData and putImageData, if you want the worker to draw as well.

Pixels don’t cross threads for free. ImageData isn’t on the list of transferable objects, but its ArrayBuffer is. Transferring moves the memory instead of copying it, and leaves your side detached with a byteLength of 0. So the page keeps the original and transfers a copy for each request.

The drag also outruns the worker. A worker has its own event loop, and while one message handler is busy removing dozens of seams, the message saying the slider moved waits. The snippet removes one seam, then yields, so a newer request can arrive; the older loop sees it is no longer the latest and stops. Results carry their revision, and the page draws only the latest. The blunt alternative is worker.terminate(), which stops a worker at once without letting it finish, followed by a fresh worker.

preview-worker.ts
import { energy, findSeam, removeSeam, type Raster } from '../carve';

export type PreviewRequest = {
	revision: number;
	width: number;
	height: number;
	rgba: ArrayBuffer;
	target: number;
};
export type PreviewResult = { revision: number; width: number; height: number; rgba: ArrayBuffer };

// Canvas pixels are RGBA. The lesson's carve functions work on opaque RGB.
export function toRaster(rgba: Uint8ClampedArray, width: number, height: number): Raster {
	const pixels: number[] = [];
	for (let i = 0; i < rgba.length; i += 4) pixels.push(rgba[i], rgba[i + 1], rgba[i + 2]);
	return { width, height, pixels };
}

export function toRGBA(image: Raster): Uint8ClampedArray<ArrayBuffer> {
	const rgba = new Uint8ClampedArray(image.width * image.height * 4);
	for (let p = 0; p < image.width * image.height; p++) {
		rgba.set(image.pixels.slice(p * 3, p * 3 + 3), p * 4);
		rgba[p * 4 + 3] = 255;
	}
	return rgba;
}

// The same rule as the lesson's carve: a whole number of columns, at least 1, at most the width.
export function validTarget(target: number, width: number) {
	return Number.isInteger(target) && target >= 1 && target <= width;
}

// One seam at a time. Between seams, hand control back to the event loop so a newer
// request can be delivered, then stop if this one is no longer the latest.
export async function carveUntil(
	image: Raster,
	target: number,
	isLatest: () => boolean,
	yieldToEvents: () => Promise<void>
): Promise<Raster | null> {
	if (!validTarget(target, image.width))
		throw new RangeError('Target width must be between 1 and the original width.');
	let preview = image;
	while (preview.width > target) {
		if (!isLatest()) return null;
		preview = removeSeam(preview, findSeam(energy(preview)).columns);
		await yieldToEvents();
	}
	return isLatest() ? preview : null;
}

type WorkerScope = {
	onmessage: ((event: MessageEvent<PreviewRequest>) => void) | null;
	postMessage(message: PreviewResult, transfer: Transferable[]): void;
};

// In preview.worker.ts: servePreviews(self as unknown as WorkerScope)
export function servePreviews(
	scope: WorkerScope,
	yieldToEvents = () => new Promise<void>((done) => setTimeout(done, 0))
) {
	let latest = 0;
	scope.onmessage = async ({ data }) => {
		latest = data.revision;
		// Refuse an unreachable target here: a throw inside this async handler would be an
		// unhandled rejection. The page keeps showing its last preview.
		if (!validTarget(data.target, data.width)) return;
		const original = toRaster(new Uint8ClampedArray(data.rgba), data.width, data.height);
		const isLatest = () => data.revision === latest;
		const preview = await carveUntil(original, data.target, isLatest, yieldToEvents);
		if (!preview) return; // a newer slider position took over
		const rgba = toRGBA(preview);
		const { revision, height } = data;
		const result = { revision, width: preview.width, height, rgba: rgba.buffer };
		scope.postMessage(result, [rgba.buffer]); // moved, not copied
	};
}

// On the page: keep the original pixels, send a copy for every slider position.
export function connectPreview(
	worker: Pick<Worker, 'postMessage'> & {
		onmessage: ((event: { data: PreviewResult }) => void) | null;
	},
	original: { width: number; height: number; data: Uint8ClampedArray },
	draw: (result: PreviewResult) => void // e.g. ctx.putImageData(new ImageData(…), 0, 0)
) {
	let revision = 0;
	worker.onmessage = ({ data }) => {
		if (data.revision === revision) draw(data); // ignore results for older positions
	};
	return function requestPreview(target: number) {
		// Transferring detaches a buffer, so never transfer the original's.
		const copy = original.data.slice();
		const { width, height } = original;
		const request = { revision: ++revision, width, height, rgba: copy.buffer, target };
		worker.postMessage(request, [copy.buffer]);
	};
}

Nothing here belongs to React or Svelte. Both call requestPreview from the slider’s input handler and draw in the callback. The rule you are working around comes from the event loop, so it is the same in either.

08 / Make the call

An editing option you preview.

Seam carving is worth offering when a composition needs a different aspect ratio, there is removable space around the subjects, and someone can judge the result. Put it beside a crop and a replacement photo, and keep the original until the author accepts a derivative.

For routine responsive delivery, proportional scaling and an intentional crop are good defaults. Prefer them when geometry must stay faithful, text must stay readable, or the photograph is crowded with detail. A focal point or a separate mobile composition often solves the layout need more directly.

SourcesThe original work, tools, and platform references

Avidan and Shamir’s Seam Carving for Content-Aware Image Resizing (2007) introduced the technique. The Princeton specification explains the pixel-path formulation and boundary cases. Our code is written independently, removes vertical seams only, and uses the integer energy defined above.

Photoshop’s Content-Aware Scaling came from a MERL license, as the seam carving article records, along with forward energy. The Liquid Rescale library behind GIMP’s plug-in implements the Avidan–Shamir paper.

Platform behavior comes from MDN: object-fit, responsive images, OffscreenCanvas, transferable objects, Worker.terminate() and the execution model.

The two hikers are an AI-generated teaching photograph; every carved result comes from the code. References were checked on 13 September 2026, and the Next.js observation is pinned to version 16.0.0.

09 / Take the idea with you

Explain a narrower photo without saying “seam carving.”

“Give every pixel a score for how different it is from its neighbors. Find the path from top to bottom, one pixel per row and never jumping more than one column, whose scores add up to the least, by keeping the cheapest way to reach every pixel row by row. Take that path out, close the gap, and score the picture again.”

Then say what it never understood: which pixels were people. Before moving on, open a component with object-fit in it and ask which of the four answers that slot really wants.

Connections to follow nextRelated lessons
  • Dynamic programming is the move that finds the seam: answer each smaller question once and reuse it.
  • Levenshtein distance fills a table the same way and walks it back to recover a sequence of choices; there they are text edits, here pixels to remove.
  • Dijkstra’s shortest path solves the general shortest-path problem; a seam is the special case where every step goes down a row, so no heap is needed.
  • Floyd–Steinberg dithering also reads a picture pixel by pixel and has to decide where that work runs.

Copy the complete example, move the bright column to the middle of the 3 × 3 image, and predict which seam goes and what its energy is before you run it.

Back to applied algorithms →