Image Lab How it works
Documentation

How Image Lab works.

Two readings of the same system. Start with the overview if you want to get work done. Switch to the technical tab for the actual operators, their formulas, and their cost.

The short version

Your browser does the editing

When you open an image, it is decoded into a canvas in your browser. Every crop, resize, rotation, tone adjustment and filter is applied to that canvas locally. Nothing is uploaded for those operations, which is why they feel instant even on a large photo.

Four things still need a server, because the browser genuinely cannot do them: writing EXIF metadata, encoding BMP/TIFF/GIF, animated GIF frame surgery, and the two heavyweight operations (seam carving and AI background removal).

Your session is cached in your own browser, so a refresh will not lose your work. Clearing the workspace deletes that cache.
Getting started

Three ways in

  1. Open a file Use the Open button, or drop a file anywhere on the page, or click the empty canvas.
  2. Paste from the clipboard Press Ctrl/Cmd+V anywhere, or use the Paste button.
  3. Pick up where you left off If you edited something previously, it is restored automatically on load.

Keyboard shortcuts

  • Ctrl/Cmd+V paste an image
  • Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z redo
  • Ctrl/Cmd+S download with the current export settings
  • Hold Compare to peek at the original
The editing model

Preview, then apply

The Adjust and Filters panels work in two stages. Moving a slider previews the result immediately on the canvas but changes nothing permanent, and a badge appears to tell you a preview is pending. Pressing Apply re-runs the identical maths at full resolution and records one entry in the history.

This is why you can drag sliders freely without flooding the history with dozens of steps, and why leaving a panel with an unapplied preview discards it rather than silently committing it.

Panels

What lives where

  • Basic - crop and straighten, flip, rotate, resize with a choice of resampling filter, canvas padding, transparency flattening, and format conversion.
  • Adjust - a live histogram, levels, exposure, brightness, contrast, shadows, highlights, gamma, saturation, vibrance, temperature, tint and hue, plus auto levels, auto enhance and auto white balance.
  • Filters - grayscale, sepia, invert, sharpen, edge detect, emboss, contour, smooth, Gaussian blur, median denoise, unsharp mask, posterize, pixelate, solarize, binary threshold and Floyd-Steinberg dithering.
  • Advanced - histogram equalization, CLAHE local contrast, background removal, vignette, border, watermark and seam carving.
  • GIF - appears only when the loaded file is animated.
Reading the histogram

What the graph is telling you

The histogram counts how many pixels sit at each brightness level, from black on the left to white on the right. The coloured areas are the red, green and blue channels; the pale outline is overall luminance.

  • Everything bunched left means the image is underexposed.
  • Everything bunched right means it is blown out.
  • A tall spike hard against either edge means clipping - detail that no longer exists.
  • A narrow hump in the middle means flat, low contrast, and is what auto levels fixes.

The readout under the graph reports what percentage of pixels are already clipped at each end.

Metadata

Reading and writing EXIF

  • View - the metadata panel lists parsed EXIF tags and embedded text chunks.
  • Edit - the JSON editor accepts keys such as artist, copyright, description, software, datetime, make and model.
  • Strip - removes everything, which is the usual reason people want a metadata tool at all.
  • Embed - metadata is written into the file at download time by Pillow, since the browser's canvas encoder cannot carry it.
Any export carrying metadata takes the server path, so it is marginally slower than a plain client-side encode. Strip the metadata if you want the fastest possible export.
Formats

What goes in and out

FormatImportExportNotes
PNGYesYesLossless, keeps transparency.
JPEGYesYesLossy, no alpha, quality applies.
WEBPYesYesLossy or lossless, keeps alpha.
GIFYesYes256 colours; animation tools in the GIF tab.
BMPYesYesUncompressed, for compatibility.
TIFFYesYesLarge but metadata friendly.
HEIC / HEIFYesNoDecoded via pillow-heif.
AVIFYesNoDecoded if your browser supports it.
Raw blobsBest effortNoIdentified by magic bytes when the extension lies.
GIF tools

Animated workflow

