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.
💡 In a hurry? Jump to this chapter’s 1 big lesson ↓

9.1 Developing, testing and debugging⧉

📎 Problem set

PS0 sets up the toolchain, a minimal image class, and image I/O. → Chapter 1.5, Problem sets.

A blur can return a valid image with a dark border. A resampler can produce a sharp picture full of aliasing. Neither needs to crash. Getting image code to work requires both checking its numbers and looking at what those numbers produce. Start with one stage and an input small enough to check by hand.

9.1.1 The workflow: build, run, look at the picture⧉

Save intermediate images and open them. This is the image-processing equivalent of a print statement. When the final image has black specks, inspect the inputs and outputs of the suspected stage, then check the values at those pixels. The first place a defect becomes visible helps localize the problem; an earlier numerical error may still be its cause.

A PNG is convenient for display, but a display copy is not the underlying array. Negative values, values above one, and small floating-point differences can disappear when you clip and quantize for export. Keep the original buffer or save a format that preserves its range and precision. Record its shape, type, color encoding, minimum, maximum, and counts of NaN and infinity. Chapter 8.1, Image representation explains why a buffer's interpretation matters as much as its values.

Use the same display transform and scale for comparisons. Independently normalizing two images can hide an incorrect gain. For signed differences, map zero to a neutral gray and positive and negative errors to opposite colors; state the amplification. Mark nonfinite values separately. A black difference image may mean agreement, or merely differences too small to survive the display conversion.

9.1.2 Principles⧉

Test incrementally. Implement one stage, check its output, and retain that check before adding the next. When a long pipeline fails, inspect an intermediate near the middle to narrow the search. This is a useful shortcut when the error remains visible downstream; clipping, normalization, and later filters can conceal it, so a good-looking intermediate does not clear everything upstream.

Change one thing between runs. Keep the input, parameters, random seed, and build fixed while testing a proposed correction. Otherwise a better picture does not tell you which change helped. Once the correction works, repeat the test on other inputs and, for stochastic methods, other seeds.

Check the assumptions between stages: channel order, dimensions, coordinate origin, units, transfer function, and boundary policy. A function may compute exactly what it was asked to compute on data in the wrong domain. Inspect actual values as well as reading the code.

For a ten-stage pipeline, inspect the middle output first. If a visible defect is already present there, inspect an earlier intermediate; if it first appears later, move downstream. Repeating this can narrow the suspect region in a few checks. Then test the suspected stage on an input with a known answer. The shortcut assumes the defect remains observable: a later clamp can hide an earlier overflow, and an erroneous normalization can make a bad intermediate look plausible. Keep both the numerical array and a documented display mapping when using intermediate images to localize the fault.

Check the part you are most inclined to exempt from checking: an old helper, a familiar conversion, or the expression that looks too simple to be wrong. Familiarity is not evidence about the values in this run. When inspection of the source stops producing new information, print the actual indices and samples or display the intermediate. Your prediction of what the code does may be the mistaken assumption you are trying to find.

9.1.3 Test on inputs you can verify by hand⧉

A tiny input lets you compare every output value against a calculation independent of the implementation. Use several sizes, including a non-square image, odd dimensions, and a single row or column. These expose transposed axes, vector tails, and boundary assumptions that a convenient square image misses.

fig-debug-inputs
Figure 9.1.1. (Optional.) The four standard debug inputs, each a tiny grid you can read pixel by pixel: a constant (every pixel equal), an impulse (a single 1 in a field of 0s), an edge / half-plane (one straight black-to-white boundary), and a rectangle (a bright block on a dark field). Each isolates a different failure mode: constants catch normalization and edge-padding bugs, the impulse exposes a linear filter's kernel directly, the edge stresses behavior at discontinuities, and the rectangle adds corners and two directions at once. The chapter stands alone without it; the montage just collects the set in one place.

Apply these tests to individual stages. Compare integer results exactly when the specification requires it. For floating-point results, choose tolerances in the working units, accounting for rounding and changes in reduction order. One common comparison is

$$|a-b|\leq \epsilon_{\mathrm{abs}}+\epsilon_{\mathrm{rel}}\max(|a|,|b|).$$

The absolute tolerance handles values near zero; the relative tolerance allows error proportional to magnitude. Check nonfinite values before this comparison. A large tolerance can conceal an algorithmic error, so report the maximum difference and its location as well as pass/fail.

Why a constant is a good first test

A constant removes spatial variation. If a normalized blur changes its interior value, check the sum of weights and the arithmetic type. If only the border changes, check the extension rule. These are distinct failures with distinct fixes.

