mork 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Mork
2
- VERSION = "0.0.1"
2
+ VERSION = "0.0.2"
3
3
  end
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
- # s.add_development_dependency "rspec"
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