# Engraving quality: rubric and baseline (vs. ImagR One Click)

**Last measured:** 2026-11-05
**Data:**
- ImagR: [`benchmarks/compare/imagr_scores.json`](../benchmarks/compare/imagr_scores.json)
- Ours now: [`benchmarks/compare/ours/scores.json`](../benchmarks/compare/ours/scores.json)
- Ours before the auto-tone change: [`ours/scores_before_autotone.json`](../benchmarks/compare/ours/scores_before_autotone.json)

This file states how we decide whether ImagePrep360's One Click output is "9 out of 10". The rubric is written down *before* the scores so it can't be bent to fit them. Each threshold has a reason, and where a threshold is our own judgement, it says so.

## Test matrix

| | |
|---|---|
| **Images** | `astronaut` (512², portrait, full tonal range), `cat` (451×300, grey tabby, no true white), `coffee` (600×400, fine texture) in `benchmarks/compare/inputs/`. All three are public domain (scikit-image). |
| **Materials** | Wood (dark marks), Black Slate and Anodized Aluminium (light marks, so the file is inverted) |
| **Job** | 4 in wide at 254 DPI, so about 1016 px wide and 0.7–1 MP |
| **ImagR** | One Click in the user's logged-in account. Material set, width 4 in, 254 DPI, dithering on, nothing else touched. |
| **Ours** | One Click defaults: `prepareBase` → `finishImage` with the material's own dither (`ultra`). Script: `app/scripts/bench/compare.ts`. |
| **Scorer** | `app/src/lib/bench/score.ts`, one piece of code for both sides. ImagR is scored *inside its own page* by injecting `app/scripts/bench/inpage.ts` against ImagR's decoded copy of the source, so no file had to leave the page. See `benchmarks/RESULTS.md` for the metric definitions. |

## Rubric

Each metric is the σ = 1.5 px eye/laser-spot model at 254 DPI (the stricter of the two σ values we compute). The score for each category comes from the listed thresholds; anything worse than the 7/10 line scores proportionally lower.

| Category | Measured as | 10/10 | **9/10** | 8/10 | 7/10 | Why this line |
|---|---|---|---|---|---|---|
| **Fidelity** | 1 − SSIM, mean over the 9 cases (worst case in brackets) | ≤ 0.06 (≤ 0.10) | **≤ 0.10 (≤ 0.15)** | ≤ 0.13 | ≤ 0.16 | 0.16 is the best ImagR achieves in any mode (Easy Mode + Norton on the astronaut, 0.1655). A 9 means at least about 1.6× better than ImagR's best. |
| **Detail** | GMSD, mean (worst) | ≤ 0.050 | **≤ 0.055 (≤ 0.060)** | ≤ 0.065 | ≤ 0.08 | 1-bit dots at 254 DPI have a measured ripple floor of about 0.045–0.05 on flat dark tones (`scripts/bench/floor.ts`). A 9 is within 10–20 % of what is physically possible at this DPI. |
| **Tone** | mean \|local tone error\| at σ 4, mean (worst) | ≤ 0.02 (≤ 0.03) | **≤ 0.03 (≤ 0.05)** | ≤ 0.05 | ≤ 0.08 | 0.05 is one step on a 20-level grey ramp: about the smallest shift a viewer notices side by side. |
| **Speed** | One Click wall time per megapixel | ≤ 0.5 s/MP | **≤ 1 s/MP in Node and ≤ 1.5 s/MP in the browser** | ≤ 3 s/MP | ≤ 10 s/MP | Our own judgement: it should feel instant. ImagR takes about 15 s per job (a server round trip). |
| **Artifacts / visual** | Checklist of 5 items judged on 2× crops; each item is pass (2), partial (1) or fail (0) | 10/10 points | **9/10 points** | 8 | 7 | The checklist: (1) no directional worm or line patterns; (2) no crushed blacks or blown highlights beyond the source's own; (3) no halos; (4) thin features legible at 1:1; (5) 1-bit file, correct size and DPI |
| **Exposure robustness** | Synthetic photos in `app/test/autotone.test.ts`: well-exposed with no white, hazy, underexposed, overexposed, full-range | 5/5 behave as specified | **—** | 4/5 | 3/5 | Smart Auto must fix bad exposure without "fixing" good exposure |

