Featured Content
Cool Pic of the Moment
Extras


Wednesday, March 10, 2010

VB.Net project: Scanning Photos

Like most people, I have a collection of prints of 35mm snapshots that I have taken over the years using a plain point-and-shoot camera. I've been using digital cameras since about 2004, so these prints are older than that - some go back to the early 90's even. Recently, I decided to try and scan these prints so I would have a digital archive of these pictures. The ones from the early 90's are starting to fade, so scanning them now before the faded even more seemed smart.

I have an HP 1350 all-in-one printer and scanner and used it for my test scans. After a few trials, it became apparent that the scans were not going to be high quality. 300 dpm provided about the best quality image you could expect to get. This is true for just about any machine that scans 35mm prints - scanning the negatives with the right equipment is the way to go. But I only have a few of the negatives, so for me this wasn't an option, and the scans were at least tolerable. Plus, many of these pictures weren't that great anyway since I am not a good photographer. Lastly, the HP software and another program I have (Paint Shop Photo Album) only let you scan one picture at a time. This was going to be very slow and tedious to grind through hundreds of old prints. However, the scanner bed on the 1350 measures 8.5" by 11.5" which is big enough to fit five 3x5 photos at once. If I could scan five at a time and somehow automate cropping out each picture individually, I could greatly speed up the process.

So I decided to do a little research into rolling my own VB code for scanning and image processing. As it turns out, there is a small and very useful library called EZTwain Classic that is easy to integrate into a VB.NET program. It is essentially a .COM wrapper around the built-in TWAIN functionality in Windows. I tinkered with it and was able to fairly quickly come up with a simple app that has the following functions:

  • Scan up to five 3x5 prints at once into a single large BMP file
  • Parse the large BMP file into five individual bitmaps
  • Provide some basic image editing functions like rotate, crop, median filter (especially important for scanned images)
  • Automatic date/filename generation

Code - Main Form

Here is a summary of the key sequences of code to get EZTwain to work. The source code for the full program is available at the link at the end of this article.

First, create a new VB project and copy the EZTWAIN.VB file from the EZTwain .zip download into the same directory as your source code and using the solution explorer, add it as an existing item to your project. Next, copy the EZTW32.dll from the same .zip into the bin/debug and bin/release directories of your project.

NOTE: There is apparently a bug in the latest version (1.15) of EZTWAIN.VB that prevents it from compiling! There are several problems with the DibToImage() function at the top of the file that apparently are broken code compatabilites. Just delete or comment out the entire DibToImage() function as it is unnecessary for this tutorial.

We need to allow the user to select the TWAIN source. There is a single function call - SelectImageSource() - that accomplishes this. In the sample app, there is a menu item for calling this function:

Private Sub SelectSourceToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles SelectSourceToolStripMenuItem.Click 
        EZTwain.SelectImageSource(Me.Handle) 
End Sub 

SelectImageSource() will prompt the user with a window like the one shown below. The available options will depend on the TWAIN sources available on your system. In my case, I use the one with the cryptic 1300 series name:

Select Source dialog box

Next, we want to capture a "raw" scan for the entire glass space on the scanner. The following code accomplishes that:

    Private Sub AcquireRawScanToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles AcquireRawScanToolStripMenuItem.Click
        EZTwain.SetHideUI(1) 
        EZTwain.LoadSourceManager() 
        EZTwain.OpenSourceManager(Me.Handle) 
        EZTwain.OpenDefaultSource() 
        EZTwain.SetCurrentResolution(300) 
        EZTwain.EnableSource(Me.Handle) 
 
        If My.Computer.FileSystem.FileExists(g_rootSavePath & "scans\temp_scan.bmp") Then 
            My.Computer.FileSystem.DeleteFile(g_rootSavePath & "scans\temp_scan.bmp") 
        End If 
        EZTwain.AcquireToFilename(0, g_rootSavePath & "scans\temp_scan.bmp") 
        If Not My.Computer.FileSystem.FileExists(g_rootSavePath & "scans\temp_scan.bmp") Then 
            Return 
        End If 
        Dim bmp As Bitmap = Bitmap.FromFile(g_rootSavePath & "scans\temp_scan.bmp") 
 
        EZTwain.DisableSource() 
        EZTwain.CloseSource() 
        EZTwain.CloseSourceManager(Me.Handle) 
        EZTwain.UnloadSourceManager() 
        Me.ShowRawScanForm(bmp)
    End Sub 