ToolWhat it does
ResizeScales every frame, preserving per-frame timing.
TrimKeeps a frame range and drops the rest.
SpeedDivides every frame duration by your factor.
ReversePlays the sequence backwards.
Ping-pongAppends the reversed middle frames for a seamless bounce.
OptimizeShrinks the palette and optionally drops every Nth frame.
Poster frameExports one frame as a PNG still.
Frame ZIPExports every frame as numbered PNGs in a ZIP.
Static-image tools stay disabled on an animated GIF, because applying them would flatten it to a single frame.
This deployment

Build information

  • BackendFlask + Pillow
  • Seam carvingnone
  • AI backgroundNot installed on this build
  • Upload limit32 MB
Foundations

Colour model and notation

Pixels arrive as 8-bit sRGB with an optional alpha channel. Throughout this page a pixel is written p = (R, G, B, A) with each component in [0, 255], and the normalised form x = v/255 in [0, 1] is used wherever the maths is scale-free.

The sRGB transfer function

Stored sRGB values are not proportional to light. The encoding applies a roughly 1/2.2 power curve, so decoding to linear light is:

sRGB to linear ⎧ x / 12.92 if x ≤ 0.04045 L(x) = ⎨ ⎩ ((x + 0.055) / 1.055)^2.4 otherwise

This matters because operations that are physically "adding light" - exposure, blurring, alpha blending - are only strictly correct in linear space, while operations defined perceptually - contrast, most stylistic filters - are conventionally done in the encoded space.

Image Lab follows the same convention as the CSS filter specification and the canvas 2D compositor: pixel operations run on the encoded sRGB values. The trade is a small loss of physical accuracy in exchange for results that match what the browser itself would produce, and for a preview that agrees exactly with the committed render.

Luminance

Wherever a single brightness value is needed, the Rec. 709 luma coefficients are used:

LumaY = 0.2126·R + 0.7152·G + 0.0722·B

The weights are wildly unequal because human cone response is: green carries most of the perceived brightness, blue almost none. Using a flat (R+G+B)/3 average instead is the classic reason naive grayscale conversions look wrong.

Adjust panel

The tone curve is one composed function

Exposure, levels, brightness, contrast, shadow/highlight shaping, gamma and white-balance shift are all point operations: the output for a pixel depends only on that pixel's own value. Composing them gives a single function T: [0,255] → [0,255] per channel.

Composed per-channel tone operatorx₁ = x₀ · 2^EV exposure, in stops x₂ = clamp( (255·x₁ − B) / (W − B), 0, 1 ) levels, black B and white W x₃ = x₂^(1/γ_mid) midtone gamma x₄ = x₃ · β brightness β x₅ = (x₄ − ½)·κ + ½ contrast κ about mid grey x₆ = x₅ + s·(1 − x₅)³·½ shadow lift, s ∈ [−1, 1] x₇ = x₆ + h·(x₆)³·½ highlight lift, h ∈ [−1, 1] x₈ = clamp(x₇, 0, 1)^(1/γ) output gamma x₉ = x₈ + δ_c white balance offset, per channel T(v) = round( 255 · clamp(x₉, 0, 1) )

Why the cubic weights

The shadow term is weighted by (1 − x)³ and the highlight term by x³. Both are unity at their own end of the range and fall off fast, so a shadow lift leaves highlights untouched and vice versa. A linear weight would bleed across the whole range and simply read as brightness.

Why it is a lookup table

Because T only depends on the input value, it is evaluated 256 times per channel and cached. Applying it is then one array index per subpixel:

CostNaive: W·H·3 evaluations of the full chain Tabled: 3·256 evaluations + W·H·3 array lookups For a 24 MP image that is 768 evaluations instead of ~72 million.

This is the single reason the sliders can preview at frame rate: the expensive part is independent of image size, and every additional slider is free because it folds into the same table.

White balance

Temperature moves the red and blue channels in opposition; tint moves green against both. The automatic version uses the grey-world assumption - that a scene averages to neutral - and solves for per-channel gains:

Grey-world gainsμ_c = (1/N) Σ p_c for c ∈ {R, G, B} μ̄ = (μ_R + μ_G + μ_B) / 3 g_c = μ̄ / μ_c then p_c ← clamp(g_c · p_c)

It fails predictably: a photo that genuinely is mostly one colour (a forest, a red wall) will be pushed towards grey, because the assumption it rests on is false for that image.

Adjust panel

