kvg_character_recognition 0.1.2 → 0.1.3
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 +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
|