- VB.Net project: Scanning Photos
- Crates and Barrels
- Todesangst
- The Basement Home Theater
- Building a HTPC
- MAPFool
- Who is Peppy?
- The True Street Machine
- The Frustrating German Language
- Food Packaging Deceptions
- Building a House Addition
- My Favorites
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:
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:
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:
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.




