A blog for developers programming with Autodesk platforms, particularly AutoCAD and Forge. With a special focus on AR/VR and IoT.

February 01, 2009

Importing and pixelizing images inside AutoCAD using F#

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

letrec 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

letrec 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

letrec 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

letmutable (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

letmutable 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... :-)