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.
@@ -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