重温 Yliluoma 有序抖动算法
Revisiting Yliluoma's ordered dither algorithm

原始链接: https://30fps.net/pages/revisiting-yliluoma-2/

本文探讨了基于颜色的有序抖动技术,超越了简单的 1 位处理,旨在为索引调色板生成高质量的图像。 作者分析了 Joel Yliluoma 在 2011 年提出的“N 候选”算法,该算法通过维护候选颜色的指数移动平均值(EMA)来选择像素颜色。该方法的核心在于寻找最佳的几何“混合因子”($t$),以最小化输入颜色与调色板颜色之间的误差。 作者介绍了三种简化的“EMA”变体: * **EMA-Sweep:** 对 Yliluoma 原算法的直接重现。 * **EMA-Exact:** 用解析公式计算 $t$ 值以取代原有的参数扫描,从而提高效率。 * **EMA-Constant:** 进一步简化流程,使用固定的混合因子($t=0.3$),在轻度抖动效果上表现出色。 尽管这些 EMA 方法在性能上与 Thomas Knoll(Photoshop 所用)著名的抖动算法相当,但作者指出,由于它们需要对每个像素测试所有调色板颜色,因此速度通常较慢。然而,研究证实,高质量的抖动并不需要复杂的感知色彩模型,简单的亮度加权或预处理通常就已足够。文章最后提供了源代码和用于实现的实际对比。

``` Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 重温 Yliluoma 的有序抖动算法 (30fps.net) 由 ibobev 在 4 小时前发布,9 分 | 隐藏 | 过往 | 收藏 | 讨论 | 帮助 考虑申请 YC 2026 年秋季批次!申请截止日期为 7 月 27 日。 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索: ```
相关文章

原文

Summary: This article discusses Joel Yliluoma’s 2011 ordered dither algorithm (left), explains its internals in greater detail than other treatments, presents new simplified variants (middle), and compares the results to a state-of-the-art algorithm (right). Source code is included at the end.

A 1-bit introduction

Let’s lay some foundations first. I assume you know what ordered dithering looks like (if not, see above). In black and white it’s easy to code. First you acquire a threshold matrix from somewhere. For example, a 4x4 Bayer matrix like this:

\begin{bmatrix} 0 & 8 & 2 & 10 \\ 12 & 4 & 14 & 6 \\ 3 & 11 & 1 & 9 \\ 15 & 7 & 13 & 5 \end{bmatrix}

To apply the matrix to a grayscale image, you go over each pixel, find out which matrix element it corresponds to (conceptually, the matrix is tiled over the image), and read a threshold from the matrix. If the input pixel’s gray value is higher than the threshold, you output a white pixel. The code could look like this:

Ordered Dithering with Arbitrary or Irregular Colour Palettes that explains everything you need to know. Please read at least its “The Probability Matrix” section.

A key property these algorithms try to satisfy is local mean reproduction: when the dithered result is seen from afar (or blurred), it should look like the original. This translates to minimizing the distance between the input pixel color \mathbf{p} and the weighted sum of all chosen candidates w_1 \mathbf{r}_1 + ... + w_N \mathbf{r}_N. For more details, I suggest reading the blog post linked above.

Knoll’s algorithm

A 16-color image dithered with Knoll’s algorithm.

One algorithm that minimizes the above distance astonishingly well is Thomas Knoll’s dither algorithm, famously used in Adobe Photoshop (he’s its inventor after all). Knoll’s algorithm first sets a goal color \mathbf{x}_1 to the palette color closest to the input color \mathbf{p}. This palette color \mathbf{r}_1 is the first candidate color. Then the algorithm measures how much error there is between \mathbf{r}_1 and \mathbf{p}, and moves the goal in the opposite direction:

\mathbf{x}_{2} = \mathbf{x}_1 + (\mathbf{p} - \mathbf{r}_1).

Now the process repeats and a new closest point to the goal \mathbf{x}_2 is found. This will be the second candidate color. It compensates for the error of the earlier candidate; if the first palette color found was too blue, then this one will be yellowish. After N rounds of this, the iteration has visited different colors around \mathbf{p}. Each color could’ve been visited multiple times, and the final candidate probabilities are relative to the frequency of the visits. The process can be summarized as follows:

I know the procedure may still seem abstract, but the point is that the algorithm is both simple and effective. There are some subtleties like sorting by brightness to keep the color choices consistent across pixels, but in Python it boils down to this:

