A friend and esteemed colleague asked - very validly - why I decided to use circles on a grid to display the results of a mathematical function in this last post, rather than using a linear object of some kind. Well I did, in fact, have a plan in mind... :-)
This post extends the concept, introduced in that post, of displaying data in a grid of solid-hatched circles. This post focuses on importing a bitmap image from a file, pixelizing the contents and using the "averaged" pixel colours to modify our grid. The idea actually came to me during an R.E.M. concert I attended at Paléo, a Swiss music festival, last summer. The real-time manipulation performed on the live video feed of the band - and especially of Michael Stipe, the lead singer - which was then projected onto screens adjacent to the stage was really, really cool. They managed to pixelize and manipulate the colours in a way that I found incredible - it was like seeing real-time graphic design at work. I understand that much of the work is done in advance, but even so I found it very impressive. Some of you regular concert-goers may find what I've just described to be pretty run-of-the-mill, but I fully admit that these days - what with one thing and another - I don't get out much. :-)
The pixelization approach I decided to take was to read in square chunks of the bitmap image and then average out the RGB values for all the pixels in each square. These average values are then used to colour the circles representing the "pixels" for their respective squares. You'll notice some inadvertent cropping of the imported image, which happens because we ask for the width in terms of our circular pixels and then sample the bitmap in chunks that have a whole number of pixels on each side: if the picture's width is not exactly divisible by the width entered there will be a little cropping.
Why did I choose F# for this rather than C#? The image processing domain in general is a strong fit for functional programming techniques. And while I haven't yet taken the step in this post, certain parts of the below code are inherently parallelizable, especially the operations related to averaging of pixel colours. The reading of the bitmap itself might prove more difficult, as it uses "unsafe" direct memory access (via the NativePtr class), but it's by no means impossible to parallelize, at least in theory.
Anyway, here's the F# code I put together:
// Use lightweight F# syntax
#light
// Declare a specific namespace and module name
module Pixelizor.Commands
// Import managed assemblies
#nowarn "9" // ... because we're using NativePtr
open Autodesk.AutoCAD.Runtime
open Autodesk.AutoCAD.ApplicationServices
open Autodesk.AutoCAD.DatabaseServices
open Autodesk.AutoCAD.EditorInput
open Autodesk.AutoCAD.Geometry
open Autodesk.AutoCAD.Colors
open System.Drawing.Imaging
open Microsoft.FSharp.NativeInterop
// Declare our command
[<CommandMethod("pix")>]
let pixelate() =
// Let's get the usual helpful AutoCAD objects
let doc =
Application.DocumentManager.MdiActiveDocument
let ed = doc.Editor
let db = doc.Database
// "use" has the same effect as "using" in C#
use tr =
db.TransactionManager.StartTransaction()
// Get appropriately-typed BlockTable and BTRs
let bt =
tr.GetObject
(db.BlockTableId,OpenMode.ForRead)
:?> BlockTable
let ms =
tr.GetObject
(bt.[BlockTableRecord.ModelSpace],
OpenMode.ForWrite)
:?> BlockTableRecord
// Function to create a filled circle (hatch) at a
// specific location
// Note the valid use of tr and ms, as they are in scope
let createCircle pt rad =
let hat = new Hatch()
hat.SetDatabaseDefaults()
hat.SetHatchPattern
(HatchPatternType.PreDefined,
"SOLID")
let id = ms.AppendEntity(hat)
tr.AddNewlyCreatedDBObject(hat, true)
// Now we create the loop, which we make db-resident
// (appending a transient loop caused problems, so
// we're going to use the circle and then erase it)
let cir = new Circle()
cir.Radius <- rad
cir.Center <- pt
let lid = ms.AppendEntity(cir)
tr.AddNewlyCreatedDBObject(cir, true)
// Have the hatch use the loop we created
let loops = new ObjectIdCollection()
loops.Add(lid) |> ignore
hat.AppendLoop(HatchLoopTypes.Default, loops)
hat.EvaluateHatch(true)
// Now we erase the loop
cir.Erase()
id
// Function to create our grid of circles
let createGrid xsize ysize rad offset =
let ids = new ObjectIdCollection()
for i = 0 to xsize - 1 do
for j = 0 to ysize - 1 do
let pt =
new Point3d
(offset * (Int32.to_float i),
offset * (Int32.to_float j),
0.0)
let id = createCircle pt rad
ids.Add(id) |> ignore
ids
// Function to change the colour of an entity
let changeColour (col : Color) (id : ObjectId) =
if id.IsValid then
let ent =
tr.GetObject(id, OpenMode.ForWrite) :?> Entity
ent.Color <- col
// Add up the RGB values of a list of pixels
// We use a recursive function with an accumulator argument,
// (rt, gt, bt), to allow tail call optimization
let rec sumColors (pix : List<(byte*byte*byte)>) (rt,gt,bt) =
match pix with
| [] -> (rt, gt, bt)
| (r, g, b) :: tl ->
sumColors tl
(rt + Byte.to_int r,
gt + Byte.to_int g,
bt + Byte.to_int b)
// Average out the RGB values of a list of pixels
let getAverageColour (pixels : List<(byte*byte*byte)>) =
let (rsum, gsum, bsum) =
sumColors pixels (0, 0, 0)
let count = pixels.Length
let ravg = Byte.of_int (rsum / count)
let gavg = Byte.of_int (gsum / count)
let bavg = Byte.of_int (bsum / count)
// For some reason the pixel needs ro be reversed - probably
// because of the bitmap format (needs investigation)
Color.FromRgb(bavg, gavg, ravg)
// Get a chunk of pixels to average from one row
// We use a recursive function with an accumulator argument
// to allow tail call optimization
let rec getChunkRowPixels p xsamp acc =
if xsamp = 0 then
acc
else
let pix =
[(NativePtr.get p 0,
NativePtr.get p 1,
NativePtr.get p 2)]
let p = NativePtr.add p 3
getChunkRowPixels p (xsamp-1) (pix @ acc)
// Get a chunk of pixels to average from multiple rows
// We use a recursive function with an accumulator argument
// to allow tail call optimization
let rec getChunkPixels p stride xsamp ysamp acc =
if ysamp = 0 then
acc
else
let pix = getChunkRowPixels p xsamp []
let p = NativePtr.add p stride
getChunkPixels p stride xsamp (ysamp-1) (pix @ acc)
// Get the various chunks of pixels to average across
// a complete bitmap image
let pixelateBitmap (image:System.Drawing.Bitmap) xsize ysize =
// Create a 2-dimensional array of pixel lists (one list,
// which then needs averaging, per final pixel)
let (arr2 : List<(byte*byte*byte)>[,]) =
Array2.create xsize ysize []
// Lock the entire memory block related to our image
let bd =
image.LockBits
(System.Drawing.Rectangle
(0, 0, image.Width ,image.Height),
ImageLockMode.ReadOnly, image.PixelFormat)
// Establish the number of pixels to sample per chunk
// in each of the x and y directions
let xsamp = image.Width / xsize
let ysamp = image.Height / ysize
// We have a mutable pointer to step through the image
let mutable (p:nativeptr<byte>) =
NativePtr.of_nativeint (bd.Scan0)
// Loop through the various chunks
for i = 0 to ysize - 1 do
// We take a copy of the current value of p, as we
// don't want to mutate p while extracting the pixels
// within a row
let mutable xp = p
for j = 0 to xsize - 1 do
// Get the square chunk of pixels starting at
// this x,y position
let chk =
getChunkPixels xp bd.Stride xsamp ysamp []
// Add it into our array
arr2.[j,ysize-1-i] <- chk
// Mutate the pointer to move along to the right
// by a value of 3 (our RGB value) times the
// number of pixels we're sampling in x
xp <- NativePtr.add xp (xsamp * 3)
done
// Mutate the original p pointer to move on one row
p <- NativePtr.add p (bd.Stride * ysamp)
done
// Finally unlock the bitmap data and return the array
image.UnlockBits(bd)
arr2
// Prompt the user for the file and the width of the image
let pofo =
new PromptOpenFileOptions
("Select an image to import and pixelate")
pofo.Filter <-
"Jpeg Image (*.jpg)|*.jpg|All files (*.*)|*.*"
let pfnr = ed.GetFileNameForOpen(pofo)
let file =
match pfnr.Status with
| PromptStatus.OK ->
pfnr.StringResult
| _ ->
""
if System.IO.File.Exists(file) then
let img = System.Drawing.Image.FromFile(file)
let pio =
new PromptIntegerOptions
("\nEnter number of horizontal pixels: " )
pio.AllowNone <- true
pio.UseDefaultValue <- true
pio.LowerLimit <- 1
pio.UpperLimit <- img.Width
pio.DefaultValue <- 100
let pir = ed.GetInteger(pio)
let xsize =
match pir.Status with
| PromptStatus.None ->
img.Width
| PromptStatus.OK ->
pir.Value
| _ -> -1
if xsize > 0 then
// Calculate the vertical size from the horizontal
let ysize = img.Height * xsize / img.Width
if ysize > 0 then
// Create our basic grid
let ids = createGrid xsize ysize 0.5 1.2
// Some helper functions using values we've just set...
// From a certain index in the list, get an object ID
let getId i =
if i >= 0 then
ids.[i]
else
ObjectId.Null
// From a certain x and y in the grid, get an object ID
let getId x y =
getId ((x * ysize) + y)
// Cast our image to a bitmap and then
// get the chunked pixels
let bmp = img :?> System.Drawing.Bitmap
let arr = pixelateBitmap bmp xsize ysize
// Loop through the pixel list and average them out
// (which could be parallelized), using the results
// to change the colour of the circles in our grid
for x = 0 to xsize - 1 do
for y = 0 to ysize - 1 do
let lst = arr.[x,y]
let col = getAverageColour lst
let id = getId x y
changeColour col id
done
done
// Commit the transaction
tr.Commit()
Here are the results of running it and choosing a photo I took yesterday during my visit to Kodaikanal in Tamil Nadu (India's most southern state).
First the original image:
Here's what happens when we run the PIX command, selecting the above image and choosing 20 pixels in width:
Now with a width of 50...
And finally with a width of 100...
Give it a try yourself, pixelizing different images at different resolutions - you can get some very cool results.
This one is too fun to just let rest... I'm going to see if I get some time this week to work on the parallelization of the colour averaging operation, to see if that improves performance (even if it doesn't today, it will do eventually when I either get a 64-core machine or move some of the code to be hosted in the cloud... :-)