## Results: 9 cases, σ 1.5 (lower is better)

"Before" is the previous Smart Auto (full `autoLevels` stretch). "Now" is the exposure-aware `autoTone` (see the next section).

| Case | 1−SSIM ImagR | before | **now** | GMSD ImagR | before | **now** | Tone ImagR | before | **now** | ms (Node) |
|---|---|---|---|---|---|---|---|---|---|---|
| astronaut · wood | 0.453 | 0.081 | **0.082** | 0.204 | 0.049 | **0.049** | 0.143 | 0.022 | **0.020** | 756 |
| astronaut · slate | 0.437 | 0.071 | **0.070** | 0.218 | 0.050 | **0.050** | 0.141 | 0.004 | **0.004** | 721 |
| astronaut · anodized | 0.437 | 0.079 | **0.080** | 0.218 | 0.048 | **0.048** | 0.141 | 0.008 | **0.007** | 756 |
| cat · wood | 0.442 | 0.126 | **0.066** | 0.264 | 0.064 | **0.054** | 0.131 | 0.164 | **0.028** | 499 |
| cat · slate | 0.464 | 0.108 | **0.060** | 0.269 | 0.060 | **0.052** | 0.137 | 0.138 | **0.015** | 480 |
| cat · anodized | 0.464 | 0.116 | **0.064** | 0.269 | 0.063 | **0.054** | 0.137 | 0.141 | **0.018** | 488 |
| coffee · wood | 0.449 | 0.162 | **0.142** | 0.231 | 0.051 | **0.051** | 0.170 | 0.024 | **0.026** | 513 |
| coffee · slate | 0.465 | 0.141 | **0.130** | 0.239 | 0.052 | **0.053** | 0.181 | 0.012 | **0.009** | 525 |
| coffee · anodized | 0.465 | 0.159 | **0.142** | 0.239 | 0.052 | **0.052** | 0.181 | 0.018 | **0.014** | 548 |
| **mean** | 0.453 | 0.116 | **0.093** | 0.239 | 0.054 | **0.051** | 0.151 | 0.059 | **0.016** | |

- Now **ours beats ImagR on every metric in all 9 cases**: per case by at least 3.2× on 1−SSIM, 4.1× on GMSD and 4.6× on tone. On average the gap is 4.9×, 4.6× and 9.7×.
- Before the change, the cat on wood had a **worse** tone error than ImagR (0.164 vs 0.131). That was the one case we lost.
- ImagR's Black Slate and Anodized Aluminium outputs are byte-identical for every image. It has one pipeline for "light-marking" materials.
- ImagR timing: 14.5–16.5 s from clicking *Prepare Image* to the result. Ours: 0.48–0.76 s per job in Node (about 0.7 s/MP), and about 1.1 s/MP in the browser (One Click at 2.3 MP took 2.6 s).

## Scorecard

| Category | ImagR One Click | Ours before | **Ours now** | Notes |
|---|---|---|---|---|
| Fidelity | 2 | 8 (mean 0.116; worst 0.162) | **9** (mean 0.093; worst 0.142) | Coffee is the weakest case: its fine foam texture has more detail than 1-bit at 254 DPI can carry |
| Detail | 2 | 8 (mean 0.054; worst 0.064) | **9** (mean 0.051; worst 0.054) | At the 1-bit floor; only a higher DPI moves this |
| Tone | 4 | 7 (mean 0.059; worst 0.164) | **10** (mean 0.016; worst 0.028) | Fixed by `autoTone` |
| Speed | 5 (about 15 s per job, including the server round trip) | 9 | **9** (0.7 s/MP Node, 1.1 s/MP browser) | |
| Artifacts / visual | 4 (checklist: 0 + 0 + 0 + 2 + 2) | 9 | **9** (2 + 2 + 2 + 1 + 2) | See the trade-off below |
| Exposure robustness | not measured | 8 (4/5: it brightened the well-exposed "no white" photo from mean 0.40 to 0.50) | **10** (5/5) | `app/test/autotone.test.ts` |