Let's break down what we are doing. The first and last blocks of code are just the sequential steps needed to prep the source manager and activate the source for scanning. SetHideUI(1) requests the source manager to not display the user interface as we won't need it for the scan. SetCurrentResolution() sets the dpi for the scan. The middle block of code is scanning the entire glass space of the scanner to a file called "temp_scan.bmp". This file is about 2550x3500 and at 24-bit color it is a 25.5MB file. I delete this file if it exists before every scan as we only need it long enough for the next operation - parsing it into separate images. The variable g_rootSavePath is set elsewhere in the application to the path where you want to save your scans.

Once we have the raw scan, there is a very simple form that contains a picturebox control to which the scanned image is copied to by calling the ShowRawScanForm() method. This is just to give a quick preview to the user of what was scanned.

   Private Sub ShowRawScanForm(ByVal bmp As Bitmap)
        Dim rsf As RawScanForm = Nothing 
        For Each f As Form In Me.MdiChildren 
            If TypeOf f Is RawScanForm Then 
                rsf = DirectCast(f, RawScanForm) 
            End If 
        Next 
 
        If rsf Is Nothing Then 
            rsf = New RawScanForm 
            rsf.PictureBox1.Image = bmp 
            rsf.MdiParent = Me 
            rsf.Show() 
        Else 
            rsf.PictureBox1.Image = bmp 
            rsf.PictureBox1.Refresh() 
        End If 
    End Sub 

Now for the fun part - cropping the raw scan into five separate images. The main method for this is shown below:

    Private Sub ParseRawScanToolStripMenuItem_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles ParseRawScanToolStripMenuItem.Click 
        Dim rsf As RawScanForm = Nothing 
        For Each f As Form In Me.MdiChildren 
            If TypeOf f Is RawScanForm Then 
                rsf = DirectCast(f, RawScanForm) 
            End If 
        Next 
 
        If rsf Is Nothing Then 
            MsgBox("No raw scan open.", MsgBoxStyle.Exclamation) 
            Return 
        End If 
 
        Dim img As Image = rsf.PictureBox1.Image 
        Dim recs As List(Of Rectangle) = Me.FindEdges(img) 
        For Each r As Rectangle In recs 
            Dim ChildForm As New PreviewForm 
            ChildForm.MdiParent = Me 
            Dim bmpChunk As New Bitmap(r.Width, r.Height, Imaging.PixelFormat.Format24bppRgb) 
            Dim g As Graphics = Graphics.FromImage(bmpChunk) 
            g.DrawImage(img, New Rectangle(0, 0, r.Width, r.Height), r, GraphicsUnit.Pixel) 
            g.Dispose() 
 
            ChildForm.Size = New Size(921, 500) 
            ChildForm.PictureBox1.Image = bmpChunk.Clone 
            ChildForm.DateTimePicker1.Value = My.Globals.g_targetDate 
            ChildForm.PictureBox1.SizeMode = PictureBoxSizeMode.Zoom 
            ChildForm.PictureBox1.Dock = DockStyle.Fill 
            ChildForm.PictureBox1.Location = New Point(0, 0) 
            ChildForm.Show() 
            bmpChunk.Dispose() 
        Next 
    End Sub 

We select the image from the RawScanForm and pass it to the method FindEdges(). It returns a List(of Rectangle) which represents up to five images that were found in the raw scan. Finally, an individual bitmap is extracted from the raw scan file using each of the rectangles found by the FindEdges() method. Each individual bitmap is assigned to a new PreviewForm.

