BookNotesSlidesP-sets Syllabusdraft · v0.2.49.1
◀▲⌂▶
💬Comments welcome. To leave a note, select any text and click the note / highlight button that pops up — or open the panel with the tab at the top-right (‹). Notes are visible only inside our private review group.

45.34 Photoshop 1.0 ported to JavaScript⧉

The previous appendix runs Photoshop 1.0 in an emulator, at 1990 speed on a 640 by 480 screen. This one throws the emulator away and keeps the program. The whole application is rebuilt in JavaScript below: the menu bar, the dialogs, and the tool palette reproduced control for control from the resource definitions in the source Adobe and the Computer History Museum published in 2013, and the image arithmetic transcribed integer for integer. It runs on a photograph of your choosing at whatever size your laptop will manage.

fig-photoshop-js
Figure 45.34.1. Everything is live. Open a picture, then work: double-click a tool for its 1990 options dialog, click the color swatches for the color picker, drag a selection from inside it and it floats, and Edit ▸ Paste Controls decides how it lands. Levels, Curves' ancestor Arbitrary, all 22 filters, the Calculate commands, and the Effects corner handles are under Image. One level of Undo, because that is what 1990 gave you.

Eight of those filters are also broken out on their own below, with a readout of what each is doing inside.

The transcription is deliberately literal. Where the original is 68000 assembly the port keeps its register-level behavior, including the truncating divides, the fixed-point rounding constants, and the exact way each filter treats the edge of the image. The point is to run the 1990 algorithm, not a modern paraphrase of it.

fig-photoshop-filters
Figure 45.34.2. Photoshop 1.0.1's own routines, running live: Gaussian Blur, Unsharp Mask, Find Edges, Add Noise, Median, Despeckle, Sharpen Edges, and the Custom 5 by 5 kernel. Pick a picture or drop in your own, drag the seam to compare against the original, and read what the algorithm actually did in the panel at the bottom left. For Gaussian Blur the "vs modern" view shows the difference from a floating-point Gaussian of the same sigma, amplified 24 times; for Despeckle and Sharpen Edges the "Mask" view shows the blend mask they build.

45.34.1 Gaussian Blur, and five boxes⧉

Set the radius below 2.0 pixels and the code runs a sampled, truncated Gaussian: a separable kernel of exp(-((j - r) * 10 / width)^2 / 2), quantized to sixteen bits, applied horizontally and then vertically. The internal width is ten times the requested sigma in pixels: a dialog value of Radius 1.0 becomes width = 10 and sigma one pixel.

Push the radius past 2.0 and something else happens. The exact kernel is abandoned and the blur becomes a stack of box filters, and the way their sizes are chosen is the nicest thing in the file. A box of radius r has variance r(r+1)/3. The code sets itself a budget, goal = width^2 * 3 / 100, which is three times the variance you asked for, and then allocates the variance budget across five scheduled box passes, with a possible additional pass to use the remainder. At each step it chooses a radius from that step’s budget and subtracts the variance spent. Ask for sigma 6 and you get boxes of radius 4, 4, 4, 4, 4 and 2, whose variances add to 35.3 against a target of 36.

This is the central-limit argument of Chapter 6.2, Convolution, shipped as a product decision in 1990: repeated box filters converge on a Gaussian, and a box filter of any radius costs the same per pixel because it slides a running sum. Whether it converges well enough is the question, and the figure answers it. The "vs modern" view subtracts a floating-point Gaussian of the same sigma from the 1990 output and multiplies the difference by 24. Away from the borders it stays flat gray: across sigma 1 to 15 the two agree to about 0.4 levels out of 256, with the worst pixel off by less than 1.5. Those measurements describe the demo’s test image, not an error bound for arbitrary inputs. The approximation was fast enough to ship, and its discrepancy can be inspected on edges, impulses and other images.

45.34.2 Unsharp Mask is thirty lines⧉

Unsharp Mask inherits from Gaussian Blur. It copies the band, blurs the copy, and then walks the two arrays in step through a single assembly routine that is thirty lines long. Stripped of the register shuffling it is

out = clamp(src + (src - blur) * amount / 100)

with the percentage held as amount * 4096 / 100 and the multiply-shift-round arranged so the division by 100 costs nothing per pixel. That is the whole filter, and it is the operation Chapter 8.8, Sharpening derives from scratch.

Drag the Amount slider and two things show. The clamp at 0 and 255 is the entire overflow story: there is no headroom in the stored result, although the intermediate arithmetic uses wider values, so a strong Amount flattens the halos against the ends of the range. And the Threshold field in the 1.0.1 dialog never reaches the filter; the word does not appear anywhere in its code, and only Amount and Radius are read.

45.34.3 Find Edges, without a square root⧉

Find Edges is a Sobel pair, which is unsurprising, followed by a magnitude, which is where the 1990 constraint shows. A square root per pixel was not affordable on a 68020, and neither was floating point. So the code sorts the two gradients, divides the smaller by the larger in eight-bit fixed point, and uses that ratio to index a 257-entry table, one multiply and one shift later.

