← Shot More Than Once

Shot More Than Once · Part 6 of 6

A Retouch You Can Come Back To

A focus stack is a guess. It is usually a good one, but where a bristle crosses a soft background, or two depths meet at a hard edge, the merge picks the wrong frame and leaves a smear no setting will fix. Sharp 1.0 answered that the way every serious stacker does: a brush that paints any source frame back over the result where the merge guessed wrong. What 1.0 did not do was let you come back to it. This article is about how 1.2 keeps a retouch, and the bracket it was made from, so that it can be opened again and changed.

A recipe, not a picture

The brush never stored a second copy of the photograph. What it stores is a recipe: for each pixel, which frame it should come from and how strongly. The screen only ever shows an 8-bit preview of the painting, but that does not matter, because export throws the preview away. It starts again from the 16-bit merge and lays each frame the recipe names back over it at full depth, reading only those frames.

Swift
/// Every frame the recipe actually draws from. Export only has to read these.
public var framesUsed: Set<Int> {
    var used = Set<Int>()
    for p in 0..<(width * height) where strength[p] > 0 && source[p] != Self.untouched {
        used.insert(Int(source[p]))
    }
    return used
}

A pixel comes from one frame. Painting from a second frame over a pixel already claimed takes it over rather than blending the two, the same rule the merge itself follows. Erasing lowers the strength, and a pixel that reaches zero goes back to being the merge.

What 1.0 lost

That design was sound, and three things around it were not. The 16-bit result lived in the temporary directory, which iOS empties when it likes; the brush and the export both start from that file. Finishing a retouch replaced the result file, so a second session with the brush started from the first one’s output and painted over it, rather than changing it. And when the app quit, only the list of frames and the settings survived. The merge, the map, the recipe and the retouched picture were all gone.

Two bytes for the frame

Before any of that, the recipe itself had a limit. Its source index was one byte, and 255 meant untouched. So frame 255 could not be painted from, and every frame after it was quietly turned into frame 254. In an app that sells unlimited frames, with brackets of three hundred now within reach of a slab stack, that was a real bug. In 1.2 the index is two bytes, and untouched is the largest value they hold.

Swift
/// No frame painted here — the pixel is the merge, as the stacker left it.
public static let untouched: UInt16 = .max

public private(set) var source: [UInt16]
/// How strongly, 0–255.
public private(set) var strength: [UInt8]

/// The frame a pixel is claimed for. Everything below `untouched` is a frame.
public static func claim(_ frame: Int) -> UInt16 {
    UInt16(min(max(frame, 0), Int(untouched) - 1))
}

Two bytes of source and one of strength make three bytes a pixel. A second copy of the picture, in the floating-point form the engine works in, would be four times that.

recipe at 45 MP 45 M × (2 + 1) bytes = 135 MB float copy 45 M × 3 channels × 4 = 540 MB
Recipe135 MBFloat copy540 MB0
What the brush holds at 45 megapixels: the recipe, three bytes a pixel, against a full-precision copy of the picture.

The recipe as a file

To survive quitting, the recipe needs a file. It is a 16-byte header — the letters “SHRT”, a version number, the width and the height — followed by the sources and then the strengths, compressed with LZFSE.

Swift
public func encoded() throws -> Data {
    var raw = Data()
    var header: [UInt32] = [0x5348_5254, 1, UInt32(width), UInt32(height)]   // "SHRT", v1
    raw.append(Data(bytes: &header, count: header.count * 4))
    source.withUnsafeBytes { raw.append(contentsOf: $0) }
    strength.withUnsafeBytes { raw.append(contentsOf: $0) }
    return try (raw as NSData).compressed(using: .lzfse) as Data
}

A retouch touches a small part of a picture. The rest of the recipe is untouched sources and zero strengths, long runs of the same bytes, and it compresses to almost nothing. Reading it back checks the magic number, the version, and that the length is exactly sixteen plus three bytes a pixel, and refuses anything else.

Before you read on

If the retouched picture is saved, why keep the merge as well?