mateljou’s shader version. This should be enough context to understand the alternative solution presented next.

Yliluoma’s algorithms

In 2011, Joel Yliluoma presented a series of dithering algorithms as alternatives to Knoll’s. They were described on his website in an article titled Arbitrary-palette positional dithering algorithm. As far as I know, they haven’t gained much traction. I studied them carefully and found one particularly interesting.

Yliluoma-2 simplified

The previous 16-color image now dithered with Yliluoma’s algorithm #2.

I will focus on the second algorithm presented in the article. The variant in its C++ implementation, to be exact. Let’s call it Yliluoma-2.

On a high level, Yliluoma-2 is similar to other N-candidate algorithms like Knoll’s: on each pixel, select weighted candidate colors from the palette, then output one based on a threshold matrix (or noise). The difference is how the candidates are selected, which in this case is done by testing every palette color using a unique error formula.

No need for complex color difference formulas

There’s a common belief that you need complex color difference calculations for high quality dithering. I think this is mistaken, and what you really want are (a) more weight for the green channel and (b) emphasis on differences of bright colors.

The article proposes a luma-weighted (previously on this site) color difference formula, which I implemented. You can see its result in the first image from the left below. A simpler way to achieve the same thing is to desaturate your image and palette before dithering. The final indexed image still uses the original, colorful palette. For example libimagequant multiplies RGB colors by (0.5, 1, 0.45) and raises each channel to the power of 0.8. I did that in the second picture below, and it works just as well.

Of course, if you want a high-quality reconstruction, as in “looks like the original when squinting”, then do dithering in linear space. I’d also argue that if your output resolution is low and pixels big, you can do whatever you like. You’re constructing something very different-looking that you hope the viewer will interpret the same way as the original.

Candidates are chosen via closest-point-to-line-segment tests

Back to the main topic. I spent a pleasant afternoon with the code and arrived at a geometric interpretation of it. Recall how N-candidate methods both find candidate palette colors and assign weights (probabilities) to them. The color selection loop in Yliluoma-2 keeps track of an exponential moving average (EMA) of the candidate colors chosen so far. In other words, on each search iteration, the routine updates the moving average \mathbf{x}_i with a new candidate \mathbf{r}_i via \mathbf{x}_{i+1} = (1 - t)\mathbf{x}_i + t\mathbf{r}_i, where t \in [0,1] is a mixing weight that varies on every iteration. (Perhaps t should be called t_i instead?)

Okay, so the candidate is mixed in to the running average via lerp(). But how is the candidate chosen and where does the mixing factor t come from? The candidate point is chosen by testing line segments between every palette color \mathbf{c}_k and the current mean \mathbf{x}_i. The line that passes closest to the input color \mathbf{p} tells which palette color to choose as the candidate \mathbf{r}_i.

The value of t is then the mixing factor that produced the closest point on the segment that passed nearest to the input. It’s hard to explain, but visually things should make more sense:

The closest point to input \mathbf{p} on the line segment from \mathbf{x}_i to \mathbf{r}_i is identified by t. In this example, t would be about 0.6.

To summarize: of all palette colors, the chosen candidate best approximates the input when linearly combined with the current mean.

Exponential moving average loop in detail

Here’s the algorithm in more detail. Studying it is not necessary to understand the developments below, but I thought it prudent to document it.

On the first iteration, the candidate is chosen as the closest palette color to the input: \mathbf{x}_1 = \text{closest}(\mathbf{p}). Its weight is 1. The rest of the iterations proceed like this:

Yliluoma-2’s exponential moving average loop (simplified)
  1. Consider every palette color \mathbf{c}_k as a possible candidate.
    • Find a mixing factor t that minimizes the squared distance ||((1-t)\mathbf{x}_i + t \mathbf{c}_k) - \mathbf{p}||^2.
    • If the distance was the shortest found so far, then:
      • Update t_\text{best} = t
      • Update \mathbf{c}_\text{best} = \mathbf{c}_k.
      • Update k_\text{best} = k.
  2. Choose the candidate \mathbf{r}_i = \mathbf{c}_\text{best}.
  3. Move the mean point towards the candidate via the best mixing factor: \mathbf{x}_{i+1} = (1 - t_\text{best})\mathbf{x}_i + t_\text{best}\mathbf{r}_{i}
  4. Add t_\text{best} to candidate \mathbf{r}_i’s weight.
  5. Repeat N times.