The table is a hypotenuse: entry i is round(16384 sqrt(1 + (i/256)^2)), so the magnitude comes out as larger sqrt(1 + (smaller/larger)^2), which is exactly the hypotenuse, to within a level. The figure regenerates the table from that formula rather than copying Adobe's numbers, and it reproduces all 257 of them.

45.34.4 Add Noise, and the same theorem again⧉

The Gaussian option of Add Noise is the central limit theorem a second time, in the same file, for a different purpose. It sums six uniform random numbers, each one the Toolbox Random() shifted right by four, and then applies two constants that look arbitrary and are not. Six terms uniform on [-2048, 2047] have variance approximately 2^23, so a standard deviation of 2896.3. Multiplying by 23170/16384, which is sqrt(2) in fixed point, brings that close to 4096. The final scale by amount/4096 therefore targets the standard deviation typed into the Amount field, before integer rounding and output clipping. Set Amount to 32 and you get noise with a sigma of 32 levels, by construction.

The Uniform option is a smaller pleasure: a rejection sampler. Mask Random() down to the smallest 2^k - 1 that covers twice the amount, draw again whenever the result overshoots, then subtract the amount. That is how you get an unbiased uniform over an arbitrary range out of a power-of-two mask, without a division.

Add noise here and then switch to Median or Despeckle, which is the other half of the exercise: filters that suppress some noise while changing image detail.

45.34.5 Median, and what happens at the border⧉

The median is the textbook sliding histogram: 256 bins, one column added and one dropped per pixel, then a walk through the bins until the running count reaches half the window. Two details are not textbook. The code tracks the window's sum as well as its count, and if the mean is above 128 it walks down from bin 255 instead of up from bin 0, which can reduce the number of bins visited on a bright picture; the saving depends on its histogram.

The other detail is a genuine difference in behavior, not an optimization. Every other filter here treats a pixel outside the image as a repeat of the nearest edge pixel. The median does not: it simply leaves the missing samples out, so a corner pixel is the median of four samples rather than nine, and the filter is weaker at the border than in the middle. You can see it if you look for it.

45.34.6 Despeckle and Sharpen Edges are not kernels⧉

These two are the reason this appendix exists. Neither is a convolution. Both run Sobel into a scratch buffer, push the magnitude through a soft threshold (zero below 64, then a ramp to 255), and use the result as a mask to blend the filtered image against the unfiltered one, pixel by pixel:

out = original + (filtered - original) * (255 - mask) / 255

For Despeckle the filtered version is Blur More, and the mask is used as it stands, so the picture is smoothed everywhere the gradient is low and left alone everywhere it is high. For Sharpen Edges the mask is inverted and the filtered version is Sharpen, so the sharpening lands on the edges and not on the flat areas where it would only amplify noise.

Press the Mask button in the figure and you can see what the filter is thinking. That is edge-aware smoothing: blend toward a blurred image where the gradient is small, which is the whole subject of Part 10, Edges matter and the motivation for the bilateral filter that Chapter 10.1, Bilateral filtering builds. It was a menu item in 1990, driven by a Sobel magnitude and a linear ramp rather than a range kernel, and it works.

45.34.7 Custom is the general case, and the built-ins are the special cases⧉

The Custom dialog takes a 5 by 5 kernel, a Scale to divide by, and an Offset to add. Three decisions in its implementation are all about the cost of a multiply. The kernel is stored sparse, so a kernel with one number in it evaluates one tap and not twenty-five. Any weight whose magnitude is a power of two is marked and shifted rather than multiplied, and so is the Scale. And the image is split into a valid interior, where precomputed byte offsets are used directly, and a border, where each tap's coordinates have to be clamped.

Load the Blur, Blur More, Sharpen or Sharpen More presets and the panel will tell you that the kernel you are looking at is one of Photoshop's own built-in filters. It is. Those four menu items are hand-written assembly loops for kernels the user could have typed into this dialog:

menu itemkerneldivisor
Blur0 1 0 / 1 4 1 / 0 1 08
Blur More1 2 1 / 2 2 2 / 1 2 114
Sharpen0 -1 0 / -1 8 -1 / 0 -1 04
Sharpen More-1 -1 -1 / -1 12 -1 / -1 -1 -14

They exist because a specialized loop beat the general one, not because they do anything the general one cannot. Run one both ways and the outputs are identical except for Blur More, where a few pixels differ by one: its divisor of 14 is not a power of two, so the assembly multiplies by 4681 and keeps the high word instead of dividing, and the general path rounds where the special one truncates.

45.34.8 On reading source code⧉

The 1.0.1 release is about 128,000 lines and reading it front to back tells you almost nothing, because a program's text is not in the order its logic runs. What the filters above have in common is that they became legible the moment they produced pixels. The variance budget in the Gaussian is four lines of Pascal that mean nothing until you watch five boxes turn into a bell curve; the mask in Despeckle is an unremarkable buffer until you display it.

The license on the source is non-commercial and forbids redistribution, so the code panel in the figure shows our transcription rather than Adobe's original, and names the file and routine each one came from. The archive is a download away if you want to read along.


No new notation is introduced in this chapter.