Because erasing has to go somewhere. Erasing a stroke takes the pixel back to the merge, and a second session with the brush must start from the merge plus the recipe, not from the last export, or every new stroke is painted over the old ones with no way to take them out. The retouched file is a convenience, so sharing is instant; the merge and the recipe are what the retouch actually is.

A folder per bracket

In 1.2 every bracket is a project, kept in a folder of its own under Application Support, in Projects/<id>/, and not in the temporary directory. The folder holds:

  • project.json: the frames, the settings, and the result’s metadata, including how every frame was aligned, which the brush needs to lay a frame back in the right place;
  • the merged 16-bit TIFF exactly as the stacker wrote it, its preview JPEG, and the map PNG;
  • once retouched, the recipe, and the retouched TIFF and its preview, beside the merge.

Frames the app copied into its own container are stored by name. Frames left where the photographer keeps them in Files are stored as security-scoped bookmarks, which is the only way a sandboxed app can reach them again after a relaunch. A bookmark that has gone stale drops its frame from the list rather than leaving it there to fail later.

The merge is never overwritten. The saved result names it separately from the file Export shares:

Swift
/// The 16-bit file as the stacker wrote it. Never overwritten.
var merged: String
/// The screen copy of it.
var mergedPreview: String
/// What is on screen and what Export shares: the retouched file once there is one.
var tiff: String
var preview: String
var map: String?
var recipe: String?

Reopening the brush loads the recipe and the merge’s preview. Painting carries on from where it stopped, and erasing still goes back to the merge rather than to the last export. Finishing writes a new retouched file and recipe and removes the previous ones. Erase everything and finish, and the retouched files are removed and the result is the merge again.

The bracket 1.0 left

Sharp 1.0 kept one bracket, in Documents/bracket.json. On the first launch of 1.2 that file becomes a project called “Bracket”, frames and settings and all, and is opened as the last project, so nobody opens 1.2 to find the bracket they left gone. There is no result to carry over: 1.0’s result was in the temporary directory. This was checked on a simulator that still held 1.0’s data, and the old bracket, seven frames on Clean, appeared as the first project.

One more thing changed with several projects. Importing a frame used to remove any file of the same name already in the app’s inbox before copying. With one bracket that was tidy; with several, a frame of the same name may belong to another project, and removing it would take it out from under that one. Imports now never overwrite: IMG_0001.CR2 becomes IMG_0001 2.CR2.

One project free

The free version keeps one project, as 1.0 kept one bracket; starting a new one clears it. Keeping every bracket, running several projects one after another as a batch, and the rest of the work with many brackets belong to the same $9.99 unlock as before. 1.2 added to it without changing the price, so anyone who bought it for 1.0 has them.

Tapped through in the simulator

The whole round was tapped through in the iPad simulator. A stroke was painted, and Done showed “Putting the retouching back at full depth…” while the export ran. The brush was opened again and the stroke was still there, pixel-identical. Switching between projects brought each back as it was left, and a batch of two projects ended with “2 of 2 projects stacked.”

One thing does not come back. The recipe persists, but the undo history does not: it starts fresh each time the brush is opened. A stroke from yesterday can be erased, but not undone.

What the tests hold

  • Frames 255 and 300 can both be painted from, each claiming the pixel as itself, and afterwards the recipe uses frame 300 alone.
  • A 40 × 30 recipe painted from frame 7 survives being encoded and decoded with its width, height, sources and strengths exactly as they were.
  • Painting claims pixels for the frame painted from, and erasing gives them back: untouched, zero strength, and an empty recipe.
  • A second frame takes a pixel over rather than blending with the first.
  • Undo puts the recipe back, leaving nothing painted, and the patch it keeps covers only what the stroke touched.
  • Export lays the source frame over the merge at full precision: the middle of a hard stroke is the frame outright, and outside it the merge is untouched. A frame read a band at a time lands on the right rows.

That closes the series. Sharp 1.2 began as the first run on a real iPad, and most of it is what that run found: a disk that ran out, a wrist that rolls between frames, a detail size that had to be chosen by eye, exposure brackets that needed a tool of their own, brackets long enough to need stacking in slabs, and, here, a retouch that did not outlast the app. Each of the six is one of those findings, with the code that answers it and the test that holds it.