19

Summary

When repeatedly drawing anything (apparently with a low alpha value) on a canvas, regardless of if it's with drawImage() or a fill function, the resulting colors are significantly inaccurate in all browsers that I've tested. Here's a sample of the results I'm getting with a particular blend operation:

Invalid blending results

Problem Demonstration

For an example and some code to play with, check out this jsFiddle I worked up:

http://jsfiddle.net/jMjFh/2/

The top set of data is a result of a test that you can customize by editing iters and rgba at the top of the JS code. It takes whatever color your specify, RGBA all in range [0, 255], and stamps it on a completely clean, transparent canvas iters number of times. At the same time, it keeps a running calculation of what the standard Porter-Duff source-over-dest blend function would produce for the same procedure (ie. what the browsers should be running and coming up with).

The bottom set of data presents a boundary case that I found where blending [127, 0, 0, 2] on top of [127, 0, 0, 63] produces an inaccurate result. Interestingly, blending [127, 0, 0, 2] on top of [127, 0, 0, 62] and all [127, 0, 0, x] colors where x <= 62 produces the expected result. Note that this boundary case is only valid in Firefox and IE on Windows. In every other browser on every other operating system I've tested, the results are far worse.

In addition, if you run the test in Firefox on Windows and Chrome on Windows, you'll notice a significant difference in the blending results. Unfortunately we're not talking about one or two values off--it's far worse.

Background Information

I am aware of the fact that the HTML5 canvas spec points out that doing a drawImage() or putImageData() and then a getImageData() call can show slightly varying results due to rounding errors. In that case, and based on everything I've run into thus far, we're talking about something miniscule like the red channel being 122 instead of 121.

As a bit of background material, I asked this question a while ago where @NathanOstgard did some excellent research and drew the conclusion that alpha premultiplication was to blame. While that certainly could be at work here, I think the real underlying issue is something a bit larger.

The Question

Does anyone have any clue how I could possibly end up with the (wildly inaccurate) color values I'm seeing? Furthermore, and more importantly, does anyone have any ideas how to circumvent the issue and produce consistent results in all browsers? Manually blending the pixels is not an option due to performance reasons.

Thanks!


Edit: I just added a stack trace of sorts that outputs each intermediate color value and the expected value for that stage in the process.


Edit: After researching this a bit, I think I've made some slight progress.

Reasoning for Chrome14 on Win7

In Chrome14 on Win7 the resulting color after the blend operation is gray, which is many values off from the expected and desired reddish result. This led me to believe that Chrome doesn't have an issue with rounding and precision because the difference is so large. Instead, it seems like Chrome stores all pixel values with premultiplied alpha.

This idea can be demonstrated by drawing a single color to the canvas. In Chrome, if you apply the color [127, 0, 0, 2] once to the canvas, you get [127, 0, 0, 2] when you read it back. However, if you apply [127, 0, 0, 2] to the canvas twice, Chrome gives you [85, 0, 0, 3] if you check the resulting color. This actually makes some sense when you consider that the premultiplied equivalent of [127, 0, 0, 2] is [1, 0, 0, 2].

Apparently, when Chrome14 on Win7 performs the blending operation, it references the premultiplied color value [1, 0, 0, 2] for the dest component and the non-premultiplied color value [127, 0, 0, 2] for the source component. When these two colors are blended together, we end up with [85, 0, 0, 3] using the Porter-Duff source-over-dest approach, as expected.

So, it seems like Chrome14 on Win7 inconsistently references the pixel values when you work with them. The information is stored in a premultiplied state internally, is presented back to you in non-premultiplied form, and is manipulated using values of both forms.

I'm thinking it might be possible to circumvent this by doing some additional calls to getImageData() and/or putImageData(), but the performance implications don't seem that great. Furthermore, this doesn't seem to be the same issue that Firefox7 on Win7 is exhibiting, so some more research will have to be done on that side of things.


Edit: Below is one possible approach that seems likely to work.

Thoughts on a Solution

I haven't gotten back around to working on this yet, but one WebGL approach that I came up with recently was to run all drawing operations through a simple pixel shader that performs the Porter-Duff source-over-dest blend itself. It seems as though the issue here only occurs when interpolating values for blending purposes. So, if the shader reads the two pixel values, calculates the result, and writes (not blends) the value into the destination, it should mitigate the problem. At the very least, it shouldn't compound. There will definitely still be minor rounding inaccuracies, though.

I know in my initial question I mentioned that manually blending the pixels wouldn't be viable. For a 2D canvas implementation of an application that needs real-time feedback, I don't see a way to fix this. All of the blending would be done on the CPU and would prevent other JS code from executing. With WebGL, however, your pixel shader runs on the GPU so I'm pretty sure there shouldn't be any performance hit.