The workhorse methods here are FindEdges() and the four separate functions it calls: TraceRectangleRight, TraceRectangleLeft, TraceRectangleTop, and TraceRectangleBottom.

    Private Function FindEdges(ByVal bmp As Bitmap) As List(Of Rectangle) 
        Dim width As Integer = bmp.Width 
        Dim height As Integer = bmp.Height 
        Dim finished, isEmpty As Boolean 
        Dim centers(4) As Point 
        centers(0) = New Point(500, 700) 
        centers(1) = New Point(500, 2800) 
        centers(2) = New Point(1800, 500) 
        centers(3) = New Point(1800, 1700) 
        centers(4) = New Point(1800, 3000) 
        Dim stepSize As Integer = 5 
        Dim recs As New List(Of Rectangle) 
 
        For i As Integer = 0 To 4 
            Dim recBounding As New Rectangle(centers(i).X - 100, centers(i).Y - 100, 200, 200) 
            isEmpty = True 
            Do 
                finished = True 
                If Not Me.TraceRectangleRight(bmp, recBounding) Then 
                    If recBounding.Right + stepSize > bmp.Width - 1 Then 
                        recBounding.Width += (bmp.Width - recBounding.Right - 1) 
                    Else 
                        recBounding.Width += stepSize 
                        finished = False 
                        isEmpty = False 
                    End If 
                End If 
                If Not Me.TraceRectangleLeft(bmp, recBounding) Then 
                    If recBounding.X - stepSize < 0 Then 
                        recBounding.Width += recBounding.X 
                        recBounding.X = 0 
                    Else 
                        recBounding.Width += stepSize 
                        recBounding.X -= stepSize 
                        finished = False 
                        isEmpty = False 
                    End If 
                End If 
 
                If Not Me.TraceRectangleTop(bmp, recBounding) Then 
                    If recBounding.Y - stepSize < 0 Then 
                        recBounding.Height += recBounding.Y 
                        recBounding.Y = 0 
                    Else 
                        recBounding.Height += stepSize 
                        recBounding.Y -= stepSize 
                        finished = False 
                        isEmpty = False 
                    End If 
                End If 
                If Not Me.TraceRectangleBottom(bmp, recBounding) Then 
                    If recBounding.Height + stepSize > bmp.Height - 1 Then 
                        recBounding.Height += (bmp.Height - recBounding.Height - 1) 
                    Else 
                        recBounding.Height += stepSize 
                        finished = False 
                        isEmpty = False 
                    End If 
                End If 
            Loop Until finished 
            If Not isEmpty Then 
                recs.Add(recBounding) 
            End If 
        Next 
        Return recs 
    End Function 
 
    Public Function TraceRectangleRight(ByVal bmp As Bitmap, ByVal rec As Rectangle) As Boolean 
        'returns true if the right edge of the rectangle traces over all white pixels in the image 
        If rec.Right >= bmp.Width - 1 Then 
            Return True 
        End If 
 
        Dim lower, upper As Integer 
        lower = rec.Top 
        If lower < 0 Then 
            lower = 0 
        End If 
        upper = rec.Bottom 
        If upper > bmp.Height - 1 Then 
            upper = bmp.Height - 1 
        End If 
 
        Dim pixelCount As Integer 
        For i As Integer = lower To upper Step 5 
            If (bmp.GetPixel(rec.Right, i).R < 255 OrElse bmp.GetPixel(rec.Right, i).G < 255 OrElse bmp.GetPixel(rec.Right, i).B < 255) Then 
                'this is a colored pixel 
                pixelCount += 1 
                If pixelCount > 10 Then 
                    Return False 
                End If 
            End If 
        Next 
        Return True 
    End Function 
 
    Public Function TraceRectangleLeft(ByVal bmp As Bitmap, ByVal rec As Rectangle) As Boolean 
        'returns true if the left edge of the rectangle traces over all white pixels in the image 
        If rec.Left <= 0 Then 
            Return True 
        End If 
 
        Dim lower, upper As Integer 
        lower = rec.Top 
        If lower < 0 Then 
            lower = 0 
        End If 
        upper = rec.Bottom 
        If upper > bmp.Height - 1 Then 
            upper = bmp.Height - 1 
        End If 
 
        Dim pixelCount As Integer 
        For i As Integer = lower To upper Step 5 
            If (bmp.GetPixel(rec.Left, i).R < 255 OrElse bmp.GetPixel(rec.Left, i).G < 255 OrElse bmp.GetPixel(rec.Left, i).B < 255) Then 
                'this is a colored pixel 
                pixelCount += 1 
                If pixelCount > 10 Then 
                    Return False 
                End If 
            End If 
        Next 
        Return True 
    End Function 
 
    Public Function TraceRectangleTop(ByVal bmp As Bitmap, ByVal rec As Rectangle) As Boolean 
        'returns true if the top edge of the rectangle traces over all white pixels in the image 
        If rec.Top <= 0 Then 
            Return True 
        End If 
 
        Dim lower, upper As Integer 
        lower = rec.Left 
        If lower <= 0 Then 
            lower = 50 
        End If 
        upper = rec.Right 
        If upper >= bmp.Width - 1 Then 
            upper = bmp.Width - 51 
        End If 
 
        Dim pixelCount As Integer 
        For i As Integer = lower To upper Step 5 
            If (bmp.GetPixel(i, rec.Top).R < 255 OrElse bmp.GetPixel(i, rec.Top).G < 255 OrElse bmp.GetPixel(i, rec.Top).B < 255) Then 
                'this is a colored pixel 
                pixelCount += 1 
                If pixelCount > 10 Then 
                    Return False 
                End If 
            End If 
        Next 
        Return True 
    End Function 
 
    Public Function TraceRectangleBottom(ByVal bmp As Bitmap, ByVal rec As Rectangle) As Boolean 
        'returns true if the bottom edge of the rectangle traces over all white pixels in the image 
        If rec.Bottom >= bmp.Height - 1 Then 
            Return True 
        End If 
 
        Dim lower, upper As Integer 
        lower = rec.Left 
        If lower <= 0 Then 
            lower = 50 
        End If 
        upper = rec.Right 
        If upper >= bmp.Width - 1 Then 
            upper = bmp.Width - 51 
        End If 
 
        Dim pixelCount As Integer 
        For i As Integer = lower To upper Step 5 
            If (bmp.GetPixel(i, rec.Bottom).R < 255 OrElse bmp.GetPixel(i, rec.Bottom).G < 255 OrElse bmp.GetPixel(i, rec.Bottom).B < 255) Then 
                'this is a colored pixel 
                pixelCount += 1 
                If pixelCount > 10 Then 
                    Return False 
                End If 
            End If 
        Next 
        Return True 
    End Function 

