mork 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/Gemfile +1 -2
- data/Guardfile +10 -0
- data/config/grids.yml +42 -0
- data/lib/mork.rb +8 -4
- data/lib/mork/grid.rb +307 -0
- data/lib/mork/grid_const.rb +14 -0
- data/lib/mork/mimage.rb +86 -0
- data/lib/mork/mimage_list.rb +29 -0
- data/lib/mork/npatch.rb +51 -0
- data/lib/mork/report.rb +39 -0
- data/lib/mork/sheet.rb +144 -0
- data/lib/mork/sheet_pdf.rb +97 -0
- data/lib/mork/version.rb +1 -1
- data/mork.gemspec +9 -2
- data/spec/mork/grid_spec.rb +85 -0
- data/spec/mork/mimage_list_spec.rb +37 -0
- data/spec/mork/mimage_spec.rb +48 -0
- data/spec/mork/npatch_spec.rb +52 -0
- data/spec/mork/report_spec.rb +13 -0
- data/spec/mork/sheet_pdf_spec.rb +34 -0
- data/spec/mork/sheet_spec.rb +149 -0
- data/spec/samples/code_sample.pdf +85 -0
- data/spec/samples/code_sample.png +0 -0
- data/spec/samples/code_zero.pdf +79 -0
- data/spec/samples/info.yml +53 -0
- data/spec/samples/reg_mark.jpg +0 -0
- data/spec/samples/sample.pages +0 -0
- data/spec/samples/sample01.jpg +0 -0
- data/spec/samples/two_pages.pdf +0 -0
- data/spec/spec_helper.rb +34 -0
- metadata +199 -31
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'RMagick'
|
2
|
+
|
3
|
+
module Mork
|
4
|
+
# The class MimageList
|
5
|
+
class MimageList
|
6
|
+
def initialize(fname)
|
7
|
+
raise "Initializing a MimageList requires a string" unless fname.class == String
|
8
|
+
if File.extname(fname) == '.pdf'
|
9
|
+
@images = Magick::ImageList.new(fname) { self.density = 200 }
|
10
|
+
else
|
11
|
+
@images = Magick::ImageList.new(fname)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def shift
|
16
|
+
Mimage.new @images.shift
|
17
|
+
end
|
18
|
+
|
19
|
+
def [] (i)
|
20
|
+
Mimage.new @images[i]
|
21
|
+
end
|
22
|
+
|
23
|
+
def each
|
24
|
+
@images.each do |i|
|
25
|
+
yield Mimage.new i
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/mork/npatch.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'narray'
|
2
|
+
|
3
|
+
module Mork
|
4
|
+
# Handles low-level computations on a Mimage
|
5
|
+
# Typically used on smaller patches
|
6
|
+
class NPatch
|
7
|
+
def initialize(mim)
|
8
|
+
@mim = mim
|
9
|
+
@width = mim.width
|
10
|
+
@height = mim.height
|
11
|
+
end
|
12
|
+
|
13
|
+
def average
|
14
|
+
narr.mean
|
15
|
+
end
|
16
|
+
|
17
|
+
def dark_centroid
|
18
|
+
return nil, nil unless sufficient_contrast?
|
19
|
+
xp = patch.sum(1).to_a
|
20
|
+
yp = patch.sum(0).to_a
|
21
|
+
# find the intensity trough
|
22
|
+
ctr_x = xp.find_index(xp.min)
|
23
|
+
ctr_y = yp.find_index(yp.min)
|
24
|
+
return nil, nil if edgy?(ctr_x, ctr_y)
|
25
|
+
return ctr_x, ctr_y
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
def patch
|
30
|
+
@the_npatch ||= blurry_narr.reshape!(@width, @height)
|
31
|
+
end
|
32
|
+
|
33
|
+
def narr
|
34
|
+
@narr ||= NArray[@mim.pixels]
|
35
|
+
end
|
36
|
+
|
37
|
+
def blurry_narr
|
38
|
+
@blurry_narr ||= NArray[@mim.blur!(10,5).pixels]
|
39
|
+
end
|
40
|
+
|
41
|
+
def sufficient_contrast?
|
42
|
+
# just a wild guess for now
|
43
|
+
blurry_narr.stddev > 5000
|
44
|
+
end
|
45
|
+
|
46
|
+
def edgy?(x, y)
|
47
|
+
tol = 5
|
48
|
+
(x < tol) or (y < tol) or (y > @height - tol) or (x > @width - tol)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/mork/report.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
module Mork
|
2
|
+
class Report
|
3
|
+
def initialize(im, opts={})
|
4
|
+
grid_type = opts[:grid_type] || :default
|
5
|
+
grid_file = opts[:grid_file] || 'config/grids.yml'
|
6
|
+
@grid = Grid.new grid_type, grid_file
|
7
|
+
@sheet = Sheet.new(im, @grid)
|
8
|
+
@chlist = opts[:chlist] || @grid.default_chlist
|
9
|
+
end
|
10
|
+
|
11
|
+
def sheet_code
|
12
|
+
@sheet.code
|
13
|
+
end
|
14
|
+
|
15
|
+
def selected_choices
|
16
|
+
# sc = []
|
17
|
+
# @chlist.each_with_index do |ch, i|
|
18
|
+
# sc[i] = (0...ch).collect do |x|
|
19
|
+
# puts "Q.#{i} Ch.#{x}"
|
20
|
+
# @mimage.darkened? @ruler.cell(i, x)
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
# sc
|
24
|
+
end
|
25
|
+
|
26
|
+
def highlight_all_choices
|
27
|
+
@chlist.each_with_index do |ch, i|
|
28
|
+
(0...ch).each do |x|
|
29
|
+
@sheet.highlight_choice(i, x)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
@sheet.highlight_code
|
33
|
+
end
|
34
|
+
|
35
|
+
def write_sheet(fname)
|
36
|
+
@sheet.write(fname)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/mork/sheet.rb
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
module Mork
|
2
|
+
class Sheet
|
3
|
+
def initialize(im, grid)
|
4
|
+
im = Mimage.new(im) if im.class == String
|
5
|
+
raise "A new sheet requires either a Mimage or the name of the source image file" unless im.class == Mimage
|
6
|
+
# grid = Grid.new(grid) if grid.class == String
|
7
|
+
# raise "A new sheet requires either a Mimage or the name of the source image file" unless im.class == Mimage
|
8
|
+
@grid = grid
|
9
|
+
@mimage = register(im)
|
10
|
+
end
|
11
|
+
|
12
|
+
# code
|
13
|
+
#
|
14
|
+
# returns the sheet code as an integer
|
15
|
+
def code
|
16
|
+
code_string.to_i(2)
|
17
|
+
end
|
18
|
+
|
19
|
+
# code_string
|
20
|
+
#
|
21
|
+
# returns the sheet code as a string of 0s and 1s. The string is CODE_BITS
|
22
|
+
# bits long, with most significant bits to the left
|
23
|
+
def code_string
|
24
|
+
cs = (0...@grid.code_bits).inject("") { |c, v| c << code_cell_value(v) }
|
25
|
+
cs.reverse
|
26
|
+
end
|
27
|
+
|
28
|
+
# marked?(question, choice)
|
29
|
+
#
|
30
|
+
# returns true if the specified question/choice cell has been darkened
|
31
|
+
# false otherwise
|
32
|
+
def marked?(q, c)
|
33
|
+
shade_of(q, c) < dark_threshold
|
34
|
+
end
|
35
|
+
|
36
|
+
# mark_array(range)
|
37
|
+
#
|
38
|
+
# returns an array of arrays of marked choices.
|
39
|
+
# takes either a range of questions, an array of questions, or a fixnum,
|
40
|
+
# in which case the choices for the first n questions will be returned.
|
41
|
+
# if called without arguments, all available choices will be evaluated
|
42
|
+
def mark_array(r = nil)
|
43
|
+
question_range(r).collect do |q|
|
44
|
+
cho = []
|
45
|
+
(0...@grid.max_choices_per_question).each do |c|
|
46
|
+
cho << c if marked?(q, c)
|
47
|
+
end
|
48
|
+
cho
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def mark_logical_array(r = nil)
|
53
|
+
question_range(r).collect do |q|
|
54
|
+
(0...@grid.max_choices_per_question).collect {|c| marked?(q, c)}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def highlight_choice(q, c)
|
59
|
+
@mimage.highlight! @grid.choice_cell_area(q, c)
|
60
|
+
end
|
61
|
+
|
62
|
+
def highlight_code_bit(i)
|
63
|
+
@mimage.highlight! @grid.code_bit_area(i)
|
64
|
+
end
|
65
|
+
|
66
|
+
def highlight_dark_calibration_bit
|
67
|
+
@mimage.highlight! @grid.black_calibration_area
|
68
|
+
end
|
69
|
+
|
70
|
+
def highlight_light_calibration_bit
|
71
|
+
@mimage.highlight! @grid.white_calibration_area
|
72
|
+
end
|
73
|
+
|
74
|
+
def write(fname)
|
75
|
+
@mimage.write(fname)
|
76
|
+
end
|
77
|
+
|
78
|
+
def dark_code_bit_shade
|
79
|
+
NPatch.new(@mimage.crop @grid.black_calibration_area).average
|
80
|
+
end
|
81
|
+
|
82
|
+
def light_code_bit_shade
|
83
|
+
NPatch.new(@mimage.crop @grid.white_calibration_area).average
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
def shade_of(q, c)
|
88
|
+
NPatch.new(@mimage.crop @grid.choice_cell_area(q, c)).average
|
89
|
+
end
|
90
|
+
|
91
|
+
def dark_threshold
|
92
|
+
50000
|
93
|
+
end
|
94
|
+
|
95
|
+
def question_range(r)
|
96
|
+
if r.nil?
|
97
|
+
(0...@grid.max_questions)
|
98
|
+
elsif r.is_a? Fixnum
|
99
|
+
(0...r)
|
100
|
+
else
|
101
|
+
r
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def shade_of_code_bit(i)
|
106
|
+
NPatch.new(@mimage.crop @grid.code_bit_area(i)).average
|
107
|
+
end
|
108
|
+
|
109
|
+
def code_cell_value(i)
|
110
|
+
shade_of_code_bit(i) < dark_threshold ? "1" : "0"
|
111
|
+
end
|
112
|
+
|
113
|
+
# ================
|
114
|
+
# = Registration =
|
115
|
+
# ================
|
116
|
+
|
117
|
+
def register(img)
|
118
|
+
# send page size to the grid, so that all later measurements can be done within the
|
119
|
+
# grid itself. WARNING: this method assumes a 'stretch' strategy, i.e. where the
|
120
|
+
# image after registration has the same size in pixels as the original scanned file
|
121
|
+
@grid.set_page_size img.width, img.height
|
122
|
+
# find the XY coordinates of the 4 registration marks
|
123
|
+
x1, y1 = reg_centroid_on(img, @grid.reg_mark_search_area(:top_left))
|
124
|
+
x2, y2 = reg_centroid_on(img, @grid.reg_mark_search_area(:top_right))
|
125
|
+
x3, y3 = reg_centroid_on(img, @grid.reg_mark_search_area(:bottom_right))
|
126
|
+
x4, y4 = reg_centroid_on(img, @grid.reg_mark_search_area(:bottom_left))
|
127
|
+
# stretch the 4 points to fit the original size and return the resulting image
|
128
|
+
img.stretch [
|
129
|
+
x1, y1, 0, 0,
|
130
|
+
x2, y2, img.width, 0,
|
131
|
+
x3, y3, img.width, img.height,
|
132
|
+
x4, y4, 0, img.height
|
133
|
+
]
|
134
|
+
end
|
135
|
+
|
136
|
+
# returns the centroid of the dark region within the given area
|
137
|
+
# in the XY coordinates of the entire image
|
138
|
+
def reg_centroid_on(img, c)
|
139
|
+
cx, cy = NPatch.new(img.crop(c)).dark_centroid
|
140
|
+
return nil, nil if cx.nil?
|
141
|
+
return cx + c[:x], cy + c[:y]
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'prawn'
|
2
|
+
|
3
|
+
module Mork
|
4
|
+
class SheetPDF < Prawn::Document
|
5
|
+
def initialize(grid, info)
|
6
|
+
@grid = grid
|
7
|
+
@info = info
|
8
|
+
super my_page_params
|
9
|
+
registration_marks
|
10
|
+
calibration_and_code @info[:code]
|
11
|
+
header
|
12
|
+
questions_and_choices
|
13
|
+
end
|
14
|
+
|
15
|
+
def my_page_params
|
16
|
+
{
|
17
|
+
page_size: @grid.pdf_page_size,
|
18
|
+
margin: @grid.pdf_margins
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
def registration_marks
|
23
|
+
fill {
|
24
|
+
@grid.pdf_reg_marks.each do |r|
|
25
|
+
circle r[:p], r[:r]
|
26
|
+
end
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
def calibration_and_code(code)
|
31
|
+
fill do
|
32
|
+
# draw the dark calibration bar
|
33
|
+
c = @grid.pdf_dark_calibration_area
|
34
|
+
rectangle c[:p], c[:w], c[:h]
|
35
|
+
# draw the bars corresponding to the code
|
36
|
+
# least to most significant bit, left to right
|
37
|
+
@grid.pdf_code_areas_for(code).each do |c|
|
38
|
+
rectangle c[:p], c[:w], c[:h]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def header
|
44
|
+
@info[:header].each do |k,v|
|
45
|
+
text_box v, at: @grid.pdf_header_xy(k),
|
46
|
+
width: @grid.pdf_header_width(k),
|
47
|
+
size: @grid.pdf_header_size(k) || 12
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def questions_and_choices
|
52
|
+
stroke do
|
53
|
+
line_width 0.3
|
54
|
+
nquestions.times do |q|
|
55
|
+
fill_color "000000"
|
56
|
+
text_box "#{q+1}", at: @grid.pdf_qnum_xy(q),
|
57
|
+
width: @grid.pdf_qnum_width,
|
58
|
+
align: :right,
|
59
|
+
size: Q_NUM_SIZE
|
60
|
+
stroke_color "ff0000"
|
61
|
+
font_size CH_LETTER_SZ
|
62
|
+
nchoices(q).times do |c|
|
63
|
+
a = @grid.pdf_choice_cell_area q, c
|
64
|
+
rounded_rectangle a[:p], a[:w], a[:h], 2.mm
|
65
|
+
fill_color "ff0000"
|
66
|
+
draw_text (65+c).chr, at: @grid.pdf_choice_letter_xy(q, c)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def save(fn)
|
73
|
+
render_file fn
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
def nquestions
|
78
|
+
@info[:choices].length
|
79
|
+
end
|
80
|
+
|
81
|
+
def nchoices(i)
|
82
|
+
@info[:choices][i]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class Fixnum
|
88
|
+
def mm
|
89
|
+
self * 2.83464566929134
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
class Float
|
94
|
+
def mm
|
95
|
+
self * 2.83464566929134
|
96
|
+
end
|
97
|
+
end
|
data/lib/mork/version.rb
CHANGED
data/mork.gemspec
CHANGED
@@ -10,7 +10,6 @@ Gem::Specification.new do |s|
|
|
10
10
|
s.homepage = ""
|
11
11
|
s.summary = %q{Optical mark recognition of multiple-choice response sheets}
|
12
12
|
s.description = %q{Coming soon}
|
13
|
-
|
14
13
|
s.rubyforge_project = "mork"
|
15
14
|
|
16
15
|
s.files = `git ls-files`.split("\n")
|
@@ -19,6 +18,14 @@ Gem::Specification.new do |s|
|
|
19
18
|
s.require_paths = ["lib"]
|
20
19
|
|
21
20
|
# specify any dependencies here; for example:
|
22
|
-
|
21
|
+
s.add_dependency "narray"
|
22
|
+
s.add_dependency "rmagick"
|
23
|
+
s.add_development_dependency 'rake'
|
24
|
+
s.add_development_dependency "rspec"
|
25
|
+
s.add_development_dependency "cucumber"
|
26
|
+
s.add_development_dependency "guard-rspec"
|
27
|
+
s.add_development_dependency "guard-shell"
|
28
|
+
s.add_development_dependency "rb-fsevent"
|
29
|
+
|
23
30
|
# s.add_runtime_dependency "rest-client"
|
24
31
|
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Mork
|
4
|
+
describe Grid do
|
5
|
+
let(:grid) { Grid.new(:default) }
|
6
|
+
|
7
|
+
describe "#initialize" do
|
8
|
+
it "returns a Grid object" do
|
9
|
+
grid.should be_a(Grid)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "#cell_x" do
|
14
|
+
context "for 1st-column questions" do
|
15
|
+
it "returns the distance from the registration frame of the left edge of the 1st choice" do
|
16
|
+
grid.send(:cell_x,0,0).should == 7.5
|
17
|
+
end
|
18
|
+
|
19
|
+
it "returns the distance from the registration frame of the left edge of the 2nd choice" do
|
20
|
+
grid.send(:cell_x,0,1).should == 14.5
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context "for 4th-column questions" do
|
25
|
+
it "returns the distance from the registration frame of the left edge of the 1st choice" do
|
26
|
+
grid.send(:cell_x,120,0).should == 157.5
|
27
|
+
end
|
28
|
+
|
29
|
+
it "returns the distance from the registration frame of the left edge of the 2nd choice" do
|
30
|
+
grid.send(:cell_x,120,1).should == 164.5
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "#cell_y" do
|
36
|
+
it "returns the distance from the registration frame of the top edge of the 1st row of cells" do
|
37
|
+
grid.send(:cell_y,0).should == 33.5
|
38
|
+
end
|
39
|
+
|
40
|
+
it "returns the distance from the registration frame of the 40th row of cells" do
|
41
|
+
grid.send(:cell_y,39).should == 267.5
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "#cell_xy" do
|
46
|
+
it "returns the left and top distances from the registration frame of a sample cell" do
|
47
|
+
x, y = grid.send(:cell_xy,54, 3)
|
48
|
+
x.should == 78.5
|
49
|
+
y.should == 117.5
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# describe "#question_area" do
|
56
|
+
# before(:each) do
|
57
|
+
# grid.reg_marks(@image)
|
58
|
+
# end
|
59
|
+
# it "returns a hash" do
|
60
|
+
# grid.question_area(1).should be_an_instance_of(Hash)
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
# it "returns the location in pixels of the first question patch" do
|
64
|
+
# c = grid.question_area(1)
|
65
|
+
# c[:x].should be_within(4).of(90)
|
66
|
+
# c[:y].should be_within(4).of(388)
|
67
|
+
# end
|
68
|
+
# it "returns the location in pixels of the 40th question patch" do
|
69
|
+
# c = grid.question_area(40)
|
70
|
+
# c[:x].should be_within(4).of(90)
|
71
|
+
# c[:y].should be_within(4).of(3120)
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# it "returns the location in pixels of the 121th question patch" do
|
75
|
+
# c = grid.question_area(121)
|
76
|
+
# c[:x].should be_within(4).of(1887)
|
77
|
+
# c[:y].should be_within(4).of(388)
|
78
|
+
# end
|
79
|
+
#
|
80
|
+
# it "returns the location in pixels of the last question patch" do
|
81
|
+
# c = grid.question_area(160)
|
82
|
+
# c[:x].should be_within(4).of(1887)
|
83
|
+
# c[:y].should be_within(4).of(3120)
|
84
|
+
# end
|
85
|
+
# end
|