**Every category is now at least 9.** The one partial point is thin-feature crispness (item 4).

## Visual comparison (2× crops, nearest-neighbour)

![Coffee on anodized aluminium: source, ImagR, ours](../benchmarks/compare/crops/coffee_anodized_2x.png)

![Cat on wood: source, ImagR, ours (with autoTone)](../benchmarks/compare/crops/cat_wood_2x.png)

### The honest trade-off
**ImagR One Click is punchier.**
- Heavy local contrast makes the cup rim and the cat's whiskers and eye outline read crisply from a distance.
- That has a cost:
  - Error-diffusion **worm and line patterns**, which show as streaks in the fur and the foam.
  - **Crushed blacks:** solid black blocks in the rim and fur stripes where the photo has detail.
  - **Halos** along the rim.
  - Tones that move far from the photo (mean tone error 0.15).
- On a real burn the solid blocks also over-burn.

**Ours is faithful but softer.**
- Tones, fur and foam texture follow the photo.
- No streaks: DBS dithering places dots without directional artifacts.
- Thin, low-contrast features are fainter than in ImagR, for example the coffee's foam edge arc and individual whiskers. That's the partial point in the checklist.
- People who like the punchy look can already pick the *Punchy* / *Moody* **Looks**. Those deliberately trade fidelity for impact.

**Still to confirm with a real burn.** The metric assumes ideal dots. Real lasers have dot gain, and isolated single dots can under-burn, which would make ours look lighter and softer than the simulation.

## What changed: exposure-aware auto tone

Smart Auto's first step was a full levels stretch (`autoLevels(0.4 %, 1)`). It maps the clipped darkest and brightest pixels to black and white. That is right for a dark or washed-out photo. A well-exposed photo that simply has no white is different. The cat is one: its 99.6th percentile is 0.73. On such a photo the stretch brightens everything: the cat's mean went from 0.46 to 0.62 and the tone error to 0.164.

`autoTone` (`app/src/lib/image/adjust.ts`) works as follows:
- **Well-exposed photo** (median between 0.35 and 0.65): it keeps the median fixed. It adds only the contrast that fits without clipping more than 0.4 % at either end. The gain is capped at 2.5×. The cat gets +16 % contrast and the same brightness.
- **Dark or washed-out photo** (median outside that band): it blends smoothly, over 0.15, into the full stretch, so those photos are still corrected.
- It is monotonic and full-range photos pass through unchanged. All of this is unit-tested.
- Exported workflows use the matching `autoTone` op, so a saved workflow reproduces One Click.

**Variants tried** (`app/scripts/bench/levels-variants.ts`; means over the 9 cases, 1−SSIM / GMSD / tone):

| Variant | 1−SSIM | GMSD | Tone | Verdict |
|---|---|---|---|---|
| full stretch (old) | 0.116 | 0.054 | 0.059 | brightens well-exposed photos |
| half-strength stretch | 0.096 | 0.052 | 0.035 | still brightens the cat (tone error 0.094 on wood) |
| median-preserving gamma | 0.120 | 0.057 | 0.033 | a strong curve distorts structure |
| gain cap 1.25 / 1.4 | 0.106 / 0.112 | 0.052 / 0.053 | 0.051 / 0.059 | clamping breaks the median anchor |
| no stretch | 0.085 | 0.051 | 0.011 | best on this metric only because it is the identity; it would leave dark or hazy photos uncorrected |
| **autoTone (adopted)** | **0.093** | **0.052** | **0.016** | close to the identity on good photos and still fixes bad ones |

## Crisp look (opt-in)

**Measured:** 2026-11-06. **Data:** [`benchmarks/compare/ours/scores_crisp.json`](../benchmarks/compare/ours/scores_crisp.json). **Script:** `cd app && npx tsx ../benchmarks/compare/crisp.ts`.

