qrio 0.0.1
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.
- checksums.yaml +7 -0
- data/.gemtest +0 -0
- data/.gitignore +5 -0
- data/.rvmrc.example +1 -0
- data/Gemfile +3 -0
- data/README.md +66 -0
- data/Rakefile +8 -0
- data/examples/extract_qr +18 -0
- data/lib/qrio.rb +15 -0
- data/lib/qrio/finder_pattern_slice.rb +136 -0
- data/lib/qrio/horizontal_match.rb +31 -0
- data/lib/qrio/image_dumper.rb +97 -0
- data/lib/qrio/image_loader/png_image_loader.rb +18 -0
- data/lib/qrio/matrix.rb +72 -0
- data/lib/qrio/neighbor.rb +40 -0
- data/lib/qrio/qr.rb +207 -0
- data/lib/qrio/qr_matrix.rb +403 -0
- data/lib/qrio/region.rb +113 -0
- data/lib/qrio/sampling_grid.rb +152 -0
- data/lib/qrio/version.rb +3 -0
- data/lib/qrio/vertical_match.rb +31 -0
- data/qrio.gemspec +23 -0
- data/test/finder_pattern_slice_test.rb +121 -0
- data/test/fixtures/block_test.qr +25 -0
- data/test/fixtures/finder_pattern1.png +0 -0
- data/test/fixtures/finder_pattern2.png +0 -0
- data/test/fixtures/finder_pattern3.png +0 -0
- data/test/fixtures/finder_pattern4.png +0 -0
- data/test/fixtures/masked0.qr +25 -0
- data/test/fixtures/no_finder_pattern1.png +0 -0
- data/test/fixtures/qrio-codewords.txt +100 -0
- data/test/fixtures/qrio.qr +33 -0
- data/test/horizontal_match_test.rb +34 -0
- data/test/matrix_test.rb +66 -0
- data/test/qr_matrix_test.rb +164 -0
- data/test/qr_test.rb +107 -0
- data/test/region_test.rb +87 -0
- data/test/sampling_grid_test.rb +78 -0
- metadata +111 -0
data/lib/qrio/region.rb
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
module Qrio
|
2
|
+
# a rectangular matrix of bits
|
3
|
+
class Region
|
4
|
+
attr_reader :x1, :y1, :x2, :y2, :orientation
|
5
|
+
|
6
|
+
def initialize(x1, y1, x2, y2)
|
7
|
+
@x1 = x1
|
8
|
+
@y1 = y1
|
9
|
+
@x2 = x2
|
10
|
+
@y2 = y2
|
11
|
+
|
12
|
+
set_orientation
|
13
|
+
end
|
14
|
+
|
15
|
+
def left; x1; end
|
16
|
+
def right; x2; end
|
17
|
+
def top; y1; end
|
18
|
+
def bottom; y2; end
|
19
|
+
|
20
|
+
def top_left
|
21
|
+
[x1, y1]
|
22
|
+
end
|
23
|
+
|
24
|
+
def bottom_right
|
25
|
+
[x2, y2]
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_coordinates
|
29
|
+
[top_left, bottom_right].flatten
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_point_size
|
33
|
+
[top_left, width, height].flatten
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_s
|
37
|
+
"R[#{ to_coordinates.join(',') }]"
|
38
|
+
end
|
39
|
+
|
40
|
+
def hash
|
41
|
+
to_s.hash
|
42
|
+
end
|
43
|
+
|
44
|
+
def eql?(other)
|
45
|
+
self == other
|
46
|
+
end
|
47
|
+
|
48
|
+
def ==(other)
|
49
|
+
to_s == other.to_s
|
50
|
+
end
|
51
|
+
|
52
|
+
def width
|
53
|
+
x2 - x1 + 1
|
54
|
+
end
|
55
|
+
|
56
|
+
def height
|
57
|
+
y2 - y1 + 1
|
58
|
+
end
|
59
|
+
|
60
|
+
def center
|
61
|
+
[left + width / 2, top + height / 2]
|
62
|
+
end
|
63
|
+
|
64
|
+
def horizontal?; orientation == :horizontal; end
|
65
|
+
def vertical?; orientation == :vertical; end
|
66
|
+
|
67
|
+
def orientation_matches?(other)
|
68
|
+
orientation == other.orientation
|
69
|
+
end
|
70
|
+
|
71
|
+
def union(other)
|
72
|
+
self.class.new(
|
73
|
+
[left, other.left].min,
|
74
|
+
[top, other.top].min,
|
75
|
+
[right, other.right].max,
|
76
|
+
[bottom, other.bottom].max
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
def translate(xoffset, yoffset)
|
81
|
+
self.class.new(
|
82
|
+
left - xoffset,
|
83
|
+
top - yoffset,
|
84
|
+
right - xoffset,
|
85
|
+
bottom - yoffset
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
# return a new region that would be the result of rotating
|
90
|
+
# a matrix of width x height containing this region
|
91
|
+
def rotate(mw, mh)
|
92
|
+
self.class.new(
|
93
|
+
mh - bottom - 1,
|
94
|
+
left,
|
95
|
+
mh - top - 1,
|
96
|
+
right
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def set_orientation
|
103
|
+
@orientation = case
|
104
|
+
when width > height
|
105
|
+
:horizontal
|
106
|
+
when height > width
|
107
|
+
:vertical
|
108
|
+
else
|
109
|
+
:square
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
module Qrio
|
2
|
+
class SamplingGrid
|
3
|
+
attr_reader :origin_corner, :top_right, :bottom_left,
|
4
|
+
:orientation, :bounds, :angles,
|
5
|
+
:block_width, :block_height, :provisional_version,
|
6
|
+
:finder_patterns, :matrix
|
7
|
+
|
8
|
+
def initialize(matrix, finder_patterns)
|
9
|
+
@matrix = matrix
|
10
|
+
@finder_patterns = finder_patterns
|
11
|
+
@angles = []
|
12
|
+
|
13
|
+
find_origin_corner
|
14
|
+
detect_orientation
|
15
|
+
end
|
16
|
+
|
17
|
+
def find_origin_corner(normalized=false)
|
18
|
+
build_finder_pattern_neighbors
|
19
|
+
|
20
|
+
shared_corners = @finder_patterns.select do |fp|
|
21
|
+
fp.neighbors.select(&:right_angle?).count > 1
|
22
|
+
end
|
23
|
+
|
24
|
+
# TODO : handle multiple possible matches
|
25
|
+
if @origin_corner = shared_corners.first
|
26
|
+
if normalized
|
27
|
+
# we have correct orientation, identify fp positions
|
28
|
+
@top_right = non_origin_finder_patterns.map(&:destination).detect{|fp| fp.center.last < @matrix.height / 2.0 }
|
29
|
+
@bottom_left = non_origin_finder_patterns.map(&:destination).detect{|fp| fp.center.first < @matrix.width / 2.0 }
|
30
|
+
else
|
31
|
+
set_bounds
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def set_bounds
|
37
|
+
@bounds = @origin_corner.dup
|
38
|
+
@bounds.neighbors.select(&:right_angle?).each do |n|
|
39
|
+
@bounds = @bounds.union(n.destination)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# which way is the QR rotated?
|
44
|
+
# 0) normal - shared finder patterns in top left
|
45
|
+
# 1) - shared finder patterns in top right
|
46
|
+
# 2) - shared finder patterns in bottom right
|
47
|
+
# 3) - shared finder patterns in bottom left
|
48
|
+
def detect_orientation
|
49
|
+
# TODO : handle multiple possible matches
|
50
|
+
other_corners = non_origin_finder_patterns
|
51
|
+
|
52
|
+
dc = other_corners.map(&:distance).inject(0){|s,d| s + d } / 2.0
|
53
|
+
threshold = dc / 2.0
|
54
|
+
|
55
|
+
other_corners = other_corners.map(&:destination)
|
56
|
+
|
57
|
+
set_block_dimensions(@origin_corner, *other_corners)
|
58
|
+
@provisional_version = ((dc / @block_width).round - 10) / 4
|
59
|
+
|
60
|
+
xs = other_corners.map{|fp| fp.center.first }
|
61
|
+
ys = other_corners.map{|fp| fp.center.last }
|
62
|
+
|
63
|
+
above = ys.select{|y| y < (@origin_corner.center.last - threshold) }
|
64
|
+
left = xs.select{|x| x < (@origin_corner.center.first - threshold) }
|
65
|
+
|
66
|
+
@orientation = if above.any?
|
67
|
+
left.any? ? 2 : 3
|
68
|
+
else
|
69
|
+
left.any? ? 1 : 0
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def non_origin_finder_patterns
|
74
|
+
@origin_corner.neighbors.select(&:right_angle?)[0,2]
|
75
|
+
end
|
76
|
+
|
77
|
+
def build_finder_pattern_neighbors
|
78
|
+
@finder_patterns.each do |source|
|
79
|
+
@finder_patterns.each do |destination|
|
80
|
+
next if source.center == destination.center
|
81
|
+
@angles << Neighbor.new(source, destination)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def set_block_dimensions(*finder_patterns)
|
87
|
+
@block_width = finder_patterns.inject(0){|s,f| s + f.width } / 21.0
|
88
|
+
@block_height = finder_patterns.inject(0){|s,f| s + f.height } / 21.0
|
89
|
+
end
|
90
|
+
|
91
|
+
def logical_width
|
92
|
+
@matrix.width / @block_width
|
93
|
+
end
|
94
|
+
|
95
|
+
def logical_height
|
96
|
+
@matrix.height / @block_height
|
97
|
+
end
|
98
|
+
|
99
|
+
def normalize
|
100
|
+
@matrix = @matrix.extract(*@bounds.to_point_size)
|
101
|
+
translate(*@bounds.top_left)
|
102
|
+
|
103
|
+
if @orientation > 0
|
104
|
+
(4 - @orientation).times do
|
105
|
+
rotate
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
find_origin_corner(true)
|
110
|
+
end
|
111
|
+
|
112
|
+
def translate(x, y)
|
113
|
+
@angles = []
|
114
|
+
other_corners = non_origin_finder_patterns.map(&:destination)
|
115
|
+
|
116
|
+
@origin_corner = @origin_corner.translate(x, y)
|
117
|
+
translated = [@origin_corner]
|
118
|
+
translated += other_corners.map{|c| c.translate(x, y) }
|
119
|
+
|
120
|
+
@finder_patterns = translated
|
121
|
+
end
|
122
|
+
|
123
|
+
def rotate
|
124
|
+
@matrix = @matrix.rotate
|
125
|
+
@finder_patterns.map!{|f| f.rotate(@bounds.width, @bounds.height) }
|
126
|
+
end
|
127
|
+
|
128
|
+
def extracted_pixels
|
129
|
+
start_x = @origin_corner.center.first - (@block_width * 3.0)
|
130
|
+
start_y = @origin_corner.center.last - (@block_height * 3.0)
|
131
|
+
|
132
|
+
dest_x = @bottom_left.center.first - (@block_width * 3.0)
|
133
|
+
dest_y = @top_right.center.last - (@block_height * 3.0)
|
134
|
+
|
135
|
+
# TODO : take bottom right alignment pattern into consideration
|
136
|
+
dy = (dest_y - start_y) / logical_width
|
137
|
+
dx = (dest_x - start_x) / logical_height
|
138
|
+
|
139
|
+
logical_height.round.times do |row_index|
|
140
|
+
row_start_x = start_x + row_index * dx
|
141
|
+
row_start_y = start_y + (row_index * @block_height)
|
142
|
+
|
143
|
+
logical_width.round.times do |col_index|
|
144
|
+
x = row_start_x + (col_index * @block_width)
|
145
|
+
y = row_start_y + (col_index * dy)
|
146
|
+
|
147
|
+
yield x.round, y.round
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
data/lib/qrio/version.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
module Qrio
|
2
|
+
class VerticalMatch < FinderPatternSlice
|
3
|
+
def self.build(offset, origin, terminus)
|
4
|
+
new(offset, origin, offset, terminus)
|
5
|
+
end
|
6
|
+
|
7
|
+
def offset; left; end
|
8
|
+
def origin; top; end
|
9
|
+
def terminus; bottom; end
|
10
|
+
|
11
|
+
def length
|
12
|
+
height
|
13
|
+
end
|
14
|
+
|
15
|
+
def breadth
|
16
|
+
width
|
17
|
+
end
|
18
|
+
|
19
|
+
def left_of?(other)
|
20
|
+
right < other.left
|
21
|
+
end
|
22
|
+
|
23
|
+
def right_of?(other)
|
24
|
+
left > other.right
|
25
|
+
end
|
26
|
+
|
27
|
+
def offset_diff(other)
|
28
|
+
left_of?(other) ? other.left - right : left - other.right
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/qrio.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "qrio/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "qrio"
|
7
|
+
s.version = Qrio::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Solomon White"]
|
10
|
+
s.email = ["rubysolo@gmail.com"]
|
11
|
+
s.homepage = "http://github.com/rubysolo/qrio"
|
12
|
+
s.licenses = %w(MIT)
|
13
|
+
s.summary = %q(QR code decoder)
|
14
|
+
s.description = %q(QR code decoder in pure Ruby)
|
15
|
+
|
16
|
+
s.add_runtime_dependency "chunky_png"
|
17
|
+
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
20
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
21
|
+
s.require_paths = ["lib"]
|
22
|
+
end
|
23
|
+
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require_relative '../lib/qrio'
|
2
|
+
require 'test/unit'
|
3
|
+
|
4
|
+
class TestFinderPatternSlice < Test::Unit::TestCase
|
5
|
+
def test_ratio_matching
|
6
|
+
[
|
7
|
+
[ 1, 1, 3, 1, 1],
|
8
|
+
[10,10,30,10,10],
|
9
|
+
[11, 9,28,10,12],
|
10
|
+
[ 8, 8,19, 9, 8],
|
11
|
+
[11,12,34,13,12],
|
12
|
+
[12,20,46,19,14],
|
13
|
+
[10,13,32,14, 8],
|
14
|
+
[10,12,34,13, 7],
|
15
|
+
[11,13,32,14, 8],
|
16
|
+
].each{|b| assert_matches_ratio b }
|
17
|
+
|
18
|
+
(9..12).to_a.each do |a|
|
19
|
+
(11..13).to_a.each do |b|
|
20
|
+
(31..34).to_a.each do |c|
|
21
|
+
(13..15).to_a.each do |d|
|
22
|
+
(7..9).to_a.each do |e|
|
23
|
+
assert_matches_ratio [a, b, c, d, e]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_intersection_detection
|
32
|
+
slice1 = Qrio::HorizontalMatch.build(27, 16, 62)
|
33
|
+
slice1 = slice1.union Qrio::HorizontalMatch.build(45, 16, 62)
|
34
|
+
|
35
|
+
slice2 = Qrio::VerticalMatch.build(27, 16, 62)
|
36
|
+
slice2 = slice2.union Qrio::VerticalMatch.build(44, 16, 62)
|
37
|
+
|
38
|
+
assert slice1.length_matches?(slice2)
|
39
|
+
assert slice1.breadth_matches?(slice2)
|
40
|
+
assert slice1.intersects? slice2
|
41
|
+
|
42
|
+
slice1 = Qrio::HorizontalMatch.build(21, 5, 57)
|
43
|
+
slice1 = slice1.union Qrio::HorizontalMatch.build(35, 5, 57)
|
44
|
+
|
45
|
+
slice2 = Qrio::VerticalMatch.build(22, 3, 54)
|
46
|
+
slice2 = slice2.union Qrio::VerticalMatch.build(38, 3, 54)
|
47
|
+
|
48
|
+
assert slice1.intersects? slice2
|
49
|
+
end
|
50
|
+
|
51
|
+
=begin
|
52
|
+
def test_slice_ratio
|
53
|
+
correct = [
|
54
|
+
[16, 27, 62, 45],
|
55
|
+
[27, 16, 44, 62],
|
56
|
+
[10, 27, 62, 45],
|
57
|
+
[27, 10, 44, 62],
|
58
|
+
[ 5, 21, 57, 35]
|
59
|
+
].map{|a| @s.new(*a) }
|
60
|
+
|
61
|
+
correct.each do |slice|
|
62
|
+
assert slice.has_correct_ratio?, slice.ratio
|
63
|
+
end
|
64
|
+
|
65
|
+
incorrect = [
|
66
|
+
[16, 27, 62, 28],
|
67
|
+
[27, 16, 30, 62],
|
68
|
+
].map{|a| @s.new(*a) }
|
69
|
+
|
70
|
+
incorrect.each do |slice|
|
71
|
+
assert ! slice.has_correct_ratio?, slice.ratio
|
72
|
+
end
|
73
|
+
end
|
74
|
+
=end
|
75
|
+
|
76
|
+
def test_slice_builder
|
77
|
+
slice = Qrio::FinderPatternSlice.build_matching(
|
78
|
+
10, 5,
|
79
|
+
[1, 1, 3, 1, 1],
|
80
|
+
:horizontal
|
81
|
+
)
|
82
|
+
|
83
|
+
assert slice.is_a?(Qrio::HorizontalMatch)
|
84
|
+
assert_equal 10, slice.offset
|
85
|
+
assert_equal 5, slice.origin
|
86
|
+
assert_equal 11, slice.terminus
|
87
|
+
assert_equal 5, slice.left
|
88
|
+
assert_equal 11, slice.right
|
89
|
+
assert_equal 10, slice.top
|
90
|
+
assert_equal 10, slice.bottom
|
91
|
+
assert_equal 1, slice.height
|
92
|
+
assert_equal 7, slice.width
|
93
|
+
|
94
|
+
slice = Qrio::FinderPatternSlice.build_matching(
|
95
|
+
23, 15,
|
96
|
+
[1, 1, 3, 1, 1],
|
97
|
+
:vertical
|
98
|
+
)
|
99
|
+
|
100
|
+
assert slice.is_a?(Qrio::VerticalMatch)
|
101
|
+
assert_equal 23, slice.offset
|
102
|
+
assert_equal 15, slice.origin
|
103
|
+
assert_equal 21, slice.terminus
|
104
|
+
assert_equal 23, slice.left
|
105
|
+
assert_equal 23, slice.right
|
106
|
+
assert_equal 15, slice.top
|
107
|
+
assert_equal 21, slice.bottom
|
108
|
+
assert_equal 7, slice.height
|
109
|
+
assert_equal 1, slice.width
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def assert_matches_ratio(widths)
|
115
|
+
norm = Qrio::FinderPatternSlice.normalized_ratio(widths)
|
116
|
+
norm = norm.map{|n| '%.2f' % n }
|
117
|
+
msg = "Expected #{ widths.join('|') } [#{ norm.join('|') }]"
|
118
|
+
msg << " to match finder pattern ratio"
|
119
|
+
assert Qrio::FinderPatternSlice.matches_ratio?(widths), msg
|
120
|
+
end
|
121
|
+
end
|