Normalize weights via weights /= sum(weights).

Like Knoll’s algorithm, Yliluoma-2 also ends up approximating a convex hull. On each iteration, the goal \mathbf{x}_{i+1} is moved to the closest point on the segment that was found earlier. Because this point is in between the old goal \mathbf{x}_i and the input color \mathbf{p}, it must be on the opposite side (loosely speaking) of \mathbf{p}. That’s why the goal, the running average, moves around \mathbf{p}.

In the above explanation, I tried to make the procedure easy to understand by choosing a formulation with explicit averaging steps. The original C++ code uses a running average instead, presumably for speed.

How to find t

Above, I left it open as to how exactly the closest point on a line is found. The simplest way is to do a parameter sweep: test different values of t \in [0,1] and keep the one that was closest to the input point. Here’s a diagram. We are trying to find the point D that’s closest to C on the line segment AB. Below, the red dots stand for individual values of t we are testing:

We want the point closest to C on the line segment AB.

Unfortunately, a parameter sweep is unlikely to hit the exact optimal t value. It also takes time, but if you have a complex color difference formula, it might be the only thing that works. But remember how we established that luma weighting can be done via preprocessing? That’s why we can ignore the perceptual stuff when searching for t and solve the geometry problem directly.

An analytic t solution

Assuming A \neq B, we get

t_\text{closest} = \frac{(C - A) \cdot (B - A)}{||(B - A)||^2},

where “\cdot” denotes a dot product. Since we want the point in between A and B, we have to clamp the range to [0,1]:

t = \max(0, \min(1, t_\text{closest}))

and like earlier, D = (1-t)A + tB.

The same as code

a demonstration.

The exact ts chosen by EMA-Exact produce results similar to Yliluoma-2’s parameter sweep. Since the solved t is clamped to [0.2, 1.0), the resulting dithering is also a tad weaker than Yliluoma-2’s.

Below is an EMA-Exact vs Yliluoma-2 comparison with 16 colors, N=16 candidate iterations, and a 4x4 threshold matrix in both. They look mostly the same.

The number of candidates N has a large influence on the noisiness of the result. This applies to all N-candidate methods. The comparison below demonstrates with EMA-Exact how much worse 16 candidates look than 32. See for example the back of the yellow-turquoise macaw.

At high enough candidate counts, the EMA-Exact and EMA-Constant variants are comparable to Knoll’s algorithm at 20% and 10% dither strengths, respectively. At least that’s the case in the Chrono Cross screenshot used as an example in Yliluoma’s 2011 article.

Below is a 16-color comparison with the fixed palette used in the article. I used N=32 candidate iterations, so Yliluoma-2 was left out of this as its C++ code does only N=16. See the supplement for a direct N=16 comparison against Yliluoma-2.

Conclusion

Yliluoma’s second color selection algorithm does not need a perceptual color difference function to work. The algorithm calculates repeated weighted averages, and the chosen color candidate set’s quality is determined by the mixing factor t along with the number of candidates N. The optimal value for t can be solved exactly when using an Euclidean color difference formula. A suitable N for a K-color image seems to be around N=2K.

The simplified “EMA” variants yield comparable quality to the original despite being faster. While these variants occasionally overtake Knoll’s algorithm in quality, this isn’t the case most of the time. Also, the variants inherit their predecessor’s structure and thus have to test all K palette colors on each candidate search iteration. Knoll’s algorithm, on the other hand, can use a faster O(\log K) closest point lookup.

For cases when only light dithering is needed, the EMA-Constant variant presents a new, simple alternative. But since it’s still slower than Knoll’s algorithm, it’s difficult to recommend it.

Material

The article supplement includes more result images and an artificial 2D comparison at the end.

A standalone script that implements the Knoll, EMA-Sweep, EMA-Exact, EMA-Constant, and Offset ordered dithers with a 4x4 threshold matrix:

Usage: uv run color_selection.py bigbuckbunny_bird.png 16 32 --algo=ema-exact

My doctored C++ version of the original Yliluoma-2 algorithm:

This article is based on research I did for a book I’m writing. It’s about color reduction algorithms. Sign up here if you’re interested.

Further reading

联系我们 contact @ memedata.com