Saturation, vibrance and hue

These are not point operations on a single channel - they mix channels - so they run as a second pass after the tone tables.

Saturation

Linear interpolation between the pixel and its own luminance, extrapolating past 1 to oversaturate:

Saturation, factor σp_c ← Y + σ·(p_c − Y) where Y is the luma of p σ = 0 → fully desaturated (p_c = Y for all c) σ = 1 → identity σ > 1 → pushed away from the neutral axis

Vibrance

Vibrance is saturation with a per-pixel gain that shrinks as the pixel gets more saturated, so already-vivid areas stop moving while muted ones keep going. Using HSV-style saturation as the measure:

Vibrance, amount νS(p) = (max(R,G,B) − min(R,G,B)) / max(R,G,B) 0 when neutral, 1 when pure σ_effective = σ + ν · (1 − S(p)) p_c ← Y + σ_effective·(p_c − Y)

The (1 − S) factor is the whole trick, and it is why vibrance is the safer control for photos of people: skin sits at low-to-mid saturation, so it receives a much smaller boost than a saturated background does.

Hue rotation

Hue rotation is a rotation about the neutral axis of RGB space, expressed as a 3x3 matrix. Image Lab uses the matrix from the filter effects specification, which is what the browser's own hue-rotate() uses:

Hue rotation by θ, with (l_R, l_G, l_B) = (0.213, 0.715, 0.072) ⎡ l_R ⎤ ⎡ 1−l_R −l_G −l_B ⎤ ⎡ −l_R −l_G 1−l_B ⎤ M(θ) = ⎢ l_R ⎥·1 + cos θ·⎢ −l_R 1−l_G −l_B ⎥ + sin θ·⎢ 0.143 0.140 −0.283 ⎥ ⎣ l_R ⎦ ⎣ −l_R −l_G 1−l_B ⎦ ⎣ −(1−l_R) l_G l_B ⎦ (each row of the first term is (l_R, l_G, l_B)) [R' G' B']ᵀ = M(θ) · [R G B]ᵀ

Being a rotation about the grey axis, it leaves neutral colours neutral - grey stays grey at any angle - and approximately preserves luminance, which is why it does not visibly brighten or darken the image as you sweep it.

Filters panel

Convolution

Sharpen, emboss, edge detect, contour and smooth are all discrete 2D convolutions. For a kernel K of odd side n = 2r+1:

Discrete 2D convolution 1 r r (I ∗ K)(x, y) = ─── Σ Σ K(i, j) · I(x − i, y − j) + bias d i=−r j=−r d = divisor (usually ΣK, or 1 when ΣK = 0)

The divisor normalises gain. When ΣK = 1 the filter preserves average brightness; when ΣK = 0 the result is centred on zero, which is why edge and emboss kernels are given a bias of 128 to push the mid-point back into visible range.

The kernels in use

Sharpen - a discrete Laplacian added back to the identity. ΣK = 1.

0 −1 0 −1 5 −1 0 −1 0

Edge detect - the Laplacian alone. ΣK = 0, bias 128.

−1 −1 −1 −1 8 −1 −1 −1 −1

Emboss - an asymmetric directional derivative, lighting the image from the top-left.

−2 −1 0 −1 1 1 0 1 2

Smooth - a weighted box average, d = 13.

1 1 1 1 5 1 1 1 1

Boundary handling

Sampling outside the image is resolved by clamping coordinates to the edge (equivalent to extending the border pixels outward). The alternatives - zeroing, wrapping, or mirroring - produce a dark rim, a wrapped rim, and a reflected rim respectively. Clamping is chosen because its artefact is the least visible.

Separability

A kernel is separable when it factors as an outer product K = uvᵀ, which drops the cost from O(n²) to O(2n) per pixel. A Gaussian is separable; the sharpen and emboss kernels above are not. This is exactly why Gaussian blur is delegated to the compositor - a GPU-side separable pass beats anything done per-pixel in JavaScript by a wide margin.

2D Gaussian, and its separation 1 −(i² + j²) / 2σ² G(i, j) = ───── · e 2πσ² 1 −i²/2σ² 1 −j²/2σ² = ──── e · ──── e = g(i) · g(j) √(2π)σ √(2π)σ
Filters panel

Unsharp masking

The name is historical: the technique came from darkroom practice, where a blurred (unsharp) positive was used as a mask against the negative. Digitally it is high-frequency boosting - subtract a low-passed copy to isolate detail, then add a multiple of it back.

Unsharp maskD(x, y) = I(x, y) − (I ∗ G_σ)(x, y) the detail (high-pass) layer ⎧ I(x, y) + a · D(x, y) if |D(x, y)| ≥ t O(x, y) = ⎨ ⎩ I(x, y) otherwise a = amount, σ = radius, t = threshold

What each control actually does

  • Amount aGain on the detail layer. a = 1 doubles local contrast at the edges; beyond about 1.5 the overshoot becomes a visible halo.
  • Radius σSets which spatial frequencies count as "detail". Small σ sharpens fine texture; large σ becomes local contrast enhancement rather than sharpening.
  • Threshold tA deadband. Where the local difference is below t, the pixel is left alone, which stops the filter from amplifying sensor noise in flat sky or skin.

Note the relationship to the sharpen kernel above: with σ small enough that the Gaussian collapses to a 3x3 neighbourhood, unsharp masking and the Laplacian sharpen kernel converge on the same operator. Unsharp masking just exposes the radius and threshold as parameters instead of hard-coding them.

Filters panel

The median filter is not a convolution

Median denoise selects the middle value of the sorted neighbourhood rather than a weighted sum. That makes it a rank-order filter and, crucially, non-linear:

Median of a (2r+1)² windowO(x, y) = median{ I(x+i, y+j) : −r ≤ i, j ≤ r } Non-linear: median(A + B) ≠ median(A) + median(B)

The consequence is the one property that makes it worth having: a median filter removes impulse noise (single hot or dead pixels, salt-and-pepper speckle) essentially perfectly, because an outlier can never be selected as the middle of the sorted set. A Gaussian blur, being an average, instead smears the outlier over its whole neighbourhood.

The cost is O(WH · n² log n) with a naive sort per window, which is why the window is kept small and the preview runs at display resolution.

Advanced panel

Histogram equalization

The intent is to redistribute intensities so the output histogram is as close to uniform as a discrete remapping allows. The mechanism is the cumulative distribution function.

Global equalizationh(i) = #{ pixels with luma = i }, i ∈ [0, 255] i cdf(i) = Σ h(k) k=0 ⎡ cdf(i) − cdf_min ⎤ T(i) = round ⎢ ───────────────── · 255 ⎥ ⎣ N − cdf_min ⎦ N = total pixels, cdf_min = smallest non-zero cdf value

The theory: if a variable is transformed by its own CDF, the result is uniformly distributed. Quantisation to 256 integer levels means real images only approximate this.

Preserving colour

Equalizing R, G and B independently wrecks colour, because each channel gets a different mapping and their ratios change. Image Lab equalizes luminance only and rescales chroma to follow:

Chroma-preserving remapY = luma(p) Y' = T(Y) p_c ← clamp( p_c · (Y' / Y) ) with the Y = 0 case handled separately