It seems complicated but the algorithm really is pretty simple. The array centers() are starting x,y coordinates for where the centers of the five pictures should be in the raw scan. Next, we start with a rectangle at each center and trace the four edges of the rectangle. By tracing, I mean iterating through the pixels along that edge and determining if that line segment is mostly whitespace. By mostly, here I mean no more than 10 non-white pixels. If the edge is not whitespace, then we move the edge outwards by the constant stepSize until we hit whitespace. We loop through the 4 sides of each rectangle and stop once all four sides are in whitespace. If we hit all whitespace right away, then we assume the user didn't load a picture in that location. Note that the starting x,y coordinates are based on my particular layout on my scanner and would need to be adjusted accordingly depending on the user's scanner.

Code - Preview Form

At this point, we have captured a raw scan, carved it into up to 5 individual images and opened them in individual instances of a PreviewForm. I created the PreviewForm to have some basic image editing commands - a sample of a PreviewForm is shown here:

PreviewForm example

The image is shown via a PictureBox control. It already has methods for rotating so implementing CW and CCW rotation was simple. What required custom coding was cropping and median filtering. Cropping is necessary because the pictures will never be perfectly straight and my edge finding algorithm increments every 5 pixels to save time. So, using the following code lets you manually fine tune the image by taking a preset amount off of each side:

    Private Sub Button_Crop_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button_Crop.Click 
        Dim cropX As Integer = 7 
        Dim cropY As Integer = 5 
        Dim newSize As Size = Me.PictureBox1.Image.Size 
        If newSize.Width > newSize.Height Then 
            newSize.Width -= 2 * cropX 
            newSize.Height -= 2 * cropY 
        Else 
            newSize.Width -= 2 * cropY 
            newSize.Height -= 2 * cropX 
        End If 
        Dim bmpChunk As New Bitmap(newSize.Width, newSize.Height, Imaging.PixelFormat.Format24bppRgb) 
        Dim g As Graphics = Graphics.FromImage(bmpChunk) 
        g.DrawImage(Me.PictureBox1.Image, New Rectangle(0, 0, bmpChunk.Width, bmpChunk.Height), New Rectangle(cropX, cropY, newSize.Width, newSize.Height), GraphicsUnit.Pixel) 
        Me.PictureBox1.Image = bmpChunk 
        Me.PictureBox1.Refresh() 
        g.Dispose() 
        Me.m_didCrop = True 
    End Sub 

Scanning a print usually results in an image with lots of single-pixel noise, usually referred to as "salt-and-pepper" noise. The best way to eliminate it is to use a median filter. A median filter replaces each pixel in the image with the median color value of that pixel's immediate neighbors. A good explanation can be found on Wikipedia. In my case, I am looking at only the 8 immediately adjacent pixels for this filter. This kills off the single pixel errors very nicely without making the image blurry. Using the image shown above, here is an example of before and after median filtering, zoomed in on the lower left corner:

Median filter: before and after

