kvg_character_recognition 0.1.2 → 0.1.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/kvg_character_recognition.gemspec +1 -2
- data/lib/kvg_character_recognition.rb +2 -3
- data/lib/kvg_character_recognition/datastore.rb +2 -2
- data/lib/kvg_character_recognition/preprocessor.rb +137 -60
- data/lib/kvg_character_recognition/recognizer.rb +15 -43
- data/lib/kvg_character_recognition/trainer.rb +44 -31
- data/lib/kvg_character_recognition/utils.rb +8 -0
- data/lib/kvg_character_recognition/version.rb +1 -1
- metadata +2 -17
- data/lib/kvg_character_recognition/feature_extractor.rb +0 -168
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d60d7f44902345773b5fff377783a490a41c7f06
|
4
|
+
data.tar.gz: 6b8e90de99d64c80ce576f969114cd02432fea58
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0ae3dda7c2114311a4613ed39b6e48e2e13a4b51619fb791c9476a0070a0dd49e60468fd63d7a7cb660ea52ea803e05cef48fbdd61f0c7a23c5c74141a54fc47
|
7
|
+
data.tar.gz: 6c43c6da5be2c25b6cf75ed364de496f8f87fb6f9bc0d8e2aaa0161e0e0ac159d2bdcf8805a36ff77f0ec52eab85fee697a093635a530dbfd779e7163f00f029
|
@@ -26,12 +26,11 @@ Gem::Specification.new do |spec|
|
|
26
26
|
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
27
27
|
end
|
28
28
|
|
29
|
-
spec.files = `git ls-files -z`.split("\x0").reject { |f| f == 'kvg_character_recognition-0.1.
|
29
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f == 'kvg_character_recognition-0.1.2.gem' || f.match(%r{^(test|spec|features)/}) }
|
30
30
|
spec.bindir = "exe"
|
31
31
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
32
32
|
spec.require_paths = ["lib"]
|
33
33
|
|
34
|
-
spec.add_dependency "parallel"
|
35
34
|
spec.add_dependency "nokogiri"
|
36
35
|
spec.add_dependency "bundler", "~> 1.10"
|
37
36
|
spec.add_development_dependency "rake", "~> 10.0"
|
@@ -10,9 +10,8 @@ module KvgCharacterRecognition
|
|
10
10
|
size: 109, #fixed canvas size of kanjivg data
|
11
11
|
downsample_interval: 4,
|
12
12
|
interpolate_distance: 0.8,
|
13
|
-
|
14
|
-
|
15
|
-
significant_points_heatmap_grid: 3
|
13
|
+
heatmap_coarse_grid: 17,
|
14
|
+
heatmap_granular_grid: 17,
|
16
15
|
}
|
17
16
|
VALID_KEYS = CONFIG.keys
|
18
17
|
|
@@ -16,8 +16,8 @@ module KvgCharacterRecognition
|
|
16
16
|
end
|
17
17
|
end
|
18
18
|
|
19
|
-
def
|
20
|
-
@data.select { |character|
|
19
|
+
def characters_in_range point_range, stroke_range
|
20
|
+
@data.select { |character| point_range === character[:number_of_points] && stroke_range === character[:number_of_strokes] }
|
21
21
|
end
|
22
22
|
|
23
23
|
def store character
|
@@ -9,7 +9,7 @@ module KvgCharacterRecognition
|
|
9
9
|
#Params:
|
10
10
|
#+stroke+:: array of points i.e [[x1, y1], [x2, y2] ...]
|
11
11
|
def self.smooth stroke
|
12
|
-
weights = [1,
|
12
|
+
weights = [1,1,2,1,1]
|
13
13
|
offset = weights.length / 2
|
14
14
|
wsum = weights.inject{ |sum, x| sum + x}
|
15
15
|
|
@@ -31,22 +31,44 @@ module KvgCharacterRecognition
|
|
31
31
|
end
|
32
32
|
|
33
33
|
#This method executes different preprocessing steps
|
34
|
-
#
|
34
|
+
#strokes are normalized
|
35
35
|
#1.Smooth strokes if set to true
|
36
36
|
#2.Interpolate points by given distance, in order to equalize the sample rate of input and template
|
37
37
|
#3.Downsample by given interval
|
38
38
|
def self.preprocess strokes, interpolate_distance=0.8, downsample_interval=4, smooth=true
|
39
|
-
means, diffs = means_and_diffs(strokes)
|
40
|
-
#normalize strokes
|
41
|
-
strokes = bi_moment_normalize(means, diffs, strokes)
|
42
|
-
|
43
39
|
strokes.map do |stroke|
|
44
40
|
stroke = smooth(stroke) if smooth
|
45
|
-
interpolated = interpolate(stroke, interpolate_distance)
|
41
|
+
interpolated = smooth(interpolate(stroke, interpolate_distance))
|
46
42
|
downsample(interpolated, downsample_interval)
|
47
43
|
end
|
48
44
|
end
|
49
45
|
|
46
|
+
# accumulated histogram needed by line density normalization
|
47
|
+
def self.accumulated_histogram points
|
48
|
+
grids = CONFIG[:size] + 1
|
49
|
+
h_x = []
|
50
|
+
h_y = []
|
51
|
+
(0..grids).each do |i|
|
52
|
+
h_x[i] = points.count{ |p| p[0].round == i }
|
53
|
+
h_y[i] = points.count{ |p| p[1].round == i }
|
54
|
+
h_x[i] = h_x[i] + h_x[i - 1] if i > 0
|
55
|
+
h_y[i] = h_y[i] + h_y[i - 1] if i > 0
|
56
|
+
end
|
57
|
+
|
58
|
+
[h_x, h_y]
|
59
|
+
end
|
60
|
+
|
61
|
+
# line density normalization
|
62
|
+
def self.line_density_normalize strokes
|
63
|
+
points = strokes.flatten(1)
|
64
|
+
h_x, h_y = accumulated_histogram points
|
65
|
+
strokes.map do |stroke|
|
66
|
+
stroke.map do |point|
|
67
|
+
[(CONFIG[:size] * h_x[point[0].round] / points.length.to_f).round(2), (CONFIG[:size] * h_y[point[1].round] / points.length.to_f).round(2)]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
50
72
|
#This method calculates means and diffs of x and y coordinates in the strokes
|
51
73
|
#The return values are used in the normalization step
|
52
74
|
#means, diffs = means_and_diffs strokes
|
@@ -59,16 +81,28 @@ module KvgCharacterRecognition
|
|
59
81
|
#means = [x_c, y_c]
|
60
82
|
means = sums.map{ |sum| (sum / points.length.to_f).round(2) }
|
61
83
|
|
62
|
-
|
63
|
-
[
|
84
|
+
#for slant correction
|
85
|
+
diff_x = []
|
86
|
+
diff_y = []
|
87
|
+
u11 = 0
|
88
|
+
u02 = 0
|
89
|
+
points.each do |point|
|
90
|
+
diff_x << point[0] - means[0]
|
91
|
+
diff_y << point[1] - means[1]
|
92
|
+
|
93
|
+
u11 += (point[0] - means[0]) * (point[1] - means[1])
|
94
|
+
u02 += (point[1] - means[1])**2
|
95
|
+
end
|
96
|
+
[means, [diff_x, diff_y], -1 * u11 / u02]
|
64
97
|
end
|
65
98
|
|
66
99
|
#This methods normalizes the strokes using bi moment
|
67
100
|
#Params:
|
68
101
|
#+strokes+:: [[[x1, y1], [x2, y2], ...], [[x1, y1], ...]]
|
69
|
-
#+
|
70
|
-
|
71
|
-
def self.bi_moment_normalize
|
102
|
+
#+slant_correction+:: boolean whether a slant correction should be performed
|
103
|
+
#returns normed_strokes, normed_strokes_with_slant_correction
|
104
|
+
def self.bi_moment_normalize strokes
|
105
|
+
means, diffs, slant_slope = means_and_diffs strokes
|
72
106
|
|
73
107
|
#calculating delta values
|
74
108
|
delta = Proc.new do |diff, operator|
|
@@ -87,65 +121,44 @@ module KvgCharacterRecognition
|
|
87
121
|
end
|
88
122
|
|
89
123
|
new_strokes = []
|
124
|
+
new_strokes_with_slant = []
|
125
|
+
|
90
126
|
strokes.each do |stroke|
|
91
127
|
new_stroke = []
|
128
|
+
new_stroke_slant = []
|
92
129
|
stroke.each do |point|
|
93
|
-
|
94
|
-
|
130
|
+
x = point[0]
|
131
|
+
y = point[1]
|
132
|
+
x_slant = x + (y - means[1]) * slant_slope
|
133
|
+
|
134
|
+
if x - means[0] >= 0
|
135
|
+
new_x = ( CONFIG[:size] * (x - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :>=))).round(2) ) + CONFIG[:size]/2
|
95
136
|
else
|
96
|
-
new_x = ( CONFIG[:size] * (
|
137
|
+
new_x = ( CONFIG[:size] * (x - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :<))).round(2) ) + CONFIG[:size]/2
|
97
138
|
end
|
98
|
-
if
|
99
|
-
|
139
|
+
if x_slant - means[0] >= 0
|
140
|
+
new_x_slant = ( CONFIG[:size] * (x_slant - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :>=))).round(2) ) + CONFIG[:size]/2
|
141
|
+
else
|
142
|
+
new_x_slant = ( CONFIG[:size] * (x_slant - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :<))).round(2) ) + CONFIG[:size]/2
|
143
|
+
end
|
144
|
+
|
145
|
+
if y - means[1] >= 0
|
146
|
+
new_y = ( CONFIG[:size] * (y - means[1]) / (4 * Math.sqrt(delta.call(diffs[1], :>=))).round(2) ) + CONFIG[:size]/2
|
100
147
|
else
|
101
|
-
new_y = ( CONFIG[:size] * (
|
148
|
+
new_y = ( CONFIG[:size] * (y - means[1]) / (4 * Math.sqrt(delta.call(diffs[1], :<))).round(2) ) + CONFIG[:size]/2
|
102
149
|
end
|
103
150
|
|
104
151
|
if new_x >= 0 && new_x <= CONFIG[:size] && new_y >= 0 && new_y <= CONFIG[:size]
|
105
152
|
new_stroke << [new_x.round(3), new_y.round(3)]
|
106
153
|
end
|
107
|
-
|
108
|
-
|
109
|
-
end
|
110
|
-
new_strokes
|
111
|
-
end
|
112
|
-
|
113
|
-
#This method returns the significant points of a given character
|
114
|
-
#Significant points are:
|
115
|
-
#- Start and end point of a stroke
|
116
|
-
#- Point on curve or edge
|
117
|
-
#To determine whether a point is on curve or edge, we take the 2 adjacent points and calculate the angle between the 2 vectors
|
118
|
-
#If the angle is smaller than 150 degree, then the point should be on curve or edge
|
119
|
-
def self.significant_points strokes
|
120
|
-
points = []
|
121
|
-
strokes.each_with_index do |stroke, i|
|
122
|
-
points << stroke[0]
|
123
|
-
|
124
|
-
#collect edge points
|
125
|
-
#determine whether a point is an edge point by the internal angle between vector P_i-1 - P_i and P_i+1 - P_i
|
126
|
-
pre = stroke[0]
|
127
|
-
(1..(stroke.length - 1)).each do |j|
|
128
|
-
current = stroke[j]
|
129
|
-
nex = stroke[j+1]
|
130
|
-
if nex
|
131
|
-
v1 = [pre[0] - current[0], pre[1] - current[1]]
|
132
|
-
v2 = [nex[0] - current[0], nex[1] - current[1]]
|
133
|
-
det = v1[0] * v2[1] - (v2[0] * v1[1])
|
134
|
-
dot = v1[0] * v2[0] + (v2[1] * v1[1])
|
135
|
-
angle = Math.atan2(det, dot) / (Math::PI / 180)
|
136
|
-
|
137
|
-
if angle.abs < 150
|
138
|
-
#current point is on a curve or an edge
|
139
|
-
points << current
|
140
|
-
end
|
154
|
+
if new_x_slant >= 0 && new_x_slant <= CONFIG[:size] && new_y >= 0 && new_y <= CONFIG[:size]
|
155
|
+
new_stroke_slant << [new_x_slant.round(3), new_y.round(3)]
|
141
156
|
end
|
142
|
-
pre = current
|
143
157
|
end
|
144
|
-
|
145
|
-
|
158
|
+
new_strokes << new_stroke unless new_stroke.empty?
|
159
|
+
new_strokes_with_slant << new_stroke_slant unless new_stroke_slant.empty?
|
146
160
|
end
|
147
|
-
|
148
|
-
points
|
161
|
+
[new_strokes, new_strokes_with_slant]
|
149
162
|
end
|
150
163
|
|
151
164
|
#This method interpolates points into a stroke with given distance
|
@@ -166,7 +179,7 @@ module KvgCharacterRecognition
|
|
166
179
|
|
167
180
|
#calculate new point coordinate
|
168
181
|
new_point = []
|
169
|
-
if point[0] == current[0] # x2 == x1
|
182
|
+
if point[0].round(2) == current[0].round(2) # x2 == x1
|
170
183
|
if point[1] > current[1] # y2 > y1
|
171
184
|
new_point = [current[0], current[1] + d]
|
172
185
|
else # y2 < y1
|
@@ -183,9 +196,11 @@ module KvgCharacterRecognition
|
|
183
196
|
end
|
184
197
|
|
185
198
|
new_point = new_point.map{ |num| num.round(2) }
|
186
|
-
|
199
|
+
if current != new_point
|
200
|
+
new_stroke << new_point
|
187
201
|
|
188
|
-
|
202
|
+
current = new_point
|
203
|
+
end
|
189
204
|
last_index += ((index - last_index) / 2).floor
|
190
205
|
index = last_index + 1
|
191
206
|
end
|
@@ -199,5 +214,67 @@ module KvgCharacterRecognition
|
|
199
214
|
def self.downsample stroke, interval=3
|
200
215
|
stroke.each_slice(interval).map(&:first)
|
201
216
|
end
|
217
|
+
|
218
|
+
#This methods generates a heatmap for the given character pattern
|
219
|
+
#A heatmap divides the input character pattern(image of the character) into nxn grids
|
220
|
+
#We count the points in each grid and store the number in a map
|
221
|
+
#The map array can be used as feature
|
222
|
+
#Params:
|
223
|
+
#+points+:: flattened strokes i.e. [[x1, y1], [x2, y2]...] because the seperation of points in strokes is irrelevant in this case
|
224
|
+
#+grid+:: number of grids
|
225
|
+
def self.heatmap points, grid, size
|
226
|
+
|
227
|
+
grid_size = size / grid.to_f
|
228
|
+
|
229
|
+
map = Map.new grid, grid, 0
|
230
|
+
|
231
|
+
#fill the heatmap
|
232
|
+
points.each do |point|
|
233
|
+
if point[0] < size && point[1] < size
|
234
|
+
x_i = (point[0] / grid_size).floor if point[0] < size
|
235
|
+
y_i = (point[1] / grid_size).floor if point[1] < size
|
236
|
+
|
237
|
+
map[y_i, x_i] += (1 / points.length.to_f).round(4)
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
map
|
242
|
+
end
|
243
|
+
#This method smooths a heatmap using spatial_weight_filter technique
|
244
|
+
#but instead of taking every 2nd grid, it processes every grid and stores the average of the weighted sum of adjacent grids
|
245
|
+
#Params:
|
246
|
+
#+map+:: a heatmap
|
247
|
+
def self.smooth_heatmap map
|
248
|
+
grid = map.size
|
249
|
+
#map is a heatmap
|
250
|
+
new_map = Map.new(grid, grid, 0)
|
251
|
+
|
252
|
+
(0..(grid - 1)).each do |i|
|
253
|
+
(0..(grid - 1)).each do |j|
|
254
|
+
#weights alternative
|
255
|
+
# = [1/16, 2/16, 1/16];
|
256
|
+
# [2/16, 4/16, 2/16];
|
257
|
+
# [1/16, 2/16, 1/16]
|
258
|
+
#
|
259
|
+
#weights = [1/9, 1/9, 1/9];
|
260
|
+
# [1/9, 1/9, 1/9];
|
261
|
+
# [1/9, 1/9, 1/9]
|
262
|
+
#
|
263
|
+
w11 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j-1)? map[i+1,j-1] * 1 / 9.0 : 0
|
264
|
+
w12 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j)? map[i+1,j] * 1 / 9.0 : 0
|
265
|
+
w13 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j+1)? map[i+1,j+1] * 1 / 9.0 : 0
|
266
|
+
w21 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j-1)? map[i,j-1] * 1 / 9.0 : 0
|
267
|
+
w22 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j)? map[i,j] * 1 / 9.0 : 0
|
268
|
+
w23 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j+1)? map[i,j+1] * 1 / 9.0 : 0
|
269
|
+
w31 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j-1)? map[i-1,j-1] * 1 / 9.0 : 0
|
270
|
+
w32 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j)? map[i-1,j] * 1 / 9.0 : 0
|
271
|
+
w33 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j+1)? map[i-1,j+1] * 1 / 9.0 : 0
|
272
|
+
|
273
|
+
new_map[i,j] = (w11 + w12 + w13 + w21 + w22 + w23 + w31 + w32 + w33).round(4)
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
new_map
|
278
|
+
end
|
202
279
|
end
|
203
280
|
end
|
@@ -1,34 +1,16 @@
|
|
1
1
|
require 'matrix'
|
2
|
-
require 'parallel'
|
3
2
|
module KvgCharacterRecognition
|
4
3
|
#This class contains methods calculating similarity scores between input pattern and template patterns
|
5
4
|
module Recognizer
|
6
|
-
@thread_count = 10
|
7
5
|
|
8
6
|
#This method selects all templates from the database which should be further examined
|
9
|
-
#It filtered out those characters with a too great difference in number of strokes to the input character
|
10
|
-
def self.select_templates
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
#This method uses heatmap of significant points to coarse recognize the input pattern
|
17
|
-
#Params:
|
18
|
-
#+strokes+:: strokes should be preprocessed
|
19
|
-
#+datastore+:: JSONDatastore or custom datastore type having method characters_in_stroke_range(min..max)
|
20
|
-
def self.coarse_recognize strokes, datastore
|
21
|
-
heatmap = FeatureExtractor.heatmap(Preprocessor.significant_points(strokes), CONFIG[:significant_points_heatmap_grid], CONFIG[:size]).to_a
|
22
|
-
|
23
|
-
templates = select_templates strokes, datastore
|
24
|
-
|
25
|
-
# Use threads to accelerate the process
|
26
|
-
Parallel.map(templates, in_threads: @thread_count) do |candidate|
|
27
|
-
candidate_heatmap = candidate[:heatmap_significant_points].split(",").map(&:to_f)
|
28
|
-
|
29
|
-
score = Math.euclidean_distance(heatmap, candidate_heatmap)
|
30
|
-
[score.round(3), candidate]
|
31
|
-
end
|
7
|
+
#It filtered out those characters with a too great difference in number of points and strokes to the input character
|
8
|
+
def self.select_templates character, datastore
|
9
|
+
p_min = character.number_of_points - 100
|
10
|
+
p_max = character.number_of_points + 100
|
11
|
+
s_min = character.number_of_strokes - 12
|
12
|
+
s_max = character.number_of_strokes + 12
|
13
|
+
datastore.characters_in_range(p_min..p_max, s_min..s_max)
|
32
14
|
end
|
33
15
|
|
34
16
|
#This method calculates similarity scores which is an average of the somehow weighted sum of the euclidean distance of
|
@@ -38,28 +20,18 @@ module KvgCharacterRecognition
|
|
38
20
|
#+strokes+:: strokes are not preprocessed
|
39
21
|
#+datastore+:: JSONDatastore or custom datastore type having method characters_in_stroke_range(min..max)
|
40
22
|
def self.scores strokes, datastore
|
41
|
-
|
42
|
-
|
43
|
-
strokes = Preprocessor.preprocess(strokes, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], true)
|
44
|
-
|
45
|
-
#feature extraction
|
46
|
-
directions = Matrix.columns(FeatureExtractor.spatial_weight_filter(FeatureExtractor.directional_feature_densities(strokes, CONFIG[:direction_grid])).to_a).to_a
|
47
|
-
heatmap_smoothed = FeatureExtractor.smooth_heatmap(FeatureExtractor.heatmap(strokes.flatten(1), CONFIG[:smoothed_heatmap_grid], CONFIG[:size])).to_a
|
23
|
+
character = Trainer::Character.new(strokes, nil)
|
24
|
+
templates = select_templates character, datastore
|
48
25
|
|
49
|
-
|
50
|
-
#collection is in the form [[score, c1], [score, c2] ...]
|
51
|
-
collection = coarse_recognize(strokes, datastore).sort{ |a, b| a[0] <=> b[0] }
|
26
|
+
scores = templates.map do |cand|
|
52
27
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
Math.euclidean_distance(directions[3], cand[1][:direction_e4].split(",").map(&:to_f)) ) / 4
|
28
|
+
heatmap_bi_moment_score = Math.manhattan_distance(cand[:heatmap_smoothed_granular], character.heatmap_smoothed_granular)
|
29
|
+
heatmap_line_density_score = Math.manhattan_distance(cand[:heatmap_smoothed_coarse], character.heatmap_smoothed_coarse)
|
30
|
+
heatmap_bi_moment_slant_score = Math.manhattan_distance(cand[:heatmap_smoothed_granular_with_slant], character.heatmap_smoothed_granular_with_slant)
|
31
|
+
heatmap_line_density_slant_score = Math.manhattan_distance(cand[:heatmap_smoothed_coarse_with_slant], character.heatmap_smoothed_coarse_with_slant)
|
58
32
|
|
59
|
-
heatmap_score = Math.euclidean_distance(heatmap_smoothed, cand[1][:heatmap_smoothed].split(",").map(&:to_f))
|
60
33
|
|
61
|
-
|
62
|
-
[mix/2, cand[1]]
|
34
|
+
[[heatmap_bi_moment_score, heatmap_line_density_score, heatmap_bi_moment_slant_score, heatmap_line_density_slant_score].min, cand]
|
63
35
|
end
|
64
36
|
|
65
37
|
scores.sort{ |a, b| a[0] <=> b[0] }
|
@@ -1,5 +1,43 @@
|
|
1
1
|
module KvgCharacterRecognition
|
2
2
|
module Trainer
|
3
|
+
class Character
|
4
|
+
attr_accessor :value,
|
5
|
+
:number_of_strokes,
|
6
|
+
:number_of_points,
|
7
|
+
:strokes,
|
8
|
+
:line_density_preprocessed_strokes,
|
9
|
+
:line_density_preprocessed_strokes_with_slant,
|
10
|
+
:bi_moment_preprocessed_strokes,
|
11
|
+
:bi_moment_preprocessed_strokes_with_slant,
|
12
|
+
:heatmap_smoothed_coarse,
|
13
|
+
:heatmap_smoothed_granular,
|
14
|
+
:heatmap_smoothed_coarse_with_slant,
|
15
|
+
:heatmap_smoothed_granular_with_slant
|
16
|
+
def initialize strokes, value
|
17
|
+
@value = value
|
18
|
+
@strokes = strokes
|
19
|
+
@number_of_strokes = @strokes.count
|
20
|
+
smooth = @value ? false : true
|
21
|
+
bi_moment_normalized_strokes, bi_moment_normalized_strokes_with_slant = Preprocessor.bi_moment_normalize(@strokes)
|
22
|
+
@bi_moment_preprocessed_strokes = Preprocessor.preprocess(bi_moment_normalized_strokes, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], smooth)
|
23
|
+
@bi_moment_preprocessed_strokes_with_slant = Preprocessor.preprocess(bi_moment_normalized_strokes_with_slant, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], smooth)
|
24
|
+
|
25
|
+
@number_of_points = @bi_moment_preprocessed_strokes.flatten(1).count
|
26
|
+
|
27
|
+
line_density_normalized_strokes = Preprocessor.line_density_normalize(@bi_moment_preprocessed_strokes)
|
28
|
+
@line_density_preprocessed_strokes = Preprocessor.preprocess(line_density_normalized_strokes, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], true)
|
29
|
+
line_density_normalized_strokes_with_slant = Preprocessor.line_density_normalize(@bi_moment_preprocessed_strokes_with_slant)
|
30
|
+
@line_density_preprocessed_strokes_with_slant = Preprocessor.preprocess(line_density_normalized_strokes_with_slant, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], true)
|
31
|
+
|
32
|
+
@heatmap_smoothed_coarse = Preprocessor.smooth_heatmap(Preprocessor.heatmap(@line_density_preprocessed_strokes.flatten(1), CONFIG[:heatmap_coarse_grid], CONFIG[:size])).to_a
|
33
|
+
@heatmap_smoothed_granular = Preprocessor.smooth_heatmap(Preprocessor.heatmap(@bi_moment_preprocessed_strokes.flatten(1), CONFIG[:heatmap_granular_grid], CONFIG[:size])).to_a
|
34
|
+
|
35
|
+
@heatmap_smoothed_coarse_with_slant = Preprocessor.smooth_heatmap(Preprocessor.heatmap(@line_density_preprocessed_strokes_with_slant.flatten(1), CONFIG[:heatmap_coarse_grid], CONFIG[:size])).to_a
|
36
|
+
@heatmap_smoothed_granular_with_slant = Preprocessor.smooth_heatmap(Preprocessor.heatmap(@bi_moment_preprocessed_strokes_with_slant.flatten(1), CONFIG[:heatmap_granular_grid], CONFIG[:size])).to_a
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
3
41
|
#This method populates the datastore with parsed template patterns from the kanjivg file in xml format
|
4
42
|
#Params:
|
5
43
|
#+xml+:: download the latest xml release from https://github.com/KanjiVG/kanjivg/releases
|
@@ -18,31 +56,8 @@ module KvgCharacterRecognition
|
|
18
56
|
#--------------
|
19
57
|
#parse strokes
|
20
58
|
strokes = kanji.xpath("g//path").map{|p| p.attributes["d"].value }.map{ |stroke| KvgParser::Stroke.new(stroke).to_a }
|
21
|
-
#strokes in the format [[[x1, y1], [x2, y2] ...], [[x2, y2], [x3, y3] ...], ...]
|
22
|
-
strokes = Preprocessor.preprocess(strokes, CONFIG[:interpolate_distance], CONFIG[:downsample_interval], false)
|
23
|
-
|
24
|
-
#serialize strokes
|
25
|
-
serialized = strokes.map.with_index do |stroke, i|
|
26
|
-
stroke.map{ |p| [i, p[0], p[1]] }
|
27
|
-
end
|
28
|
-
|
29
|
-
points = strokes.flatten(1)
|
30
|
-
|
31
|
-
#Feature Extraction
|
32
|
-
#--------------
|
33
|
-
#20x20 heatmap smoothed
|
34
|
-
heatmap_smoothed = FeatureExtractor.smooth_heatmap(FeatureExtractor.heatmap(points, CONFIG[:smoothed_heatmap_grid], CONFIG[:size]))
|
35
|
-
|
36
|
-
#directional feature densities
|
37
|
-
#transposed from Mx4 to 4xM
|
38
|
-
direction = Matrix.columns(FeatureExtractor.spatial_weight_filter(FeatureExtractor.directional_feature_densities(strokes, CONFIG[:direction_grid])).to_a).to_a
|
39
|
-
|
40
|
-
#significant points
|
41
|
-
significant_points = Preprocessor.significant_points(strokes)
|
42
|
-
|
43
|
-
#3x3 heatmap of significant points for coarse recognition
|
44
|
-
heatmap_significant_points = FeatureExtractor.heatmap(significant_points, CONFIG[:significant_points_heatmap_grid], CONFIG[:size])
|
45
59
|
|
60
|
+
chr = Character.new strokes, value
|
46
61
|
|
47
62
|
#Store to database
|
48
63
|
#--------------
|
@@ -50,13 +65,11 @@ module KvgCharacterRecognition
|
|
50
65
|
value: value,
|
51
66
|
codepoint: codepoint.hex,
|
52
67
|
number_of_strokes: strokes.count,
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
heatmap_smoothed: heatmap_smoothed.to_a.join(","),
|
59
|
-
heatmap_significant_points: heatmap_significant_points.to_a.join(",")
|
68
|
+
number_of_points: chr.number_of_points,
|
69
|
+
heatmap_smoothed_coarse: chr.heatmap_smoothed_coarse,
|
70
|
+
heatmap_smoothed_granular: chr.heatmap_smoothed_granular,
|
71
|
+
heatmap_smoothed_coarse_with_slant: chr.heatmap_smoothed_coarse_with_slant,
|
72
|
+
heatmap_smoothed_granular_with_slant: chr.heatmap_smoothed_granular_with_slant
|
60
73
|
}
|
61
74
|
|
62
75
|
datastore.store character
|
@@ -10,6 +10,14 @@ module Math
|
|
10
10
|
end
|
11
11
|
Math.sqrt( sum_of_squares )
|
12
12
|
end
|
13
|
+
|
14
|
+
def self.manhattan_distance(p1, p2)
|
15
|
+
sum = 0
|
16
|
+
p1.each_with_index do |p1_coord,index|
|
17
|
+
sum += (p1_coord - p2[index]).abs
|
18
|
+
end
|
19
|
+
sum
|
20
|
+
end
|
13
21
|
end
|
14
22
|
|
15
23
|
module KvgCharacterRecognition
|
metadata
CHANGED
@@ -1,29 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: kvg_character_recognition
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jiayi Zheng
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-01-
|
11
|
+
date: 2016-01-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
-
- !ruby/object:Gem::Dependency
|
14
|
-
name: parallel
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
16
|
-
requirements:
|
17
|
-
- - ">="
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: '0'
|
20
|
-
type: :runtime
|
21
|
-
prerelease: false
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
23
|
-
requirements:
|
24
|
-
- - ">="
|
25
|
-
- !ruby/object:Gem::Version
|
26
|
-
version: '0'
|
27
13
|
- !ruby/object:Gem::Dependency
|
28
14
|
name: nokogiri
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
@@ -118,7 +104,6 @@ files:
|
|
118
104
|
- kvg_character_recognition.gemspec
|
119
105
|
- lib/kvg_character_recognition.rb
|
120
106
|
- lib/kvg_character_recognition/datastore.rb
|
121
|
-
- lib/kvg_character_recognition/feature_extractor.rb
|
122
107
|
- lib/kvg_character_recognition/preprocessor.rb
|
123
108
|
- lib/kvg_character_recognition/recognizer.rb
|
124
109
|
- lib/kvg_character_recognition/trainer.rb
|
@@ -1,168 +0,0 @@
|
|
1
|
-
require 'matrix'
|
2
|
-
module KvgCharacterRecognition
|
3
|
-
#This class contains a collection of methods for extracting useful features
|
4
|
-
class FeatureExtractor
|
5
|
-
|
6
|
-
#This methods generates a heatmap for the given character pattern
|
7
|
-
#A heatmap divides the input character pattern(image of the character) into nxn grids
|
8
|
-
#We count the points in each grid and store the number in a map
|
9
|
-
#The map array can be used as feature
|
10
|
-
#Params:
|
11
|
-
#+points+:: flattened strokes i.e. [[x1, y1], [x2, y2]...] because the seperation of points in strokes is irrelevant in this case
|
12
|
-
#+grid+:: number of grids
|
13
|
-
def self.heatmap points, grid, size
|
14
|
-
|
15
|
-
grid_size = size / grid.to_f
|
16
|
-
|
17
|
-
map = Map.new grid, grid, 0
|
18
|
-
|
19
|
-
#fill the heatmap
|
20
|
-
points.each do |point|
|
21
|
-
if point[0] < size && point[1] < size
|
22
|
-
x_i = (point[0] / grid_size).floor if point[0] < size
|
23
|
-
y_i = (point[1] / grid_size).floor if point[1] < size
|
24
|
-
|
25
|
-
map[y_i, x_i] = map[y_i, x_i] + 1
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
map
|
30
|
-
end
|
31
|
-
|
32
|
-
#This method calculates the directional feature densities and stores them in a map
|
33
|
-
#The process and algorithm is described in the paper "On-line Recognition of Freely Handwritten Japanese Characters Using Directional Feature Densities" by Akinori Kawamura and co.
|
34
|
-
#Params:
|
35
|
-
#+strokes+:: [[[x1, y1], [x2, y2] ...], [[x1, y1], ...]]]
|
36
|
-
#+grid+:: number of grids in which the input character pattern should be seperated. Default is 15 as in the paper
|
37
|
-
def self.directional_feature_densities strokes, grid
|
38
|
-
#initialize a map for storing the weights in each directional space
|
39
|
-
map = Map.new grid, grid, [0, 0, 0, 0]
|
40
|
-
|
41
|
-
#step width
|
42
|
-
step = CONFIG[:size] / grid.to_f
|
43
|
-
|
44
|
-
strokes.each do |stroke|
|
45
|
-
current_p = stroke[0]
|
46
|
-
stroke.each do |point|
|
47
|
-
next if point == current_p
|
48
|
-
#map current point coordinate to map index
|
49
|
-
#i_x = xth column
|
50
|
-
#i_y = yth row
|
51
|
-
i_x = (current_p[0] / step).floor
|
52
|
-
i_y = (current_p[1] / step).floor
|
53
|
-
|
54
|
-
#direction vector V_ij = P_ij+1 - P_ij
|
55
|
-
v = [point[0] - current_p[0], point[1] - current_p[1]]
|
56
|
-
#store the sum of decomposed direction vectors in the corresponding grid
|
57
|
-
decomposed = decompose(v)
|
58
|
-
map[i_y, i_x] = [map[i_y, i_x][0] + decomposed[0],
|
59
|
-
map[i_y, i_x][1] + decomposed[1],
|
60
|
-
map[i_y, i_x][2] + decomposed[2],
|
61
|
-
map[i_y, i_x][3] + decomposed[3]]
|
62
|
-
end
|
63
|
-
end
|
64
|
-
map
|
65
|
-
end
|
66
|
-
|
67
|
-
#This method is a helper method for calculating directional feature density
|
68
|
-
#which decomposes the direction vector into predefined direction spaces
|
69
|
-
#- e1: [1, 0]
|
70
|
-
#- e2: [1/sqrt(2), 1/sqrt(2)]
|
71
|
-
#- e3: [0, 1]
|
72
|
-
#- e4: [-1/sqrt(2), 1/sqrt(2)]
|
73
|
-
#Params:
|
74
|
-
#+v+:: direction vector of 2 adjacent points V_ij = P_ij+1 - P_ij
|
75
|
-
def self.decompose v
|
76
|
-
e1 = [1, 0]
|
77
|
-
e2 = [1/Math.sqrt(2), 1/Math.sqrt(2)]
|
78
|
-
e3 = [0, 1]
|
79
|
-
e4 = [-1/Math.sqrt(2), 1/Math.sqrt(2)]
|
80
|
-
#angle between vector v and e1
|
81
|
-
#det = x1*y2 - x2*y1
|
82
|
-
#dot = x1*x2 + y1*y2
|
83
|
-
#atan2(det, dot) in range 0..180 and 0..-180
|
84
|
-
angle = (Math.atan2(v[1], v[0]) / (Math::PI / 180)).floor
|
85
|
-
if (0..44).cover?(angle) || (-180..-136).cover?(angle)
|
86
|
-
decomposed = [(Matrix.columns([e1, e2]).inverse * Vector.elements(v)).to_a, 0, 0].flatten
|
87
|
-
elsif (45..89).cover?(angle) || (-135..-91).cover?(angle)
|
88
|
-
decomposed = [0, (Matrix.columns([e2, e3]).inverse * Vector.elements(v)).to_a, 0].flatten
|
89
|
-
elsif (90..134).cover?(angle) || (-90..-44).cover?(angle)
|
90
|
-
decomposed = [0, 0, (Matrix.columns([e3, e4]).inverse * Vector.elements(v)).to_a].flatten
|
91
|
-
elsif (135..179).cover?(angle) || (-45..-1).cover?(angle)
|
92
|
-
tmp = (Matrix.columns([e4, e1]).inverse * Vector.elements(v)).to_a
|
93
|
-
decomposed = [tmp[0], 0, 0, tmp[1]]
|
94
|
-
end
|
95
|
-
|
96
|
-
decomposed
|
97
|
-
end
|
98
|
-
|
99
|
-
#This methods reduces the dimension of directonal feature densities stored in the map
|
100
|
-
#It takes every 2nd grid of directional_feature_densities map and stores the average of the weighted sum of adjacent grids around it
|
101
|
-
#weights = [1/16, 2/16, 1/16];
|
102
|
-
# [2/16, 4/16, 2/16];
|
103
|
-
# [1/16, 2/16, 1/16]
|
104
|
-
#Params:
|
105
|
-
#+map+:: directional feature densities map i.e. [[e1, e2, e3, e4], [e1, e2, e3, e4] ...] for each grid of input character pattern
|
106
|
-
def self.spatial_weight_filter map
|
107
|
-
#default grid should be 15
|
108
|
-
grid = map.size
|
109
|
-
new_grid = (grid / 2.0).ceil
|
110
|
-
new_map = Map.new(new_grid, new_grid, [0, 0, 0, 0])
|
111
|
-
|
112
|
-
(0..(grid - 1)).each_slice(2) do |i, i2|
|
113
|
-
(0..(grid - 1)).each_slice(2) do |j, j2|
|
114
|
-
#weights = [1/16, 2/16, 1/16];
|
115
|
-
# [2/16, 4/16, 2/16];
|
116
|
-
# [1/16, 2/16, 1/16]
|
117
|
-
w11 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j-1)? map[i+1,j-1].map{|e| e * 1 / 16.0} : [0, 0, 0, 0]
|
118
|
-
w12 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j)? map[i+1,j].map{|e| e * 2 / 16.0} : [0, 0, 0, 0]
|
119
|
-
w13 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j+1)? map[i+1,j+1].map{|e| e * 1 / 16.0} : [0, 0, 0, 0]
|
120
|
-
w21 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j-1)? map[i,j-1].map{|e| e * 2 / 16.0} : [0, 0, 0, 0]
|
121
|
-
w22 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j)? map[i,j].map{|e| e * 4 / 16.0} : [0, 0, 0, 0]
|
122
|
-
w23 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j+1)? map[i,j+1].map{|e| e * 2 / 16.0} : [0, 0, 0, 0]
|
123
|
-
w31 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j-1)? map[i-1,j-1].map{|e| e * 1 / 16.0} : [0, 0, 0, 0]
|
124
|
-
w32 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j)? map[i-1,j].map{|e| e * 2 / 16.0} : [0, 0, 0, 0]
|
125
|
-
w33 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j+1)? map[i-1,j+1].map{|e| e * 1 / 16.0} : [0, 0, 0, 0]
|
126
|
-
|
127
|
-
new_map[i/2,j/2] = [w11[0] + w12[0] + w13[0] + w21[0] + w22[0] + w23[0] + w31[0] + w32[0] + w33[0],
|
128
|
-
w11[1] + w12[1] + w13[1] + w21[1] + w22[1] + w23[1] + w31[1] + w32[1] + w33[1],
|
129
|
-
w11[2] + w12[2] + w13[2] + w21[2] + w22[2] + w23[2] + w31[2] + w32[2] + w33[2],
|
130
|
-
w11[3] + w12[3] + w13[3] + w21[3] + w22[3] + w23[3] + w31[3] + w32[3] + w33[3]]
|
131
|
-
end
|
132
|
-
end
|
133
|
-
|
134
|
-
new_map
|
135
|
-
end
|
136
|
-
|
137
|
-
#This method smooths a heatmap using spatial_weight_filter technique
|
138
|
-
#but instead of taking every 2nd grid, it processes every grid and stores the average of the weighted sum of adjacent grids
|
139
|
-
#Params:
|
140
|
-
#+map+:: a heatmap
|
141
|
-
def self.smooth_heatmap map
|
142
|
-
grid = map.size
|
143
|
-
#map is a heatmap
|
144
|
-
new_map = Map.new(grid, grid, 0)
|
145
|
-
|
146
|
-
(0..(grid - 1)).each do |i|
|
147
|
-
(0..(grid - 1)).each do |j|
|
148
|
-
#weights = [1/16, 2/16, 1/16];
|
149
|
-
# [2/16, 4/16, 2/16];
|
150
|
-
# [1/16, 2/16, 1/16]
|
151
|
-
w11 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j-1)? map[i+1,j-1] * 1 / 16.0 : 0
|
152
|
-
w12 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j)? map[i+1,j] * 2 / 16.0 : 0
|
153
|
-
w13 = (0..(grid-1)).cover?(i+1) && (0..(grid-1)).cover?(j+1)? map[i+1,j+1] * 1 / 16.0 : 0
|
154
|
-
w21 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j-1)? map[i,j-1] * 2 / 16.0 : 0
|
155
|
-
w22 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j)? map[i,j] * 4 / 16.0 : 0
|
156
|
-
w23 = (0..(grid-1)).cover?(i) && (0..(grid-1)).cover?(j+1)? map[i,j+1] * 2 / 16.0 : 0
|
157
|
-
w31 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j-1)? map[i-1,j-1] * 1 / 16.0 : 0
|
158
|
-
w32 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j)? map[i-1,j] * 2 / 16.0 : 0
|
159
|
-
w33 = (0..(grid-1)).cover?(i-1) && (0..(grid-1)).cover?(j+1)? map[i-1,j+1] * 1 / 16.0 : 0
|
160
|
-
|
161
|
-
new_map[i,j] = w11 + w12 + w13 + w21 + w22 + w23 + w31 + w32 + w33
|
162
|
-
end
|
163
|
-
end
|
164
|
-
|
165
|
-
new_map
|
166
|
-
end
|
167
|
-
end
|
168
|
-
end
|