Ratios between channels are preserved, so hue survives. The failure mode of global equalization is different and unavoidable: it is driven by one histogram for the entire frame, so a large uniform region (a sky) dominates the CDF and the interesting parts of the image get compressed. That is the problem CLAHE exists to solve.

Advanced panel

CLAHE

Contrast Limited Adaptive Histogram Equalization computes a separate mapping per tile, clips each histogram to bound the amount of amplification, and interpolates between tile mappings so no tile boundaries appear. Three ideas, each fixing a failure of the previous one.

1. Adaptive: equalize per tile

The image is divided into a t x t grid; each tile gets its own histogram and CDF. This solves the "sky dominates the frame" problem because each region is normalised against its own statistics. On its own it introduces two new problems.

2. Contrast limited: clip and redistribute

In a nearly flat tile, the histogram is a tall spike, so the CDF has an enormous slope there and equalization amplifies whatever noise exists into visible mush. Bounding the histogram height bounds the slope, and therefore the amplification:

Clipping with redistributionlimit = ⌊ clip · n_tile / 256 ⌋ clip is the user's clip limit excess = Σ max(0, h(i) − limit) h(i) ← min(h(i), limit) h(i) ← h(i) + ⌊excess / 256⌋ excess handed back uniformly (remainder spread one per bin)

