I am currently working with an opensource piece of Optical Mark Recognition software that uses Ghostscript and AForge for graphics processing. The software is basically designed to read when a student has marked a bubble on a test form, e.g., by using a premade template to analyze areas of the image for "blobs." It then outputs all answers to a text file.
Here is the sheet I am using to be analyzed: Sheet.
The following code is the main analyzer of the sheet:
public OmrPageOutput ApplyTemplate(OmrTemplate template, ScannedImage image)
{
// Image ready for scan
if (!image.IsReadyForScan)
{
if (!image.IsScannable)
image.Analyze();
image.PrepareProcessing();
}
// Page output
OmrPageOutput retVal = new OmrPageOutput()
{
Id = image.TemplateName + DateTime.Now.ToString("yyyyMMddHHmmss"),
TemplateId = image.TemplateName,
Parameters = image.Parameters,
StartTime = DateTime.Now,
Template = template
};
// Save directory for output images
string saveDirectory = String.Empty;
var parmStr = new StringBuilder();
if (this.SaveIntermediaryImages)
{
if(image.Parameters != null)
foreach (var pv in image.Parameters)
parmStr.AppendFormat("{0}.", pv);
retVal.RefImages = new List<string>()
{
String.Format("{0}-{1}-init.bmp", retVal.Id, parmStr),
String.Format("{0}-{1}-tx.bmp", retVal.Id, parmStr),
String.Format("{0}-{1}-fields.bmp", retVal.Id, parmStr),
String.Format("{0}-{1}-gs.bmp", retVal.Id, parmStr),
String.Format("{0}-{1}-bw.bmp", retVal.Id, parmStr),
String.Format("{0}-{1}-inv.bmp", retVal.Id, parmStr)
};
saveDirectory = Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "imgproc");
Console.WriteLine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location).ToString());
if (!Directory.Exists(saveDirectory))
Directory.CreateDirectory(saveDirectory);
image.Image.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-init.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
}
// First, we want to get the image from the scanned image and translate it to the original
// position in the template
Bitmap bmp = null;
try
{
bmp = new Bitmap((int)template.BottomRight.X, (int)template.BottomRight.Y, System.Drawing.Imaging.PixelFormat.Format24bppRgb);
// Scale
float width = template.TopRight.X - template.TopLeft.X,
height = template.BottomLeft.Y - template.TopLeft.Y;
// Translate to original
using (Graphics g = Graphics.FromImage(bmp))
{
ResizeBicubic bc = new ResizeBicubic((int)width, (int)height);
g.DrawImage(bc.Apply((Bitmap)image.Image), template.TopLeft.X, template.TopLeft.Y);
}
if (this.SaveIntermediaryImages)
bmp.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-tx.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
// Now try to do hit from the template
if (this.SaveIntermediaryImages)
{
using (var tbmp = bmp.Clone() as Bitmap)
{
using (Graphics g = Graphics.FromImage(tbmp))
{
foreach (var field in template.Fields)
{
g.DrawRectangle(Pens.Black, field.TopLeft.X, field.TopLeft.Y, field.TopRight.X - field.TopLeft.X, field.BottomLeft.Y - field.TopLeft.Y);
g.DrawString(field.Id, SystemFonts.CaptionFont, Brushes.Black, field.TopLeft);
}
}
tbmp.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-fields.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
}
}
// Now convert to Grayscale
GrayscaleY grayFilter = new GrayscaleY();
var gray = grayFilter.Apply(bmp);
bmp.Dispose();
bmp = gray;
if (this.SaveIntermediaryImages)
bmp.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-gs.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
// Prepare answers
Dictionary<OmrQuestionField, OmrOutputData> hitFields = new Dictionary<OmrQuestionField, OmrOutputData>();
BarcodeReader barScan = new BarcodeReader();
barScan.Options.UseCode39ExtendedMode = true;
barScan.Options.UseCode39RelaxedExtendedMode = true;
barScan.Options.TryHarder = true;
barScan.TryInverted = true;
barScan.Options.PureBarcode = false;
barScan.AutoRotate = true;
foreach (var itm in template.Fields.Where(o => o is OmrBarcodeField))
{
PointF position = itm.TopLeft;
SizeF size = new SizeF(itm.TopRight.X - itm.TopLeft.X, itm.BottomLeft.Y - itm.TopLeft.Y);
using (var areaOfInterest = new Crop(new Rectangle((int)position.X, (int)position.Y, (int)size.Width, (int)size.Height)).Apply(bmp))
{
// Scan the barcode
var result = barScan.Decode(areaOfInterest);
if (result != null)
hitFields.Add(itm, new OmrBarcodeData()
{
BarcodeData = result.Text,
Format = result.BarcodeFormat,
Id = itm.Id,
TopLeft = new PointF(result.ResultPoints[0].X + position.X, result.ResultPoints[0].Y + position.Y),
BottomRight = new PointF(result.ResultPoints[1].X + position.X, result.ResultPoints[0].Y + position.Y + 10)
});
}
}
// Now binarize
Threshold binaryThreshold = new Threshold(template.ScanThreshold);
binaryThreshold.ApplyInPlace(bmp);
if (this.SaveIntermediaryImages)
bmp.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-bw.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
// Set return parameters
String tAnalyzeFile = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());
bmp.Save(tAnalyzeFile, System.Drawing.Imaging.ImageFormat.Jpeg);
retVal.AnalyzedImage = tAnalyzeFile;
retVal.BottomRight = new PointF(bmp.Width, bmp.Height);
// Now Invert
Invert invertFiter = new Invert();
invertFiter.ApplyInPlace(bmp);
if (this.SaveIntermediaryImages)
bmp.Save(Path.Combine(saveDirectory, string.Format("{0}-{1}-inv.bmp", DateTime.Now.ToString("yyyyMMddHHmmss"), parmStr)));
// Crop out areas of interest
List<KeyValuePair<OmrQuestionField, Bitmap>> areasOfInterest = new List<KeyValuePair<OmrQuestionField, Bitmap>>();
foreach (var itm in template.Fields.Where(o => o is OmrBubbleField))
{
PointF position = itm.TopLeft;
SizeF size = new SizeF(itm.TopRight.X - itm.TopLeft.X, itm.BottomLeft.Y - itm.TopLeft.Y);
areasOfInterest.Add(new KeyValuePair<OmrQuestionField, Bitmap>(
itm,
new Crop(new Rectangle((int)position.X, (int)position.Y, (int)size.Width, (int)size.Height)).Apply(bmp))
);
}
// Queue analysis
WaitThreadPool wtp = new WaitThreadPool();
Object syncLock = new object();
foreach (var itm in areasOfInterest)
{
wtp.QueueUserWorkItem(img =>
{
var parm = (KeyValuePair<OmrQuestionField, Bitmap>)itm;
try
{
var areaOfInterest = parm.Value;
var field = parm.Key;
BlobCounter blobCounter = new BlobCounter();
blobCounter.FilterBlobs = true;
// Check for circles
blobCounter.ProcessImage(areaOfInterest);
Blob[] blobs = blobCounter.GetObjectsInformation();
var blob = blobs.FirstOrDefault(o => o.Area == blobs.Max(b => b.Area));
if (blob != null)
{
//var area = new AForge.Imaging.ImageStatistics(blob).PixelsCountWithoutBlack;
if (blob.Area < 30)
return;
var bubbleField = field as OmrBubbleField;
lock (syncLock)
hitFields.Add(field, new OmrBubbleData()
{
Id = field.Id,
Key = bubbleField.Question,
Value = bubbleField.Value,
TopLeft = new PointF(blob.Rectangle.X + field.TopLeft.X, blob.Rectangle.Y + field.TopLeft.Y),
BottomRight = new PointF(blob.Rectangle.X + blob.Rectangle.Width + field.TopLeft.X, blob.Rectangle.Y + blob.Rectangle.Height + field.TopLeft.Y),
BlobArea = blob.Area
});
}
}
catch (Exception e) {
Trace.TraceError(e.ToString());
}
finally
{
parm.Value.Dispose();
}
}, itm);
}
wtp.WaitOne();
// Organize the response
foreach(var res in hitFields)
{
if (String.IsNullOrEmpty(res.Key.AnswerRowGroup))
{
this.AddAnswerToOutputCollection(retVal, res);
}
else
{
// Rows of data
OmrRowData rowGroup = retVal.Details.Find(o => o.Id == res.Key.AnswerRowGroup) as OmrRowData;
if(rowGroup == null)
{
rowGroup = new OmrRowData()
{
Id = res.Key.AnswerRowGroup
};
retVal.Details.Add(rowGroup);
}
this.AddAnswerToOutputCollection(rowGroup, res);
}
}
// Remove temporary images
//foreach (var f in retVal.RefImages)
// File.Delete(Path.Combine(saveDirectory, f));
hitFieldsCopy = hitFields;
// Outcome is success
retVal.Outcome = OmrScanOutcome.Success;
}
catch(Exception e)
{
retVal.Outcome = OmrScanOutcome.Failure;
retVal.ErrorMessage = e.Message;
Trace.TraceError(e.ToString());
}
finally
{
retVal.StopTime = DateTime.Now;
bmp.Dispose();
}
//Based off values from hitFieldsCopy
//This bit of code doesn't seem to work properly -- created by Simon Ward
List<OmrQuestionField> questionNumber = hitFieldsCopy.Keys.ToList();
List<OmrOutputData> questionAnswer = hitFieldsCopy.Values.ToList();
Array questionsNumberArray = questionNumber.ToArray();
Array questionAnswerArray = questionAnswer.ToArray();
string answerString = "";
for(int i = 0; i < questionsNumberArray.Length; i++)
{
answerString += ("Question #" + questionsNumberArray.GetValue(i).ToString() + " ");
answerString += (questionAnswerArray.GetValue(i).ToString() + "\n");
}
//Console.WriteLine(answerString);
return retVal;
}
/// <summary>
/// Add an answer to an output collection
/// </summary>
/// <param name="retVal"></param>
/// <param name="answerPair"></param>
private void AddAnswerToOutputCollection(OmrOutputDataCollection collection, KeyValuePair<OmrQuestionField, OmrOutputData> answerPair )
{
// Now add answer
if (answerPair.Key is OmrBubbleField)
{
var bubbleField = answerPair.Key as OmrBubbleField;
switch (bubbleField.Behavior)
{
case BubbleBehaviorType.One:
if (!collection.AlreadyAnswered(answerPair.Value))
collection.Details.Add(answerPair.Value);
break;
case BubbleBehaviorType.Multi:
collection.Details.Add(answerPair.Value);
break;
case BubbleBehaviorType.Count:
var aggregate = collection.Details.Find(o => o.Id == bubbleField.Question) as OmrAggregateDataOutput;
if (aggregate == null)
{
aggregate = new OmrAggregateDataOutput() { Id = bubbleField.Question };
aggregate.Function = AggregationFunction.Count;
collection.Details.Add(aggregate);
}
if (collection is OmrRowData)
aggregate.RowId = collection.Id;
// Add this answer to the aggregate
aggregate.Details.Add(answerPair.Value);
break;
}
}
else if (!collection.AlreadyAnswered(answerPair.Value))
{
collection.Details.Add(answerPair.Value);
}
}
}
Now, when I was using a previous sheet (a classic Parscore test sheet), everything ran just beautifully -- no errors at all. Since I have switched, however, the code has not been functioning properly -- and I cannot figure out why for the life of me.
Here is the example that I am struggling with: I have the following sheet filled with marks Sheet. When I run the code, the following output occurs:
ID Number: 951619984
Question #1 - B
Question #2 - C
Question #3 - D
Question #4 - E
Question #5 - A
Question #6 - B
Question #7 - C
Question #8 - D
Question #9 - E
Question #10 - A
Question #11 - B
Question #12 - C
Question #13 - D
Question #14 - E
Question #15 - A
Question #16 - B
Question #17 - C
Question #18 - D
Question #19 - E
Question #20 - A
Question #21 - B
Question #22 - C
Question #23 - D
Question #24 - E
Question #25 - A
Question #26 - A
Question #27 - B
Question #28 - D
Question #29 - D
Question #30 - E
Question #31 - A
Question #32 - B
Question #33 - C
Question #34 - D
Question #35 - E
Question #36 - A
Question #37 - B
Question #38 - C
Question #39 - D
Question #40 - E
Question #41 - A
Question #42 - B
Question #43 - C
Question #44 - D
Question #45 - E
Question #46 - A
Question #47 - B
Question #48 - C
Question #49 - D
Question #50 - E
Question #51 - D
Question #52 - C
Question #53 - B
Question #54 - A
Question #55 - E
Question #56 - A
Question #57 - C
Question #58 - B
Question #59 - A
Question #60 - E
Question #61 - D
Question #62 - C
Question #63 - B
Question #64 - A
Question #65 - E
Question #66 - D
Question #67 - C
Question #68 - B
Question #69 - A
Question #70 - E
Question #71 - D
Question #72 - C
Question #73 - B
Question #74 - A
Question #75 - E
Question #76 - E
Question #77 - D
Question #78 - C
Question #79 - B
Question #80 - A
Question #81 - E
Question #82 - D
Question #83 - C
Question #84 - B
Question #85 - A
Question #86 - E
Question #87 - D
Question #88 - C
Question #89 - B
Question #90 - A
Question #91 - E
Question #92 - D
Question #93 - C
Question #94 - B
Question #95 - A
Question #96 - E
Question #97 - D
Question #98 - C
Question #99 - B
Question #100 - A
Question #101 - C
Question #102 - D
Question #103 - E
Question #104 - A
Question #105 - B
Question #106 - D
Question #107 - E
Question #108 - A
Question #109 - B
Question #110 - C
Question #111 - D
Question #112 - A
Question #113 - B
Question #114 - C
Question #115 - D
Question #116 - E
Question #117 - E
Question #118 - E
Question #119 - E
Question #120 - A
Question #121 - B
Question #122 - C
Question #123 - D
Question #124 - E
Question #125 - A
Question #126 - B
Question #127 - B
Question #128 - C
Question #129 - D
Question #130 - D
Question #131 - D
Question #132 - D
Question #133 - E
Question #134 - D
Question #135 - C
Question #136 - B
Question #137 - A
Question #138 - B
Question #139 - C
Question #140 - A
Question #141 - B
Question #142 - C
Question #143 - D
Question #144 - E
Question #145 - E
Question #146 - A
Question #147 - B
Question #148 - C
Question #149 - D
Question #150 - E
As you can see, the answers seem to be just one line off or so. I have measured everything pixel to pixel, and all of the templates and the like are set perfectly.
So, my question is: is it something to do with mark recognition in Ghostscript or AForge? Are the answer bubbles too dark, and so the scanner is misreading unfilled bubbles as filled? Or is it some other issue? I apologize, as I am still very much a student of programming. Any answers are much appreciated. Thanks you!