Crisp is aimed at checklist item 4, thin features. It is **off unless chosen**: the *Crisp* Look (amount 50), the *Crisp* fine-tune slider (Studio mode), or the `crisp` op in the Advanced editor (Detail) and in workflow recipes.

**How it works** (`app/src/lib/image/crisp.ts`). It runs on the final-size grey image, after the fine-tune and before inversion and dithering:
- It is an unsharp-mask detail signal (image − gaussian, σ 1.2 px), gated by an edge map: max(gradient, curvature) of the smoothed image. Grain and flat areas get 0, so they stay bit-identical.
- A shadow guard switches it off where the base tone is below 0.05–0.2. Deep shadows are left alone.
- **Halo clamp.** Each pixel stays inside the min–max of its own 3×3 neighbourhood in the source. On a step edge that range is the two plateaus, so no rim can form. The ramp between them is only steepened.
- The one exception is the centre of a thin line (whisker, hair, text stroke). It must be a 3×3 extreme of the source and a strict ridge or valley of the smoothed image across its strongest curvature. Its peak may then rise, or its pit deepen, by at most 1.5 × amount × its own detail. That keeps its contrast through 1-bit dots.
- Cost: 0.4 s on a 12 MP image in Node (about 35 ms/MP, close to the plain sharpen's 0.32 s). At the usual 0.7–1 MP job size it adds about 16 ms per job (mean over the 9 cases).

**New measures** (σ 1.5 eye model, same scorer):
- **fcr**: fine-feature contrast retention. Take the high-pass (image − gaussian σ 3) of what the viewer sees. On the 10 % of pixels with the most fine detail in the ideal print, it is the share of that print's fine contrast that the engraving shows. Higher means thin features read more strongly. It is above 1 already by default, because the material presets add CLAHE and a little sharpening.
- **halo**: on the pre-dither grey, overshoot past the source's 5×5 range at non-extreme pixels within 2 px of a strong edge (plateaus, where halos live), ×255. For scale, a plain unsharp mask (σ 1.2) on cat · wood scores mean 0.22 / p99.5 5.7 at ×0.5 and 1.76 / 21.3 at ×1.5.
- **crush**: share of pixels seen as solid black (< 0.04) minus the same share in the ideal print.
- **worms**: share of minority dots (local density < 30 %) that sit in thin 8-connected chains of 4 or more.

**Results** (means over the 9 cases; worst case in brackets):

| Crisp amount | 1−SSIM | GMSD | Tone | fcr | halo mean / p99.5 | crush | worms | ms (Node) |
|---|---|---|---|---|---|---|---|---|
| 0 (default) | **0.093** (0.142) | **0.051** (0.054) | **0.016** (0.028) | 1.096 | 0 / 0 | 2.97 % | 4.44 % | 602 |
| 30 | 0.096 (0.146) | 0.052 (0.055) | 0.016 (0.028) | 1.203 | 0 / 0 | 2.99 % | 4.64 % | 617 |
| **50** (the Look) | 0.097 (0.148) | 0.052 (0.055) | 0.016 (0.028) | 1.240 | 0 / 0 | 3.01 % | 4.63 % | 618 |
| 80 | 0.099 (0.151) | 0.054 (0.056) | 0.016 (0.028) | 1.273 | 0 / 0 | 3.00 % | 4.83 % | 613 |

On the synthetic line card (1–3 px lines and dots at 4–40 % contrast), fcr goes 1.011 → 1.047 / 1.066 / 1.102, with no worms in any variant. Cat on wood is the strongest photo case: fcr goes 1.24 → 1.37 / 1.42 / 1.46. Crops: [`crops/crisp_cat_wood_2x.png`](../benchmarks/compare/crops/crisp_cat_wood_2x.png) (eye and whiskers; default, 30, 50, 80).

**Reading it:**
- **Default unchanged.** Re-running `app/scripts/bench/compare.ts` writes the same 9 PNGs byte for byte and the same scores. `app/test/crisp.test.ts` hashes the default One Click / Easy output (10 cases) and two exported workflows against hashes captured before the op existed.
- **Thin features.** Crisp 50 raises fine-feature contrast by 13 % (1.096 → 1.240). Most of the gain comes in the first 30.
- **No new artifacts.** Halos are zero at every amount. That follows from the clamp, which the metric checks; a plain unsharp mask of similar strength scores 1.8 / 21. Solid-black area moves by at most 0.04 points. Chained dots rise by 0.2–0.4 points, which is within the case-to-case spread (2.9–6.5 %). Tone error does not move.
- **The cost is fidelity.** It is small: +0.004 mean 1 − SSIM at 50. Through 50 everything stays inside 9/10 (mean ≤ 0.10, worst ≤ 0.15). At 80 the worst case, coffee · wood at 0.151, just crosses the 0.15 line.

**Recommended amount per material.** Ranges are ours to try first; confirm with a real burn.

| Material | Recommend | Why |
|---|---|---|
| Wood | **50** | fcr 1.13 → 1.28 for +0.005 1−SSIM. The preset already sharpens (0.1), and 80 pushes coffee past 0.15 |
| Black Slate | **50–80** | The cheapest: no preset sharpen, so +0.004 1−SSIM even at 80 (worst 0.133); fcr 1.06 → 1.23 |
| Anodized aluminium | **50** | Same as wood: 80 reaches 0.150 on coffee |
| Fine, busy textures (foam, gravel) | **30** | Most of the gain, and the least change to the texture |

Crisp stays opt-in. It doesn't become the default until a real burn confirms the simulation, because dot gain could make it look stronger on the material than here.

## Coffee fidelity

**Measured:** 2026-11-06. **Data:** [`benchmarks/compare/ours/scores_sharpen.json`](../benchmarks/compare/ours/scores_sharpen.json). **Script:** `cd app && npx tsx ../benchmarks/compare/sharpen.ts` (about 3 min). Shared checks live in `benchmarks/compare/metrics.ts`, which `crisp.ts` now imports too.

**Question.** Coffee is the worst case (0.142 on wood and anodized, 0.130 on slate). Wood and anodized have the material sharpen (0.1, an unsharp mask of 0.2 at σ 0.8 after the resize); slate has none. Does the sharpen cost fidelity on fine textures, and would a lighter or texture-gated sharpen help?

**Method.** Smart Auto is run once per case with the sharpen removed. Each variant is then applied in its place (the `preset` row is checked to be bit-identical to `prepareBase`), and the result is finished and dithered exactly as One Click does. Besides the usual three metrics:
- **pre-dither 1 − SSIM**: the same scorer on the grey just before the dither (photo space, no dots). It shows what the tone curve and the sharpen cost on their own. The step from it to the dithered score is the dither.
- **fcr**: fine-feature contrast retention (see "Crisp look").
- **tex**: texture energy kept. On the 25 % of pixels with the most fine texture in the ideal print (local std of the σ 3 high-pass), the pre-dither grey's texture energy over the print's. 1 = the photo's own strength.
- **halo** (against the unsharpened base, so the sharpen's own overshoot shows), **crush** and **worms** as in "Crisp look".
- **gated**: the same unsharp mask, scaled by 1 − smoothstep(0.01, 0.03) of the local texture energy, so it fades out where texture is already strong.

**Coffee · wood** (worst case; σ 1.5):

| Sharpen | 1−SSIM | GMSD | Tone | pre-dither 1−SSIM | fcr | tex | halo p99.5 | crush | worms |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0.143 | 0.052 | 0.026 | 0.060 | 1.056 | 1.069 | 0.0 | 6.56 % | 6.05 % |
| 0.05 | 0.142 | 0.052 | 0.026 | 0.060 | 1.068 | 1.097 | 0.5 | 6.39 % | 5.60 % |
| **0.1 (preset)** | **0.142** | **0.051** | **0.026** | **0.061** | **1.077** | **1.126** | **1.7** | **6.42 %** | **5.46 %** |
| 0.15 | 0.144 | 0.052 | 0.026 | 0.061 | 1.091 | 1.154 | 3.2 | 6.59 % | 5.33 % |
| 0.2 | 0.143 | 0.052 | 0.026 | 0.061 | 1.098 | 1.183 | 4.9 | 6.49 % | 5.50 % |
| 0.1, σ 0.5 | 0.141 | 0.052 | 0.026 | 0.060 | 1.063 | 1.090 | 0.2 | 6.38 % | 5.58 % |
| 0.1, σ 1.2 | 0.142 | 0.051 | 0.026 | 0.061 | 1.100 | 1.166 | 3.7 | 6.42 % | 5.32 % |
| 0.1 gated | 0.142 | 0.051 | 0.026 | 0.060 | 1.058 | 1.076 | 0.0 | 6.39 % | 5.99 % |
| 0.2 gated | 0.143 | 0.052 | 0.026 | 0.061 | 1.060 | 1.082 | 0.8 | 6.58 % | 5.67 % |

**Coffee · slate and anodized** (1−SSIM / pre-dither 1−SSIM / fcr):

| Sharpen | slate | anodized |
|---|---|---|
| 0 | 0.130 / 0.005 / 1.019 (= preset) | 0.142 / 0.035 / 1.046 |
| 0.05 | 0.130 / 0.005 / 1.033 | 0.141 / 0.036 / 1.057 |
| 0.1 | 0.130 / 0.005 / 1.040 | **0.142 / 0.036 / 1.069** (preset) |
| 0.15 | 0.129 / 0.006 / 1.051 | 0.142 / 0.036 / 1.080 |
| 0.2 | 0.129 / 0.006 / 1.062 | 0.144 / 0.036 / 1.094 |
| 0.1 gated | 0.127 / 0.005 / 1.023 (GMSD 0.053 → 0.055) | 0.143 / 0.036 / 1.052 |

**All 9 cases** (mean, worst in brackets; last column = largest per-case 1−SSIM rise over today's default):

| Sharpen | 1−SSIM | GMSD | Tone | fcr | tex | halo mean / p99.5 | worms | worst Δ |
|---|---|---|---|---|---|---|---|---|
| 0 everywhere | 0.093 (0.143) | 0.051 (0.053) | 0.016 (0.028) | 1.082 | 1.090 | 0.00 / 0.0 | 4.47 % | +0.001 |
| 0.05 | 0.093 (0.142) | 0.052 (0.054) | 0.016 (0.028) | 1.093 | 1.111 | 0.00 / 0.3 | 4.40 % | +0.000 |
| **preset (today)** | **0.093 (0.142)** | **0.051 (0.054)** | **0.016 (0.028)** | **1.096** | **1.118** | **0.01 / 0.7** | **4.44 %** | — |
| 0.1 everywhere | 0.093 (0.142) | 0.051 (0.054) | 0.016 (0.028) | 1.102 | 1.132 | 0.02 / 1.1 | 4.41 % | +0.001 |
| 0.15 | 0.093 (0.144) | 0.051 (0.053) | 0.016 (0.028) | 1.111 | 1.153 | 0.04 / 2.1 | 4.32 % | +0.002 |
| 0.2 | 0.093 (0.144) | 0.051 (0.054) | 0.016 (0.028) | 1.121 | 1.175 | 0.07 / 3.2 | 4.55 % | +0.001 |
| 0.1, σ 0.5 | 0.093 (0.143) | 0.052 (0.054) | 0.016 (0.028) | 1.088 | 1.103 | 0.00 / 0.1 | 4.51 % | +0.000 |
| 0.1, σ 1.2 | 0.093 (0.143) | 0.052 (0.054) | 0.016 (0.028) | 1.122 | 1.167 | 0.05 / 2.6 | 4.34 % | +0.001 |
| 0.1 gated | 0.093 (0.143) | 0.051 (0.055) | 0.016 (0.028) | 1.091 | 1.107 | 0.00 / 0.3 | 4.53 % | +0.000 |
| 0.2 gated | 0.093 (0.143) | 0.051 (0.054) | 0.016 (0.028) | 1.100 | 1.124 | 0.02 / 1.1 | 4.51 % | +0.001 |

Crops: [`crops/sharpen_coffee_wood_2x.png`](../benchmarks/compare/crops/sharpen_coffee_wood_2x.png) (rows: crema, table grain; columns: ideal print, sharpen 0, 0.1 = preset, 0.2).

**Reading it:**
- **The sharpen costs nothing measurable.** Across the whole sweep, from 0 to 0.2 and σ 0.5 to 1.2, coffee moves by at most ±0.002 1 − SSIM, and not in one direction: on wood, sharpen 0 is 0.143 and the preset 0.142. That is the size of the dither's own jitter. A sub-1 % change in the grey moves DBS dots, as the gated slate row shows: 1 − SSIM goes down 0.003 while GMSD goes up 0.002. Before the dither, the sharpen adds at most 0.001 (0.060 → 0.061).
- **The dither carries almost all of it.** On slate the pre-dither grey is nearly the photo (0.005), yet the dithered file scores 0.130. That is the 1-bit floor for this foam at 254 DPI with this dither, the same story as GMSD's ripple floor.
- **Wood/anodized vs slate (0.142 vs 0.130) is the tone curve, not the sharpen.** With the sharpen off, the pre-dither grey already scores 0.060 on wood and 0.035 on anodized: wood's midtone lift (gamma 1.08, contrast +0.04) and anodized's contrast +0.02. Those are deliberate material compensations (wood burns dark), so this is the wrong benchmark for tuning them. The scorer compares with the photo, not with a burn on that material.
- **The sharpen does what it is for.** fcr and texture energy rise steadily with amount (coffee · wood fcr 1.056 → 1.077 at the preset, 1.098 at 0.2). Halos stay tiny at 0.1 (p99.5 1.7/255, against 21 for a plain ×1.5 unsharp mask). Crush and worms show no trend.
- **The texture gate does its job but buys nothing.** It takes the halo and texture boost off the foam (coffee · wood tex 1.126 → 1.076), which is close to having no sharpen there. Fidelity doesn't move, and the mean fcr drops from 1.096 to 1.091.

**Decision: engine unchanged.** No per-material sharpen value and no texture-gated sharpen improves coffee by more than the dither noise. Meanwhile every change gives up some fine contrast (lower) or adds halo (higher) somewhere else. Default output stays byte-identical. The `crisp.test.ts` hashes still pass, and `compare.ts` reproduces 0.093 / 0.051 / 0.016. The next lever for coffee is not sharpening. It is either a higher DPI or a material-specific scorer: score against a simulated burn of the material, so the wood tone compensation isn't counted as error. Both are left for the real-burn work.

## Regression guard
- `app/test/autotone.test.ts` (unit) checks the exposure behaviour in the robustness row.
- `app/test/crisp.test.ts` (unit) checks the Crisp op and hashes the default output against the pre-Crisp engine.
- `app/e2e/smoke.e2e.ts` (Playwright, `npm --prefix app run e2e`) checks, on desktop and on a phone viewport:
  - every page renders with no uncaught errors and no horizontal scroll, with the API down and signed out;
  - the 404 page;
  - all 4 themes;
  - a real One Click run (upload → Wood → Prepare → Download);
  - a real Easy Mode run (upload → crop → Black Slate → Prepare → fine-tune → Download).
- Last run: 26 passed.

## Next ideas
- **Crisp look:** built and opt-in (see "Crisp look (opt-in)" above). Next, burn the 9-case matrix at 0 / 50 / 80 and pick the material defaults from the photos.
- **Coffee fidelity (worst case, 0.142):** measured (see "Coffee fidelity" above). The material sharpen costs ≤ 0.002, which is within the dither noise. The dither and the wood/anodized tone curve account for the rest, so the engine is unchanged.
- **Test burns** of the 9-case matrix on real wood, slate and anodized aluminium. Photograph them under the same light and score the photos.
- Re-run this matrix after every change to Smart Auto, the material presets or the dither:
  ```bash
  cd app
  npx tsx scripts/bench/compare.ts
  ```
  ImagR numbers only change if ImagR changes. Re-capture them with the in-page scorer.