Redistribution matters: simply discarding the excess would change the total and distort the CDF's endpoint. Handing it back uniformly keeps Σh = ntile.

3. Interpolated: blend the mappings

Applying each tile's mapping to its own pixels produces visible blocking at tile edges. Instead, every pixel is mapped by bilinearly blending the four mappings whose tile centres surround it:

Bilinear blend of neighbouring tile mappingsLet (u, v) be the pixel's position between the four surrounding tile centres, u, v ∈ [0, 1), and T₀₀ … T₁₁ their mappings. T(i) = (1−v)·[ (1−u)·T₀₀(i) + u·T₁₀(i) ] + v ·[ (1−u)·T₀₁(i) + u·T₁₁(i) ]

Note this interpolates the mapping functions, not the resulting pixel values. That distinction is what makes the transition continuous across the whole image.

Choosing the parameters

  • Clip limit1.0 is nearly a no-op. 2-3 is the usual working range. Above ~5 noise amplification dominates.
  • Tile gridFewer, larger tiles approach global equalization. More, smaller tiles increase local detail but exaggerate noise and cost more.
Filters panel

Floyd-Steinberg error diffusion

Quantising to few levels produces banding, because a smooth gradient collapses into flat plateaus with hard steps. Error diffusion fixes this by refusing to throw the quantisation error away: each pixel's error is pushed onto neighbours that have not been processed yet, so the error averages out spatially and the eye integrates it back into the original tone.

Quantisation with L levels per channelstep = 255 / (L − 1) Q(v) = round(v / step) · step err = v − Q(v) The Floyd-Steinberg distribution kernel ⎡ · # 7/16 ⎤ ⎢ 3/16 5/16 1/16 ⎥ ⎣ ⎦ # = current pixel, · = already processed Weights sum to 1, so no energy is created or lost. I(x+1, y ) += err · 7/16 I(x−1, y+1) += err · 3/16 I(x , y+1) += err · 5/16 I(x+1, y+1) += err · 1/16

The scan order (left to right, top to bottom) is what makes this work: every target is a pixel the loop has not reached yet, so the accumulated error is always available when that pixel is finally quantised. Accumulation is done in floating point rather than in the 8-bit buffer, because rounding the error at each step would defeat the entire purpose.

Two levels per channel gives the classic 8-colour newsprint look. Four to six gives a recognisably retro image that still reads correctly at a distance. This is also, in essence, what the GIF encoder does when reducing to a 256-colour palette.
Basic panel

Resampling

Resizing means reconstructing a continuous signal from discrete samples and re-sampling it on a new grid. The reconstruction filter chosen is the entire quality question.

Lanczos kernel, order a (a = 3 typical) ⎧ sinc(x) · sinc(x / a) if |x| < a Lₐ(x) = ⎨ ⎩ 0 otherwise where sinc(x) = sin(πx) / (πx), and sinc(0) = 1

Lanczos is a windowed sinc. The ideal reconstruction filter is sinc, but it has infinite support; the second sinc term is a window that truncates it to ±a while keeping most of its frequency response. Its negative lobes are what produce visibly crisper edges than bilinear - and also what produce slight ringing overshoot near hard edges.

Aliasing when downscaling

Shrinking an image lowers the sampling rate. By the Nyquist criterion, any content above half the new sampling frequency will alias - fold down into false low-frequency patterns, the moiré you see on brick walls and fine fabric in a badly resized photo.

Nyquist limitf_max < f_s / 2 Downscaling by factor k reduces f_s by k, so everything above the new f_s/2 must be filtered out BEFORE resampling, not after.

Browsers apply a single fixed-width filter step, which is inadequate for large reductions. So for any downscale beyond 2x, Image Lab repeatedly halves the image first:

Progressive halvingwhile (current_width / 2 > target_width): draw current into a canvas of half its size then perform the final resize to the exact target

Each halving step is a low-pass filter followed by 2x decimation - which is precisely the anti-aliasing that Nyquist demands. Skipping it is the single most common cause of a "why does my thumbnail look terrible" bug.

