mork 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|