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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 2f5bd00df050f48f3ee955daaa966fd04b10a5f3
4
- data.tar.gz: bb45a8db8023fb37f4f929a673f1f811756540fa
3
+ metadata.gz: d60d7f44902345773b5fff377783a490a41c7f06
4
+ data.tar.gz: 6b8e90de99d64c80ce576f969114cd02432fea58
5
5
  SHA512:
6
- metadata.gz: ae3a13b9370276c1714f19acebcf856cff091c27725514ae8f0a75f43ef754576d46bc69b7804d1203db5216524d0841e82b2deac7f29729ad5ebfe55441d080
7
- data.tar.gz: 057e9896548709b6fd4187ffcf8d576b2d49fa0fb19f6b1cc3909e75befde49149aa934d54ea9c6bbab48d394f4fdf708404287ec6eae6857dab3b53b7a09650
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.1.gem' || f.match(%r{^(test|spec|features)/}) }
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
- direction_grid: 15,
14
- smoothed_heatmap_grid: 20,
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 characters_in_stroke_range range
20
- @data.select { |character| range === character[:number_of_strokes] }
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,3,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
- #0.Normalize strokes to the size 109x109 and center the coordinates using bi moment normalization method
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
- diffs = points.inject([[], []]){ |acc, point| acc = [acc[0] << point[0] - means[0], acc[1] << point[1] - means[1]] }
63
- [means, diffs]
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
- #+means+:: [x_c, y_c]
70
- #+diffs+:: [d_x, d_y]; d_x = [d1, d2, ...]
71
- def self.bi_moment_normalize means, diffs, strokes
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
- if point[0] - means[0] >= 0
94
- new_x = ( CONFIG[:size] * (point[0] - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :>=))).round(2) ) + CONFIG[:size]/2
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] * (point[0] - means[0]) / (4 * Math.sqrt(delta.call(diffs[0], :<))).round(2) ) + CONFIG[:size]/2
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 point[1] - means[1] >= 0
99
- new_y = ( CONFIG[:size] * (point[1] - means[1]) / (4 * Math.sqrt(delta.call(diffs[1], :>=))).round(2) ) + CONFIG[:size]/2
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] * (point[1] - means[1]) / (4 * Math.sqrt(delta.call(diffs[1], :<))).round(2) ) + CONFIG[:size]/2
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
- end
108
- new_strokes << new_stroke unless new_stroke.empty?
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
- points << stroke[stroke.length - 1]
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
- new_stroke << new_point
199
+ if current != new_point
200
+ new_stroke << new_point
187
201
 
188
- current = new_point
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 strokes, datastore
11
- min = strokes.count <= 5 ? strokes.count : strokes.count - 5
12
- max = strokes.count + 10
13
- datastore.characters_in_stroke_range(min..max)
14
- end
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
- #preprocess strokes
42
- #with smoothing
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
- #dump half of the templates after coarse recognition
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
- scores = Parallel.map(collection.take(collection.count / 2)) do |cand|
54
- direction_score = (Math.euclidean_distance(directions[0], cand[1][:direction_e1].split(",").map(&:to_f)) +
55
- Math.euclidean_distance(directions[1], cand[1][:direction_e2].split(",").map(&:to_f)) +
56
- Math.euclidean_distance(directions[2], cand[1][:direction_e3].split(",").map(&:to_f)) +
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
- mix = (direction_score / 100) + heatmap_score
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
- serialized_strokes: serialized.join(","),
54
- direction_e1: direction[0].join(","),
55
- direction_e2: direction[1].join(","),
56
- direction_e3: direction[2].join(","),
57
- direction_e4: direction[3].join(","),
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
@@ -1,3 +1,3 @@
1
1
  module KvgCharacterRecognition
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
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.2
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-17 00:00:00.000000000 Z
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