The four options

  • NearestZero-order hold. Blocky, but the only correct choice for pixel art and for any image where exact original values must survive.
  • BilinearFirst-order. Cheap, slightly soft, no ringing.
  • BicubicThird-order. Sharper than bilinear with mild overshoot.
  • LanczosWindowed sinc. Sharpest detail retention; the usual default for photographs.
Advanced panel

Seam carving

Content-aware resizing changes an image's dimensions by removing paths of low-importance pixels instead of scaling uniformly. Avidan and Shamir's formulation, and the one genuinely expensive operation in the app.

Energy

The importance of a pixel is the magnitude of the image gradient at that pixel:

Gradient magnitude energye(x, y) = | ∂I/∂x | + | ∂I/∂y | computed with a Sobel operator: ⎡ −1 0 +1 ⎤ ⎡ −1 −2 −1 ⎤ Gₓ = ⎢ −2 0 +2 ⎥ G_y = ⎢ 0 0 0 ⎥ ⎣ −1 0 +1 ⎦ ⎣ +1 +2 +1 ⎦

Flat regions score near zero; edges and textured detail score high.

The optimal seam

A vertical seam is a connected top-to-bottom path with exactly one pixel per row, where consecutive rows shift by at most one column. Finding the minimum-energy seam by brute force would be O(3H). Dynamic programming reduces it to a single pass:

Cumulative minimum energyM(x, y) = e(x, y) + min{ M(x−1, y−1), M(x, y−1), M(x+1, y−1) } with M(x, 0) = e(x, 0) on the first row.

After filling M, the smallest value in the bottom row is the end of the cheapest seam; backtracking the argmin choices recovers the path. Remove it, and repeat once per column of width change.

CostOne seam: O(W · H) k seams: O(k · W · H) energy must be recomputed after each removal Reducing a 4000x3000 image by 500 px ≈ 6 × 10⁹ operations.

That total is why this runs on the server and why it is the one operation with a visible progress indicator. It also explains the failure mode: seam carving assumes low-gradient means unimportant, so it will happily carve straight through a smooth face or a clear sky horizon while carefully preserving a textured background.

Basic panel

Alpha compositing

Flattening transparency, padding, and JPEG export all composite the image over an opaque background using the Porter-Duff over operator:

Source over destinationα_o = α_s + α_d·(1 − α_s) C_s·α_s + C_d·α_d·(1 − α_s) C_o = ─────────────────────────────── α_o With an opaque destination (α_d = 1, so α_o = 1) this reduces to: C_o = C_s·α_s + C_d·(1 − α_s)

The division by αo is the un-premultiply step. Canvas 2D stores premultiplied alpha internally, and the round trip through 8-bit premultiplied storage is lossy for low alpha values - a pixel at α = 1/255 has only one bit of colour precision left. This is why repeatedly applying and undoing transparency operations slowly degrades edge quality, and why the source blob is kept intact rather than being re-derived from the canvas on every step.

JPEG has no alpha channel at all, so exporting to JPEG composites over white first. That is a one-way operation, which is why the Flatten control exists: it makes the step explicit and lets you choose the background colour rather than silently getting white.

Reference

Complexity summary

W, H are image dimensions, n the kernel side, t the CLAHE tile count, k the number of seams.

OperationComplexityNotes
Tone / levels / colourO(WH)Plus a fixed 768-entry table build.
ConvolutionO(WH·n²)O(WH·n) if the kernel is separable.
Gaussian blurO(WH)Delegated to the GPU compositor.
MedianO(WH·n² log n)Sort per window; the costliest client filter.
Unsharp maskO(WH)One blur pass plus one combine pass.
Histogram / equalizeO(WH)Two passes, 256-entry accumulator.
CLAHEO(WH + t²·256)Every pixel visited once; per-tile CDFs are cheap.
Floyd-SteinbergO(WH)Strictly sequential - cannot be parallelised.
ResampleO(W'H'·a²)Plus O(WH) for each halving pre-pass.
Seam carvingO(k·WH)Server-side; energy recomputed per seam.
Every client-side preview runs against a display-resolution copy - typically under a megapixel - rather than the source image. Applying re-runs the identical code at full resolution. That is the whole performance strategy, and it is why an O(WH·n² log n) median filter is still usable interactively on a 24 megapixel photograph.