Apply the same discipline inside the pipeline: feed these inputs to your intermediate stages, not only to the final program. A failed whole-pipeline impulse test tells you that something is broken; running each stage on an impulse tells you which stage. The synthetic inputs and the binary-search habit are the same idea from two directions: small inputs make each stage checkable, and checking each stage localizes the bug.

9.1.4 Finding good real test inputs⧉

Synthetic tests cover particular properties, not all possible inputs. Follow them with a fixed set of photographs spanning the conditions the method will encounter: fine foliage and smooth skies, clipped highlights and noisy shadows, saturated colors and neutrals, faces under varied illumination, and a range of skin tones and cameras.

Keep a few standard test images so results can be compared with the literature, such as the Kodak set, a zone plate or resolution chart, and a color checker, beside your own photographs. Keep every useful failure as a regression case, with the parameters and expected behavior recorded. Also reserve photographs that you do not use for tuning. A regression suite checks that an old problem stays fixed; an untouched evaluation set checks whether improvements extend beyond the images you have repeatedly adjusted against. Use the noise models in Chapter 2.8, Noise, signal-to-noise ratio and dynamic range when constructing synthetic corruptions, and include real captures because a simulated noise model leaves out some camera behavior.

Organize that set around reasons for failure. Texture and smooth fields pull denoising in opposite directions; a noisy shadow and a clipped highlight exercise different ends of the measurement range. A saturated patch stresses gamut handling while a neutral patch exposes a small cast. Standard photographs and charts help comparisons with published results, but a known failure from your own pipeline deserves a permanent place beside them.

9.1.5 Per-algorithm debug recipes⧉

The algorithm chapters give more specific checks:

State the expected answer before running the code. Use an independent calculation or a simple reference implementation; copying the same indexing expression into the test can reproduce the same mistake.

9.1.6 Crashes and bounds⧉

Out-of-bounds accesses are common in neighborhood operations: a neighbor at the left edge has a negative column, or the last row asks for a sample at height. In C++, an invalid read or write is undefined behavior. A write may corrupt adjacent memory and trigger a crash much later. Invalid reads, dangling pointers, and data races can also fail without a useful message.

Check indices at the point of access, use a debugger, and enable the compiler's memory-error diagnostics during development. Assert properties that the algorithm actually requires, such as valid extents or a nonzero normalization denominator. Do not assert that every intermediate lies in $[0,1]$: many linear filters and color transforms legitimately produce values outside that range.

Debug assertions help catch internal mistakes, but they do not replace input validation in a release build. Reject unsupported dimensions and formats at the interface. For intended accesses outside an image, apply the specified extension rule (repeat, mirror, wrap, or zero) before reading memory. An assertion catches an unhandled access; it does not choose the correct boundary condition.

Reading compiler diagnostics

Start with the first relevant diagnostic and reduce the code to a small example. Later errors may be consequences of the first. Documentation, a search, or an LLM can help interpret a message, but check that the proposed fix preserves the operation and types you intended.

Put a check where the assumption is first used. For example, a resampler can produce an invalid source coordinate long before an eventual crash in an unrelated allocation. Checking the coordinate immediately before the load identifies the operation that broke the contract. Similarly, after computing a normalized filter, check that its weights sum to the expected value; after a transform, check finiteness before sending the result to the next stage. The first failed invariant is usually more informative than the final wrong image.

Keep internal development assertions separate from input validation. A release build may disable assertions, but it still needs to reject unsupported external sizes, formats, and indices safely. Removing a debug check must not make malformed input legal.

9.1.7 Vibe coding: writing image code with an LLM⧉

An LLM can draft image I/O, interfaces, and algorithm implementations. Review all of them. A successful file read does not establish the channel order or color encoding, and a plausible resampler may still omit the low-pass filter needed for downsampling.

In a reproduction of Kemelmacher-Shlizerman and colleagues' Photobios Kemelmacher-Shlizerman et al. 2011, I watched a model handle face registration and a dynamic-programming path through a photo collection, then repeatedly get a simple cross-fade wrong. Competence on one part gave no assurance about another. The paper describes the underlying method; this was my experience reproducing it with an LLM.

Give generated code the same tests as your own: small known answers, boundary cases, independent reference comparisons, and real photographs. Ask for the assumptions about ranges, strides, and color space explicitly. An image that looks reasonable is a starting point for review.

💡 Big lesson

Make inspection cheap, test one stage at a time, and keep the failures you fix. Hand-checkable inputs catch mistakes that photographs hide; photographs expose behavior that small synthetic tests do not cover.

Recap: big lessons of this chapter

💡 Big lesson

Make inspection cheap, test one stage at a time, and keep the failures you fix. Hand-checkable inputs catch mistakes that photographs hide; photographs expose behavior that small synthetic tests do not cover.