The tricky part is being able to feed the dest canvas in as a texture to the shader because you also need to render it on a canvas to be viewable. Ultimately, you want to avoid having to generate a WebGL texture object from your viewable canvas every time it needs to be updated. Maintaining two copies of the data, with one as an in-memory texture and one as a viewable canvas (that's updated by stamping the texture on as needed), should solve this.

Community
  • 1
  • 1
Xenethyl
  • 3,179
  • 21
  • 31
  • I notice in your jsfiddle that you blend a color with itself 32 times. I would be very curious to see the actual value after blending after *each* of these steps. It may suggest whether it's a rounding issue, or something else entirely. Also, note that doing something 32 times means that you are compounding rounding errors 32 times, so you can get pretty far from expected (though I have to admit, 7 when 127 is expected is rather drastic) – Matt Oct 07 '11 at 22:25
  • @Matt Good idea, for sure. I just edited the jsfiddle per your suggestion. I don't quite see a direct relation to a build up of rounding errors after looking over the data. Especially, as you mentioned, given the difference between 7 and 127--that's just perplexing. – Xenethyl Oct 07 '11 at 23:17
  • I could be wrong here, but don't alpha values range from 0-1 in this case, or am I confusing CSS with canvases? – Bojangles Oct 07 '11 at 23:20
  • @JamWaffles Alpha values are in the range [0.0, 1.0] for CSS declarations, but when you work with canvas pixel data the values are in the range [0, 255]. I'm (as far as I can tell) converting as necessary in this example. :) – Xenethyl Oct 07 '11 at 23:26
  • Each of the values for `R`, `G`, `B` should be integers from 0 to 127, no? Aren't you feeding in values that are not integers? http://www.w3.org/TR/css3-color/#rgba-color – artlung Oct 07 '11 at 23:33
  • @artlung No, the range for RGB is [0, 255] and A is a decimal in the range [0.0, 1.0] for CSS declarations. Canvas objects store and work with pixel data in the range [0, 255] for all channels (alpha included). The only non-integer value that I'm passing to anything (aside from my blend function, which doesn't impact the rendering) is the alpha in the `fillStyle`, which is valid. – Xenethyl Oct 07 '11 at 23:52
  • Gah. I of course meant 255. It looks like an IP address octet to me today. – artlung Oct 07 '11 at 23:57
  • @artlung Hah, I wish the issue was with IP routing. I feel like that would be easier to debug.. :P – Xenethyl Oct 07 '11 at 23:59
  • I find it interesting how different the series is if you are blending 127,0,0,1 with itself vs 127,0,0,2 with itself. I find it very odd how 127,0,0,1 blending does basically nothing for the entire series. It would seem the base case of 127,0,0,1 over 0,0,0,0 would give 127,0,0,1, and the fact that even that basic overlay doesn't work is ... odd – Matt Oct 08 '11 at 00:54
  • @Xenethyl did you find a way to circumvent this? – Ross Jul 12 '13 at 17:21
  • 1
    @Ross I haven't gotten around to playing with this again, but I've updated the question at the bottom with some thoughts. I might pick it up this weekend since you have me thinking about it now. :) – Xenethyl Jul 12 '13 at 23:26
  • @Xenethyl check this out. With Chrome (v28.0.1500.71) am applying a *brush* (left) repeatedly and here is the result: http://cl.ly/image/2W1o162R210Q. On a canvas with 500x500 (centre) the blend goes white at low opacities. On the smaller (right) canvas it doesn't. Possibly a different engine underneath, unless I missing something. Here is the fiddle: http://jsfiddle.net/rossc1/uexPp/. Stumped! :) – Ross Jul 13 '13 at 17:24
  • @Xenethyl yip, the difference kicks in at 256x256+ pixels plus on Chromium. – Ross Jul 13 '13 at 17:32
  • @Ross That's pretty bizarre. I've sort of given up trying to get 2D canvas blending working properly in Chrome at the moment. In black and white you get halos, and with colors it ends up looking completely wrong. The results are just too far off to be rounding problems. A WebGL approach with fragment shaders might be the only viable solution. – Xenethyl Jul 21 '13 at 18:28

2 Answers2

3

Oh geez. I think we just stepped into the twilight zone here. I'm afraid I don't have an answer but I can provide some more information at least. Your hunch is right that at least something is larger here.

I'd prefer an example that is drastically more simple (and thus less prone to any sort of error - I fear you might have a problem with 0-255 somewhere instead of 0-1 but didnt look thoroughly) than yours so I whipped up something tiny. What I found wasn't pleasant:

<canvas id="canvas1" width="128" height="128"></canvas>
<canvas id="canvas2" width="127" height="127"></canvas>

+

var can = document.getElementById('canvas1');
var ctx = can.getContext('2d');
var can2 = document.getElementById('canvas2');
var ctx2 = can2.getContext('2d');

var alpha = 2/255.0;

ctx.fillStyle = 'rgba(127, 0, 0, ' + alpha + ')';
ctx2.fillStyle = 'rgba(127, 0, 0, ' + alpha + ')'; // this is grey
//ctx2.fillStyle = 'rgba(171, 0, 0, ' + alpha + ')'; // this works

for (var i = 0; i < 32; i++) {
      ctx.fillRect(0,0,500,500);
      ctx2.fillRect(0,0,500,500);
}

Yields this:

enter image description here

See for yourself here:

http://jsfiddle.net/jjAMX/

It seems that if your surface area is too small (not the fillRect area, but the canvas area itself) then the drawing operation will be a wash.

It seems to work fine in IE9 and FF7. I submitted it as a bug to Chromium. (We'll see what they say, but their typical M.O. so far is to ignore most bug reports unless they are security issues).

I'd urge you to check your example - I think you might have a 0-255 to 0-1 issue somewhere. The code I have here seems to work just dandy, save for maybe the expected reddish color, but it is a consistent resultant color across all browsers with the exception of this Chrome issue.

In the end Chrome is probably doing something to optimize canvases with a surface area smaller than 128x128 that is fouling things up.

Simon Sarris
  • 62,212
  • 13
  • 141
  • 171
  • Thanks, Simon. I'm not sure why you are getting the results you've shown here. Here's what I get in FF7, Chrome14, and IE9: http://i.imgur.com/vOXbz.png. Also, changing the resolution of the canvas in Chrome had no effect for me. I don't think this is a resolution issue, especially since you can change the canvas size in my example to whatever you want and still end up with the same results. – Xenethyl Oct 08 '11 at 20:30
  • Oh sorry, I'm running Chrome 16. – Simon Sarris Oct 08 '11 at 21:19
  • Ah, that would probably explain it. Sounds like you may have identified a bug in their upcoming release, then. I don't think it's the same issue that I'm dealing with here. My problem is specifically with blending colors--if I put a color on the canvas a single time it's typically about what I would expect (within rounding tolerances). – Xenethyl Oct 09 '11 at 00:49
  • I modified your example by setting the background to white and got a really weird behavior: http://jsfiddle.net/jjAMX/6/ In Chrome 14, the canvas actually starts out opaque and becomes more transparent as the filling is applied. In Firefox 7 it is grey and in IE9 it behaves as expected. In your original example, only IE9 behaved correctly for me. – Brian Nickel Oct 15 '11 at 17:50
0

Setting globalCompositeOperation to "lighter" (the operation where overlapping pixels values are added together, it is not the default operation for many browsers) yields same color and results in ie9, chrome 14 and latest firefox aurora:

Expected: [126.99999999999999, 0, 0, 63.51372549019608]
Result: [127, 0, 0, 64]

Expected: [126.99999999999999, 0, 0, 63.51372549019608]
Result: [127, 0, 0, 64]

http://jsfiddle.net/jMjFh/11/

edit: you may learn what the different composite operations mean here:

https://developer.mozilla.org/en/Canvas_tutorial/Compositing#globalCompositeOperation

for example, "copy" was useful to me when I needed to do fade animation in certain parts of a canvas

Esailija
  • 138,174
  • 23
  • 272
  • 326
  • Thanks, but unfortunately this doesn't solve the problem. If you change the starting color to `[127, 0, 0, 20]` and leave everything else the same, you'll notice that the red channel eventually becomes `255`. That's expected with the `lighter` blending mode, because of the nature of additive blending. With `source-over-dest` blending (ie. `source-over` aka the default for HTML5), we would expect different values per my example. – Xenethyl Oct 17 '11 at 17:39
  • Oh well, should have tested it better before posting :) – Esailija Oct 17 '11 at 17:40
  • At first glance this does seem like a solution. Unfortunately it only gives comparable results to `source-over` in limited cases. I'm starting to think that the `source-over` blend mode is broken... :( – Xenethyl Oct 17 '11 at 17:45
  • I actually tested with 50 different alpha values and 2 is the only one that gives the expected result.. is that value somehow special? – Esailija Oct 17 '11 at 17:52
  • 1
    The value 2 isn't particularly special, it just takes longer for the difference to show up in your example. If you change `iters` to 300, the red channel will eventually reach `255` here as well. – Xenethyl Oct 17 '11 at 18:05