Manipulating Canvas Pixels with Haskell and Haste
Sometimes you just want to set pixels, even in a browser. If you don’t
need the sophisticated features of something like WebGL, there’s
much wider browser support for the HTML5 canvas element’s
getImageData and putImageData.
Unfortunately, the API is conceptually low-level and a bit awkward.
Probably because of that, it’s not widely used and isn’t exposed by many
of the systems that wrap JavaScript in another language. The Haste
Haskell-to-JavaScript compiler, for example, doesn’t come with a wrapper
providing easy use of putImageData. It does come with a foreign
function interface and an API for extending the wrapper, so here’s
a little extension I wrote.
Working with ImageData
All I really want to do is get the ImageData, change it, and put it
back. Haste’s withContext is a backdoor for extending the canvas API
in just that sort of way by giving low-level access to the canvas
context.
fillPixels :: (Fold.Foldable container) =>
Int -> Int -> container Graphics.Color -> Graphics.Picture ()
fillPixels width height pixelColors =
Graphics.withContext $ \ctx -> do
imageData <- getImageData ctx 0 0 width height
updateImage imageData $ pixelColors
putImageData ctx imageData 0 0 0 0 width heightThe fillPixels function expects a foldable container, which is a
one-dimensional interface, rather than two dimensional. Haskell has
plenty of two dimensional containers, but they don’t share a common
typeclass, so I’d have to choose one to force on the caller. As you’ll
see in a moment, the ImageData itself is natively one dimensional, so
I decided to just go with the flow.
Manipulating the Data
The interesting part of this is altering the image data in between
getting and putting it. What you get from getImageData (and put with
putImageData) is a one dimensional array-like object of bytes
representing color components: the first element is the amount of red in
the upper left pixel, while the second element is the amount of green in
that same pixel. Only when you get to the fifth element are you talking
about the next pixel to the right of the first. (The fourth element is
the alpha transparency of the first pixel.)
updateImagePixel :: ImageData -> Int -> Graphics.Color -> IO ()
updateImagePixel imageData index (Graphics.RGB r g b) =
updateImagePixel imageData index (Graphics.RGBA r g b 1.0)
updateImagePixel imageData index (Graphics.RGBA r g b a) = do
jsUpdateImageComponent imageData (rawIndex+0) r
jsUpdateImageComponent imageData (rawIndex+1) g
jsUpdateImageComponent imageData (rawIndex+2) b
-- Unlike everywhere else, alpha is a byte here
jsUpdateImageComponent imageData (rawIndex+3) $ round (a * 255)
where
rawIndex = index * 4
jsUpdateImageComponent :: ImageData -> Int -> Int -> IO ()
jsUpdateImageComponent = FFI.ffi "(function(d,i,v){d.data[i]=v;})"The above code is a low-level helper that translates between indexes that refer to pixels and “raw” indexes that refer to color components. It’s just a four-to-one correspondence, nothing fancy.
To assign all the pixels at once, we take a foldable container of colors
and fold over it with foldlM. The basic idea is that the mondadic
fold lets you carry over an accumulated monad as well as the regular
fold accumulator, so the index is the accumulator, and the update
actions go in the IO monad.
updateImage :: (Fold.Foldable f) => ImageData -> f Graphics.Color -> IO (Int)
updateImage imageData colors = Fold.foldlM visitPixel 0 colors
where
visitPixel index color = do
updateImagePixel imageData index color
return (index + 1)Getting From and Putting To the Canvas
All of that depends on some straightforward Haskell equivalents for the
JavaScript getImageData and putImageData methods, defined using the
foreign function interface.
newtype ImageData = ImageData Haste.JSAny
deriving (FFI.ToAny, FFI.FromAny)
getImageData = jsGetImageData
putImageData = jsPutImageData
jsGetImageData :: Graphics.Ctx -> Int -> Int -> Int -> Int -> IO ImageData
jsGetImageData = FFI.ffi
"(function(c, x, y, w, h){return c.getImageData(x, y, w, h);})"
jsPutImageData :: Graphics.Ctx -> ImageData -> Int -> Int -> Int ->
Int -> Int -> Int -> IO ()
jsPutImageData = FFI.ffi
"(function(c, d, x, y, dx, dy, dw, dh){return c.putImageData(d, x, y, dx, dy, dw, dh);})"The getImageData = jsGetImageData pattern is just a convention from
inside Haste itself. As you can see, the FFI takes a pretty simple
approach: inline JavaScript attached to Haskell type signatures.
Demo Code
If all this code works in your browser1 it should fill the
the canvas at the top of this post with randomly generated
image data on each page load. Although it shouldn’t be too hard to
define an order for folding on a genuinely two-dimensional structure,
I’ve taken advantage of the fact that I want something random to just
generate an infinite list of pixel colors and take what I need.
main = do
Just canvas <- Graphics.getCanvasById "canvas"
(width, height) <- normalizedSize canvas
colors <- selectColors
arrangement <- assignColors colors
Graphics.render canvas $ fillPixels width height $
take (width * height) $ arrangementRandom Generation
Aside from setting the dimensions, the main function is
built around two helper functions, selectColors and assignColors,
that both use randomness. The foreground and background colors are
selected randomly, and then the pixels are randomly assigned to be
foreground or background pixels. The assignment is weighted in favor of
the background color, with the fixed weighting being randomly chosen
before the assignment begins.2
selectColors :: IO (Graphics.Color, Graphics.Color)
selectColors = do
seed <- App.newSeed
return (pick foreChoices seed, pick backChoices $ App.next seed)
where
pick choices seed =
choices !! (fst $ App.randomR (0, (length choices)-1) seed)assignColors :: (Graphics.Color, Graphics.Color) -> IO [Graphics.Color]
assignColors (foreground, background) = do
seed <- App.newSeed
backgroundWeight <- return $ fst $ App.randomR (2, 30) seed
seed <- App.newSeed
return $ map zeroToForeground $
App.randomRs (0, backgroundWeight) seed
where
zeroToForeground :: Int -> Graphics.Color
zeroToForeground randomInt =
if randomInt == 0
then foreground
else backgroundBoth of those functions use the random helpers in Haste.App, which
have been completely replaced in the upcoming release.3
It’s good that those functions are being removed, because they contain a very annoying bug. The documentation clearly says that the range is inclusive below and exclusive above, but the implementation is inclusive at both ends.
You can check this by passing integer bounds of zero and one. The half-open interval should give you all zeros, but the fully closed interval gives you a mix of ones and zeros.
bugInHaste = do
seed <- App.newSeed
print $ take 100 $ App.randomRs (0::Int, 1::Int) seedDimensions
Canvases have two sets of dimensions: one width and height for the space they actually take up, and one “logical” width and height for all the drawing functions. They have the same defaults, and some ways of setting the width and height set both at once (but others don’t). Any difference between the actual and logical dimensions is bridged by automatically scaling, which can make the whole image look distorted and blurry.
I’m sure there’s a good reason for that, but I like to ignore it by resetting the logical dimensions to match the actual dimensions, then passing them as parameters to the rest of the code:
normalizedSize canvas = do
DOM.getProp canvas "clientWidth" >>= DOM.setAttr canvas "width"
DOM.getProp canvas "clientHeight" >>= DOM.setAttr canvas "height"
width <- DOM.getAttr canvas "width" >>= asInt
height <- DOM.getAttr canvas "height" >>= asInt
return (width, height)
where
asInt :: String -> IO(Int)
asInt = return . fromIntegral . toInteger . readColors
The foreground colors are from the XKCD Color Survey, which is very cool and useful. These are the top five colors, skipping pink, which was a bit too faint compared to the others.
foreChoices =
[ Graphics.RGB 0x7e 0x1e 0x9c -- purple
, Graphics.RGB 0x15 0xb0 0x1a -- green
, Graphics.RGB 0x03 0x43 0xdf -- blue
, Graphics.RGB 0x65 0x37 0x00 -- brown
, Graphics.RGB 0xe5 0x00 0x00 -- red
]I was all set to do something similar for the background colors, but it actually ended up looking best with a fully transparent background, so the background colors are a list of one.
backChoices =
[ Graphics.RGBA 255 255 255 0.0
]Thoughts on Haste and Haskell
I’m still undecided on Haskell and the ecosystem around it. There are some powerful tools here that have been used to create great software, but it also feels like there’s been a failure to learn from certain historical sources.
For example, integration is a major pain point in software. Libraries should ease that pain by producing and consuming data in general formats defined in the core of the language and commonly used in non-library code, like lists, maps, or structs. Third-party Haskell libraries fall down pretty hard in that area. They bristle with custom data types, trying to have type signatures so “rich” they can replace documentation, and often ignoring types that represent essentially the same thing a different way in the standard libraries.
I realize the type system is powerful and Haskellers are justly proud of it, but there’s a time and place for everything. APIs are not the place for type signatures complex enough to replace English sentences.
Try to use several third-party Haskell libraries in the same or related domains – a random number generator, a stats package, and a simulation package, for example – and you’ll discover that just schlepping numbers around can be more painful than you ever imagined. It’s pain that has nothing to do with type theory, and everything to do with type practice.