From 81aea256539eb0cd3d4e8ad0cb32966e43202203 Mon Sep 17 00:00:00 2001 From: Joseph Ross Date: Sat, 15 Aug 2026 16:55:26 -0700 Subject: [PATCH 1/2] FRAM-1182 Add opts07 dither option set for 9 color palettes The 10" panel measures 9 colors, but opts06 slices exactly 24 palette tokens, so a wider palette was truncated there and the leftover tokens then failed to parse. opts07 carries all 9 as name:L:a:b, adding or (orange), br (brownish 2) and y2 (softer yellow 2) to the Spectra 6. All nine inks are dithered. pushd-dither puts the six base pigments in canonical slots, keeps the extras after them, and dithers the whole palette, so adopting opts07 changes what the 10" panel renders. The gamut hull is set explicitly to those nine inks with --map-pal-str. Left to itself the tool widens the hull with magenta and cyan, which its README advises against for a 9+ colour palette. --out-pal-str carries the panel's drive values, which is what the frame matches the rendered image against. The tool's built-in out palette holds six colors; nine ink indices overflow it and it falls back to rendering the measured inks, which the frame has nothing to match. opts07 is a fixed 9 colors and rejects a 6 color palette; six and 18 sample calibration files stay on opts06. Its length check requires all 39 args rather than repeating the off by one in opts06, which admits a short palette and then panics on the slice. The submodule moves to superpixel_dither for the N-colour --pal-str parser that opts07 depends on. The commit pinned before it accepts six colours only. Assisted by Claude Opus 5 --- options/processing_options.go | 22 +++++++++++++ options/processing_options_test.go | 52 ++++++++++++++++++++++++++++++ plugins/pushd-dither | 2 +- processing/dither.go | 27 ++++++++++++++++ processing/dither_test.go | 26 +++++++++++++++ 5 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 processing/dither_test.go diff --git a/options/processing_options.go b/options/processing_options.go index e7f91a597c..523b81808a 100644 --- a/options/processing_options.go +++ b/options/processing_options.go @@ -87,6 +87,7 @@ type DitherOptions struct { OptionsSet04 bool OptionsSet05 bool OptionsSet06 bool + OptionsSet07 bool OptionsSetCam16 bool OptionsSetHpminde bool OptionsSetScam bool @@ -740,6 +741,11 @@ func applyPixelateOption(po *ProcessingOptions, args []string) error { return nil } +// The 9 colors the 10" panel measures, each as name:L:a:b. The six Spectra +// colors are r, g, bl, y, w and bk; the rest are or (orange), br (brownish 2) +// and y2 (softer yellow 2). +const opts07PaletteTokens = 9 * 4 + func applyDitherOption(po *ProcessingOptions, args []string) error { switch args[0] { case "fs": @@ -816,6 +822,22 @@ func applyDitherOption(po *ProcessingOptions, args []string) error { po.Dither.MeasuredPalette = strings.Join(args[idx+2:idx+26], ":") po.Dither.OptionsSet06 = true idx += 25 + case "opts07": + // as opts06, but carrying the 9 color palette measured on the 10" panel. ex: + // opts07:0.65:bk:12.79:5.21:-12.53:y:63.41:-12.92:68.53:bl:28.63:5.38:-39.49:r:26.32:38.07:26.51:g:30.21:-22:5.54:w:66.16:-3.5:-1.98:or:36.06:36.04:36.13:br:46.37:21.06:46.76:y2:68.07:-13.74:39.3 + if len(args) < idx+2+opts07PaletteTokens { + return fmt.Errorf("opts07 requires %d params got %d: %s", idx+2+opts07PaletteTokens, len(args), args[0]) + } + swapYellowProbStr := args[idx+1] + floatSwapYellowProb, err := strconv.ParseFloat(swapYellowProbStr, 64) + if err != nil || math.IsNaN(floatSwapYellowProb) || math.IsInf(floatSwapYellowProb, 0) { + return fmt.Errorf("Invalid swap yellow probability: %s", swapYellowProbStr) + } + + po.Dither.SwapYellowProb = floatSwapYellowProb + po.Dither.MeasuredPalette = strings.Join(args[idx+2:idx+2+opts07PaletteTokens], ":") + po.Dither.OptionsSet07 = true + idx += 1 + opts07PaletteTokens case "optscam16": po.Dither.OptionsSetCam16 = true case "optshpminde": diff --git a/options/processing_options_test.go b/options/processing_options_test.go index 4bb4673896..3c7b2a3d04 100644 --- a/options/processing_options_test.go +++ b/options/processing_options_test.go @@ -597,6 +597,58 @@ func (s *ProcessingOptionsTestSuite) TestParseBase64URLOnlyPresets() { require.Equal(s.T(), originURL, imageURL) } +const nineColorPalette = "bk:12.79:5.21:-12.53:y:63.41:-12.92:68.53:bl:28.63:5.38:-39.49:" + + "r:26.32:38.07:26.51:g:30.21:-22:5.54:w:66.16:-3.5:-1.98:" + + "or:36.06:36.04:36.13:br:46.37:21.06:46.76:y2:68.07:-13.74:39.3" + +const sixColorPalette = "bk:8.88:9.08:-12.38:y:60.02:-10.87:64.97:w:61.55:-3.11:-0.93:" + + "bl:26.91:3.06:-34.11:r:23.76:38.93:28.27:g:31:-18.73:9.4" + +func (s *ProcessingOptionsTestSuite) TestParseDitherOpts07() { + originURL := "http://images.dev/lorem/ipsum.jpg" + path := fmt.Sprintf("/dither:fs:opts07:0.65:%s/plain/%s", nineColorPalette, originURL) + po, imageURL, err := ParsePath(path, make(http.Header)) + + require.Nil(s.T(), err) + require.Equal(s.T(), originURL, imageURL) + require.True(s.T(), po.Dither.OptionsSet07) + require.False(s.T(), po.Dither.OptionsSet06) + require.Equal(s.T(), 0.65, po.Dither.SwapYellowProb) + require.Equal(s.T(), nineColorPalette, po.Dither.MeasuredPalette) +} + +func (s *ProcessingOptionsTestSuite) TestParseDitherOpts07WithTrailingOption() { + originURL := "http://images.dev/lorem/ipsum.jpg" + // applyDitherOption's loop never visits the last argument, so co is there to + // give sp a position the loop actually reaches + path := fmt.Sprintf("/dither:fs:opts07:0.65:%s:sp:co/plain/%s", nineColorPalette, originURL) + po, _, err := ParsePath(path, make(http.Header)) + + require.Nil(s.T(), err) + require.True(s.T(), po.Dither.OptionsSet07) + require.Equal(s.T(), nineColorPalette, po.Dither.MeasuredPalette) + require.True(s.T(), po.Dither.SoftProof, "the palette must not swallow the option after it") +} + +func (s *ProcessingOptionsTestSuite) TestParseDitherOpts07RejectsSixColorPalette() { + originURL := "http://images.dev/lorem/ipsum.jpg" + path := fmt.Sprintf("/dither:fs:opts07:0.65:%s/plain/%s", sixColorPalette, originURL) + _, _, err := ParsePath(path, make(http.Header)) + + require.Error(s.T(), err, "opts07 carries 9 colors; a 6 color palette belongs in opts06") +} + +func (s *ProcessingOptionsTestSuite) TestParseDitherOpts06StillTakesSixColors() { + originURL := "http://images.dev/lorem/ipsum.jpg" + path := fmt.Sprintf("/dither:fs:opts06:0.65:%s/plain/%s", sixColorPalette, originURL) + po, _, err := ParsePath(path, make(http.Header)) + + require.Nil(s.T(), err) + require.True(s.T(), po.Dither.OptionsSet06) + require.False(s.T(), po.Dither.OptionsSet07) + require.Equal(s.T(), sixColorPalette, po.Dither.MeasuredPalette) +} + func TestProcessingOptions(t *testing.T) { suite.Run(t, new(ProcessingOptionsTestSuite)) } diff --git a/plugins/pushd-dither b/plugins/pushd-dither index 6d49bc9dd7..8b435c4659 160000 --- a/plugins/pushd-dither +++ b/plugins/pushd-dither @@ -1 +1 @@ -Subproject commit 6d49bc9dd7ed76f8f0da2e3dc3f3e193752cbbdc +Subproject commit 8b435c46594bf8858a7fac4427bca1b78a9cc893 diff --git a/processing/dither.go b/processing/dither.go index e34b803158..9799cd13c2 100644 --- a/processing/dither.go +++ b/processing/dither.go @@ -182,6 +182,14 @@ func shellOutVendor(inFile string, po *options.ProcessingOptions) error { return nil } +// The drive values the 10" panel matches the rendered image against, one per ink +// and index aligned to the nine ink dither palette once its base pigments sit in +// canonical slots. Without this the tool renders --image-out against its six +// color default, overflows it and falls back to the measured inks, which the +// frame has nothing to match. Values come from eink-sop-tool's target chart. +const opts07OutPalette = "rgb:r:255:0:0:g:0:128:0:bl:0:0:255:y:255:255:0:" + + "w:255:255:255:bk:0:0:0:or:255:165:0:y2:128:128:0:br:165:42:42" + func shellOutDither(inFile string, po *options.ProcessingOptions) error { outFile := fmt.Sprintf("%s-dithered-tmp.png", inFile) proofFile := fmt.Sprintf("%s-dithered-proof-tmp.png", inFile) @@ -296,6 +304,25 @@ func shellOutDither(inFile string, po *options.ProcessingOptions) error { cmdArgs = append(cmdArgs, "--inflate-color-space", "jzazbz") cmdArgs = append(cmdArgs, "--pal-str", po.Dither.MeasuredPalette) cmdArgs = append(cmdArgs, "--swap-yellow-prob", fmt.Sprintf("%0.2f", po.Dither.SwapYellowProb)) + case po.Dither.OptionsSet07: + // opts06's treatment over the nine measured inks, all of which are dithered + cmdArgs = append(cmdArgs, "--jzazbz") + cmdArgs = append(cmdArgs, "--hull-project") + cmdArgs = append(cmdArgs, "--chroma-lightness") + cmdArgs = append(cmdArgs, "--shrink-gamut", "1.1") + cmdArgs = append(cmdArgs, "--saturation-scale", "1.0") + cmdArgs = append(cmdArgs, "--clip-error") + cmdArgs = append(cmdArgs, "--auto-enhance") + cmdArgs = append(cmdArgs, "--dea-weight", "0.95") + cmdArgs = append(cmdArgs, "--pal-auto-expand", "2.0") + cmdArgs = append(cmdArgs, "--inflate-color-space", "jzazbz") + cmdArgs = append(cmdArgs, "--pal-str", po.Dither.MeasuredPalette) + // Hull is the nine inks themselves. The tool otherwise falls back to + // widening the hull with magenta and cyan, which the nine-ink panel does + // not need; passing the dither palette here leaves it no extras to add. + cmdArgs = append(cmdArgs, "--map-pal-str", po.Dither.MeasuredPalette) + cmdArgs = append(cmdArgs, "--out-pal-str", opts07OutPalette) + cmdArgs = append(cmdArgs, "--swap-yellow-prob", fmt.Sprintf("%0.2f", po.Dither.SwapYellowProb)) case po.Dither.OptionsSetCam16: cmdArgs = append(cmdArgs, "--cam16") cmdArgs = append(cmdArgs, "--chroma-lightness") diff --git a/processing/dither_test.go b/processing/dither_test.go new file mode 100644 index 0000000000..b800d38fd1 --- /dev/null +++ b/processing/dither_test.go @@ -0,0 +1,26 @@ +package processing + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The dither tool renders --image-out by indexing the out palette with the ink +// each pixel was dithered to, so a dropped or reordered entry mis-renders the +// panel silently. The order is the canonical one the tool normalizes the nine +// ink palette into: the six base pigments, then the extras as listed. +func TestOpts07OutPaletteIsIndexAlignedToTheInkPalette(t *testing.T) { + require.True(t, strings.HasPrefix(opts07OutPalette, "rgb:"), + "drive values are RGB, not the default lab") + + groups := strings.Split(strings.TrimPrefix(opts07OutPalette, "rgb:"), ":") + require.Len(t, groups, 9*4, "one name and three components per ink") + + var names []string + for i := 0; i < len(groups); i += 4 { + names = append(names, groups[i]) + } + require.Equal(t, []string{"r", "g", "bl", "y", "w", "bk", "or", "y2", "br"}, names) +} From 84c9aec10cdcb7424c82dc1b6f8426ff7acf0a77 Mon Sep 17 00:00:00 2001 From: Joseph Ross Date: Wed, 16 Sep 2026 11:23:00 -0700 Subject: [PATCH 2/2] FRAM-1182 Let the dither tool default the hull and out palette opts07 passed --map-pal-str and --out-pal-str to correct two of the dither tool's defaults for a nine ink palette: it appended magenta and cyan to the gamut hull whatever the palette's width, and rendered --image-out against a six colour palette that nine ink indices overflow. Both defaults now key off the palette's width inside the tool, so the option set carries neither flag, nor a copy of the panel's drive values. With those gone the opts07 branch was identical to opts06 and they now share one. opts07 stays a separate option set because it parses a 36 token palette where opts06 slices 24. Rendering is unchanged: the tool-side defaults produce a pixel identical image to the explicit flags. Assisted by Claude Opus 5 --- plugins/pushd-dither | 2 +- processing/dither.go | 31 +++---------------------------- processing/dither_test.go | 26 -------------------------- 3 files changed, 4 insertions(+), 55 deletions(-) delete mode 100644 processing/dither_test.go diff --git a/plugins/pushd-dither b/plugins/pushd-dither index 8b435c4659..4545f0245c 160000 --- a/plugins/pushd-dither +++ b/plugins/pushd-dither @@ -1 +1 @@ -Subproject commit 8b435c46594bf8858a7fac4427bca1b78a9cc893 +Subproject commit 4545f0245cb2477ab3b87ceada2d2a9e1bdfdaa0 diff --git a/processing/dither.go b/processing/dither.go index 9799cd13c2..3b938380c2 100644 --- a/processing/dither.go +++ b/processing/dither.go @@ -182,14 +182,6 @@ func shellOutVendor(inFile string, po *options.ProcessingOptions) error { return nil } -// The drive values the 10" panel matches the rendered image against, one per ink -// and index aligned to the nine ink dither palette once its base pigments sit in -// canonical slots. Without this the tool renders --image-out against its six -// color default, overflows it and falls back to the measured inks, which the -// frame has nothing to match. Values come from eink-sop-tool's target chart. -const opts07OutPalette = "rgb:r:255:0:0:g:0:128:0:bl:0:0:255:y:255:255:0:" + - "w:255:255:255:bk:0:0:0:or:255:165:0:y2:128:128:0:br:165:42:42" - func shellOutDither(inFile string, po *options.ProcessingOptions) error { outFile := fmt.Sprintf("%s-dithered-tmp.png", inFile) proofFile := fmt.Sprintf("%s-dithered-proof-tmp.png", inFile) @@ -291,21 +283,9 @@ func shellOutDither(inFile string, po *options.ProcessingOptions) error { cmdArgs = append(cmdArgs, "--pal-auto-expand", "2.0") cmdArgs = append(cmdArgs, "--inflate-color-space", "jzazbz") cmdArgs = append(cmdArgs, "--pal-str", po.Dither.MeasuredPalette) - case po.Dither.OptionsSet06: - cmdArgs = append(cmdArgs, "--jzazbz") - cmdArgs = append(cmdArgs, "--hull-project") - cmdArgs = append(cmdArgs, "--chroma-lightness") - cmdArgs = append(cmdArgs, "--shrink-gamut", "1.1") - cmdArgs = append(cmdArgs, "--saturation-scale", "1.0") - cmdArgs = append(cmdArgs, "--clip-error") - cmdArgs = append(cmdArgs, "--auto-enhance") - cmdArgs = append(cmdArgs, "--dea-weight", "0.95") - cmdArgs = append(cmdArgs, "--pal-auto-expand", "2.0") - cmdArgs = append(cmdArgs, "--inflate-color-space", "jzazbz") - cmdArgs = append(cmdArgs, "--pal-str", po.Dither.MeasuredPalette) - cmdArgs = append(cmdArgs, "--swap-yellow-prob", fmt.Sprintf("%0.2f", po.Dither.SwapYellowProb)) - case po.Dither.OptionsSet07: - // opts06's treatment over the nine measured inks, all of which are dithered + // opts07 is opts06 over a nine ink palette: the dither tool reads the extra + // inks straight off --pal-str, so the option set is the same. + case po.Dither.OptionsSet06, po.Dither.OptionsSet07: cmdArgs = append(cmdArgs, "--jzazbz") cmdArgs = append(cmdArgs, "--hull-project") cmdArgs = append(cmdArgs, "--chroma-lightness") @@ -317,11 +297,6 @@ func shellOutDither(inFile string, po *options.ProcessingOptions) error { cmdArgs = append(cmdArgs, "--pal-auto-expand", "2.0") cmdArgs = append(cmdArgs, "--inflate-color-space", "jzazbz") cmdArgs = append(cmdArgs, "--pal-str", po.Dither.MeasuredPalette) - // Hull is the nine inks themselves. The tool otherwise falls back to - // widening the hull with magenta and cyan, which the nine-ink panel does - // not need; passing the dither palette here leaves it no extras to add. - cmdArgs = append(cmdArgs, "--map-pal-str", po.Dither.MeasuredPalette) - cmdArgs = append(cmdArgs, "--out-pal-str", opts07OutPalette) cmdArgs = append(cmdArgs, "--swap-yellow-prob", fmt.Sprintf("%0.2f", po.Dither.SwapYellowProb)) case po.Dither.OptionsSetCam16: cmdArgs = append(cmdArgs, "--cam16") diff --git a/processing/dither_test.go b/processing/dither_test.go deleted file mode 100644 index b800d38fd1..0000000000 --- a/processing/dither_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package processing - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -// The dither tool renders --image-out by indexing the out palette with the ink -// each pixel was dithered to, so a dropped or reordered entry mis-renders the -// panel silently. The order is the canonical one the tool normalizes the nine -// ink palette into: the six base pigments, then the extras as listed. -func TestOpts07OutPaletteIsIndexAlignedToTheInkPalette(t *testing.T) { - require.True(t, strings.HasPrefix(opts07OutPalette, "rgb:"), - "drive values are RGB, not the default lab") - - groups := strings.Split(strings.TrimPrefix(opts07OutPalette, "rgb:"), ":") - require.Len(t, groups, 9*4, "one name and three components per ink") - - var names []string - for i := 0; i < len(groups); i += 4 { - names = append(names, groups[i]) - } - require.Equal(t, []string{"r", "g", "bl", "y", "w", "bk", "or", "y2", "br"}, names) -}