And here is the code for the median filter:

    Private Sub Button_MedianFilter_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button_MedianFilter.Click 
        Dim width As Integer = Me.PictureBox1.Image.Width 
        Dim height As Integer = Me.PictureBox1.Image.Height 
        Dim r(8) As Integer 
        Dim g(8) As Integer 
        Dim b(8) As Integer 
 
        Dim bmp As Bitmap = New Bitmap(Me.PictureBox1.Image) 
        Me.PictureBox1.Image = bmp 
        Dim tempBmp As New Bitmap(Me.PictureBox1.Image) 
        Dim tc(width, height) As Color 
        With tempBmp 
            For h As Integer = 0 To height - 1 
                For w As Integer = 0 To width - 1 
                    tc(w, h) = .GetPixel(w, h) 
                Next 
            Next 
            For h As Integer = 1 To height - 2 
                For w As Integer = 1 To width - 2 
 
                    r(0) = (tc(w - 1, h - 1).R) 
                    g(0) = (tc(w - 1, h - 1).G) 
                    b(0) = (tc(w - 1, h - 1).B) 
 
                    r(1) = (tc(w, h - 1).R) 
                    g(1) = (tc(w, h - 1).G) 
                    b(1) = (tc(w, h - 1).B) 
 
                    r(2) = (tc(w + 1, h - 1).R) 
                    g(2) = (tc(w + 1, h - 1).G) 
                    b(2) = (tc(w + 1, h - 1).B) 
 
                    r(3) = (tc(w - 1, h).R) 
                    g(3) = (tc(w - 1, h).G) 
                    b(3) = (tc(w - 1, h).B) 
 
                    r(4) = (tc(w, h).R) 
                    g(4) = (tc(w, h).G) 
                    b(4) = (tc(w, h).B) 
 
                    r(5) = (tc(w + 1, h).R) 
                    g(5) = (tc(w + 1, h).G) 
                    b(5) = (tc(w + 1, h).B) 
 
                    r(6) = (tc(w - 1, h + 1).R) 
                    g(6) = (tc(w - 1, h + 1).G) 
                    b(6) = (tc(w - 1, h + 1).B) 
 
                    r(7) = (tc(w, h + 1).R) 
                    g(7) = (tc(w, h + 1).G) 
                    b(7) = (tc(w, h + 1).B) 
 
                    r(8) = (tc(w + 1, h + 1).R) 
                    g(8) = (tc(w + 1, h + 1).G) 
                    b(8) = (tc(w + 1, h + 1).B) 
 
                    Me.InsertSort(r, 8) 
                    Me.InsertSort(g, 8) 
                    Me.InsertSort(b, 8) 
                    bmp.SetPixel(w, h, Color.FromArgb(r(4), g(4), b(4))) 
 
                Next 
                If h Mod 100 = 0 Then 
                    My.Globals.g_BaseForm.ToolStripStatusLabel.Text = "Processing: " & Int(100 * h / height - 2).ToString & "%" 
                    My.Application.DoEvents() 
                End If 
            Next 
        End With 
        Me.PictureBox1.Refresh() 
        My.Globals.g_BaseForm.ToolStripStatusLabel.Text = "Ready" 
        If Me.m_undoBmp IsNot Nothing Then 
            Me.m_undoBmp.Dispose() 
            Me.m_undoBmp = Nothing 
        End If 
        Me.m_undoBmp = tempBmp 
        Me.Button1.Enabled = True 
        Me.m_didMedianCut = True 
    End Sub 
 
    Private Sub InsertSort(ByVal arr() As Integer, ByVal array_size As Integer) 
        Dim i, j, current As Integer 
        For i = 1 To array_size - 1 
            current = arr(i) 
            j = i 
            While ((j > 0) AndAlso (arr(j - 1) > current)) 
                arr(j) = arr(j - 1) 
                j -= 1 
            End While 
            arr(j) = current 
        Next 
    End Sub 

I rolled my own sort algorithm because it runs much faster this way than by using the sort method on an ArrayList or List(of Integer).

These are the major items of interest in my code. There are some additional features that I coded, such as a file naming system based off of a preformatted date that also includes a serial number. But things like that are best left to individual preferences.

If you would like to download the source code for the entire project, click on the link below. NOTE: This source package does not include the EZTwain sources or library, as it is not permitted to distribute them. You need to go to their web site and get them before this project will even compile.

Download the Visual Basic 2008 sources



Last updated December 26, 2009 by Daryn Waite. 287 total page views.



Add a comment to this page

Note: Only plain text is permitted - no HTML tags are allowed - 500 character limit - you must have JavaScript enabled in order to post.
Name:
Comment:
  characters left
Enter twenty two as digits: