udise_captcha_reader 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/udise_captcha_reader/character_recognizer.rb +122 -0
- data/lib/udise_captcha_reader/character_splitter.rb +411 -0
- data/lib/udise_captcha_reader/image_preprocessor.rb +105 -0
- data/lib/udise_captcha_reader/paths.rb +11 -0
- data/lib/udise_captcha_reader/reader.rb +42 -0
- data/lib/udise_captcha_reader/version.rb +3 -0
- data/lib/udise_captcha_reader.rb +13 -0
- metadata +112 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7ad22c9c9ccbecfb3c8e6de058ea6aea678d217e8e4547ec245b2ff8daa2b4b3
|
4
|
+
data.tar.gz: d0a2015a3a1706582678e73199d90266a32bf1643a016578f3e86d5a22286270
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 858984958f5332fa431d16a228f781dda76a1a0d03b096511f8cda39aeaa790d684f2ab70ed60a04557c29a1987588749d42bd33d7254319f52aae3ec6368746
|
7
|
+
data.tar.gz: f068ac564918ba5dc593239a0a6a7516254bd6970432cfdccb637f3cfedbeeaea75338af9f087ccff984ef03a3d1f3a80e0739c0a74e33a93697dc59a15ae20a
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require "rtesseract"
|
2
|
+
require "mini_magick"
|
3
|
+
|
4
|
+
module UdiseCaptchaReader
|
5
|
+
class CharacterRecognizer
|
6
|
+
CHAR_SIZE = 32 # Target size for normalized characters
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@options = {
|
10
|
+
lang: "eng",
|
11
|
+
processor: "mini_magick",
|
12
|
+
psm: 10,
|
13
|
+
oem: 1
|
14
|
+
}.merge(options)
|
15
|
+
end
|
16
|
+
|
17
|
+
def recognize_character(image_file)
|
18
|
+
image = MiniMagick::Image.open(image_file.path)
|
19
|
+
|
20
|
+
# Normalize the character size
|
21
|
+
width = image.width
|
22
|
+
height = image.height
|
23
|
+
scale = CHAR_SIZE / [width, height].max.to_f
|
24
|
+
|
25
|
+
# Create a normalized version
|
26
|
+
image.combine_options do |b|
|
27
|
+
# Resize to standard size
|
28
|
+
b.resize "#{(width * scale).round}x#{(height * scale).round}"
|
29
|
+
|
30
|
+
# Center in a fixed-size canvas
|
31
|
+
b.background "white"
|
32
|
+
b.gravity "center"
|
33
|
+
b.extent "#{CHAR_SIZE}x#{CHAR_SIZE}"
|
34
|
+
|
35
|
+
# Convert to grayscale and adjust contrast
|
36
|
+
b.colorspace "gray"
|
37
|
+
b.brightness_contrast "10x20" # Slight brightness boost and contrast
|
38
|
+
|
39
|
+
# Clean threshold
|
40
|
+
b.black_threshold "80%"
|
41
|
+
b.white_threshold "20%"
|
42
|
+
end
|
43
|
+
|
44
|
+
# Save normalized image
|
45
|
+
temp_output = Tempfile.new(['norm_char', '.png'])
|
46
|
+
image.write(temp_output.path)
|
47
|
+
|
48
|
+
# Try recognition with specific configurations
|
49
|
+
results = []
|
50
|
+
|
51
|
+
# Configuration attempts
|
52
|
+
configs = [
|
53
|
+
{ psm: 10, whitelist: "0123456789abcdef" },
|
54
|
+
{ psm: 10, whitelist: "0123456789" }, # Numbers only
|
55
|
+
{ psm: 10, whitelist: "abcdef" } # Hex letters only
|
56
|
+
]
|
57
|
+
|
58
|
+
configs.each do |config|
|
59
|
+
result = RTesseract.new(
|
60
|
+
temp_output.path,
|
61
|
+
lang: @options[:lang],
|
62
|
+
processor: @options[:processor],
|
63
|
+
psm: config[:psm],
|
64
|
+
oem: 1,
|
65
|
+
options: {
|
66
|
+
"tessedit_char_whitelist" => config[:whitelist],
|
67
|
+
"tessedit_ocr_engine_mode" => "1",
|
68
|
+
"tessedit_pageseg_mode" => config[:psm].to_s,
|
69
|
+
"tessdata_dir" => "/usr/local/share/tessdata", # Ensure using latest tessdata
|
70
|
+
"load_system_dawg" => "0", # Disable dictionary
|
71
|
+
"load_freq_dawg" => "0" # Disable frequency-based corrections
|
72
|
+
}
|
73
|
+
).to_s.strip.downcase
|
74
|
+
|
75
|
+
results << result unless result.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
# Try with inverted image if no results
|
79
|
+
if results.empty?
|
80
|
+
image.negate
|
81
|
+
image.write(temp_output.path)
|
82
|
+
|
83
|
+
configs.each do |config|
|
84
|
+
result = RTesseract.new(
|
85
|
+
temp_output.path,
|
86
|
+
lang: @options[:lang],
|
87
|
+
processor: @options[:processor],
|
88
|
+
psm: config[:psm],
|
89
|
+
oem: 1,
|
90
|
+
options: {
|
91
|
+
"tessedit_char_whitelist" => config[:whitelist],
|
92
|
+
"tessedit_ocr_engine_mode" => "1",
|
93
|
+
"tessedit_pageseg_mode" => config[:psm].to_s,
|
94
|
+
"tessdata_dir" => "/usr/local/share/tessdata",
|
95
|
+
"load_system_dawg" => "0",
|
96
|
+
"load_freq_dawg" => "0"
|
97
|
+
}
|
98
|
+
).to_s.strip.downcase
|
99
|
+
|
100
|
+
results << result unless result.empty?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Clean up
|
105
|
+
temp_output.unlink
|
106
|
+
|
107
|
+
# Process results
|
108
|
+
char_counts = Hash.new(0)
|
109
|
+
results.each do |result|
|
110
|
+
next if result.empty?
|
111
|
+
char = result[0].downcase
|
112
|
+
# Map commonly confused characters
|
113
|
+
char = '1' if char == 'i' || char == 'l' || char == '|'
|
114
|
+
char = '0' if char == 'o'
|
115
|
+
char_counts[char] += 1 if char.match?(/^[a-f0-9]/)
|
116
|
+
end
|
117
|
+
|
118
|
+
return '' if char_counts.empty?
|
119
|
+
char_counts.max_by { |_, count| count }[0]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,411 @@
|
|
1
|
+
require "mini_magick"
|
2
|
+
|
3
|
+
module UdiseCaptchaReader
|
4
|
+
class CharacterSplitter
|
5
|
+
def self.split_into_characters(image, original_filename)
|
6
|
+
# Get image dimensions
|
7
|
+
width = image.width
|
8
|
+
height = image.height
|
9
|
+
|
10
|
+
# Create a gridded version of the image
|
11
|
+
gridded = MiniMagick::Image.create(".png") do |f|
|
12
|
+
image.format "png"
|
13
|
+
image.write f.path
|
14
|
+
end
|
15
|
+
|
16
|
+
# Convert to black and white for checking overlaps
|
17
|
+
bw_image = MiniMagick::Image.create(".png") do |f|
|
18
|
+
image.format "png"
|
19
|
+
image.write f.path
|
20
|
+
end
|
21
|
+
bw_image.combine_options do |b|
|
22
|
+
b.colorspace "gray"
|
23
|
+
b.threshold "50%"
|
24
|
+
end
|
25
|
+
|
26
|
+
# Track potential split positions (red lines)
|
27
|
+
potential_splits = []
|
28
|
+
|
29
|
+
# Draw grid and find splits
|
30
|
+
gridded.combine_options do |c|
|
31
|
+
c.strokewidth "0.5" # Made thinner
|
32
|
+
|
33
|
+
# Calculate horizontal line positions
|
34
|
+
horizontal_y_positions = 10.times.map { |i| ((i + 1) * height / 11.0).round }
|
35
|
+
|
36
|
+
# Draw oblique lines and find splits
|
37
|
+
100.times do |i|
|
38
|
+
x = ((i + 1) * width / 101.0).round # 101 segments = 100 lines
|
39
|
+
|
40
|
+
# Calculate end point for -15 degree angle
|
41
|
+
angle_rad = -15 * Math::PI / 180 # Convert -15 degrees to radians
|
42
|
+
x_offset = (height * Math.tan(angle_rad)).round
|
43
|
+
end_x = x + x_offset
|
44
|
+
|
45
|
+
# Check intersection points with horizontal lines
|
46
|
+
black_intersection_count = 0
|
47
|
+
horizontal_y_positions.each do |y|
|
48
|
+
# Calculate x position at this y level
|
49
|
+
progress = y.to_f / height
|
50
|
+
intersect_x = x + (x_offset * progress).round
|
51
|
+
next if intersect_x < 0 || intersect_x >= width
|
52
|
+
|
53
|
+
# Check 3-pixel wide column at intersection
|
54
|
+
intersection_has_black = false
|
55
|
+
(-1..1).each do |offset|
|
56
|
+
check_x = intersect_x + offset
|
57
|
+
next if check_x < 0 || check_x >= width
|
58
|
+
|
59
|
+
result = MiniMagick::Tool::Convert.new do |convert|
|
60
|
+
convert << bw_image.path
|
61
|
+
convert.crop("1x1+#{check_x}+#{y}")
|
62
|
+
convert << "txt:-"
|
63
|
+
end
|
64
|
+
|
65
|
+
if result.include?("#000000") || result.include?("black")
|
66
|
+
intersection_has_black = true
|
67
|
+
break
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
black_intersection_count += 1 if intersection_has_black
|
72
|
+
break if black_intersection_count > 1 # No need to check more if we're already over threshold
|
73
|
+
end
|
74
|
+
|
75
|
+
# Set color based on intersection checks - allow up to 1 black intersection
|
76
|
+
if black_intersection_count > 1
|
77
|
+
c.stroke "blue"
|
78
|
+
c.strokewidth "0.75" # Slightly thicker for overlapping lines
|
79
|
+
else
|
80
|
+
c.stroke "red"
|
81
|
+
c.strokewidth "0.5"
|
82
|
+
potential_splits << {x: x, end_x: end_x} if x > width * 0.10 && x < width * 0.90 # Store positions if not too close to edges
|
83
|
+
end
|
84
|
+
c.draw "line #{x},0 #{end_x},#{height}"
|
85
|
+
end
|
86
|
+
|
87
|
+
# Draw 10 horizontal black lines
|
88
|
+
c.stroke "black"
|
89
|
+
c.strokewidth "0.5"
|
90
|
+
horizontal_y_positions.each do |y|
|
91
|
+
c.draw "line 0,#{y} #{width},#{y}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Save gridded version
|
96
|
+
gridded_path = File.join(Dir.pwd, 'tmp', "#{original_filename}_gridded.png")
|
97
|
+
gridded.write(gridded_path)
|
98
|
+
|
99
|
+
# Group consecutive red lines
|
100
|
+
groups = []
|
101
|
+
current_group = []
|
102
|
+
|
103
|
+
potential_splits.each_with_index do |split, i|
|
104
|
+
if current_group.empty? || (split[:x] - potential_splits[i-1][:x]) < width * 0.02
|
105
|
+
current_group << split
|
106
|
+
else
|
107
|
+
groups << current_group if current_group.any?
|
108
|
+
current_group = [split]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
groups << current_group if current_group.any?
|
112
|
+
|
113
|
+
# Sort groups by size (largest first) and x position
|
114
|
+
sorted_groups = groups.sort_by { |group| [-group.size, group[0][:x]] }
|
115
|
+
|
116
|
+
# Create grouped image showing first and last lines of each group
|
117
|
+
grouped = MiniMagick::Image.create(".png") do |f|
|
118
|
+
image.format "png"
|
119
|
+
image.write f.path
|
120
|
+
end
|
121
|
+
|
122
|
+
# Define distinct colors for groups (rgba with 0.2 alpha for fill, rgb for stroke)
|
123
|
+
colors = [
|
124
|
+
{ fill: "rgba(255,0,0,0.2)", stroke: "rgb(255,0,0)" }, # Red
|
125
|
+
{ fill: "rgba(0,255,0,0.2)", stroke: "rgb(0,255,0)" }, # Green
|
126
|
+
{ fill: "rgba(0,0,255,0.2)", stroke: "rgb(0,0,255)" }, # Blue
|
127
|
+
{ fill: "rgba(255,165,0,0.2)", stroke: "rgb(255,165,0)" }, # Orange
|
128
|
+
{ fill: "rgba(128,0,128,0.2)", stroke: "rgb(128,0,128)" }, # Purple
|
129
|
+
{ fill: "rgba(0,255,255,0.2)", stroke: "rgb(0,255,255)" }, # Cyan
|
130
|
+
{ fill: "rgba(255,0,255,0.2)", stroke: "rgb(255,0,255)" }, # Magenta
|
131
|
+
{ fill: "rgba(128,128,0,0.2)", stroke: "rgb(128,128,0)" } # Olive
|
132
|
+
]
|
133
|
+
|
134
|
+
grouped.combine_options do |c|
|
135
|
+
# First draw the shaded regions
|
136
|
+
sorted_groups.each_with_index do |group, index|
|
137
|
+
color = colors[index % colors.size]
|
138
|
+
c.fill color[:fill]
|
139
|
+
c.stroke "none"
|
140
|
+
first_line = group.first
|
141
|
+
last_line = group.last
|
142
|
+
# Draw filled polygon between the lines
|
143
|
+
c.draw "polygon #{first_line[:x]},0 #{last_line[:x]},0 #{last_line[:end_x]},#{height} #{first_line[:end_x]},#{height}"
|
144
|
+
end
|
145
|
+
|
146
|
+
# Then draw the boundary lines on top
|
147
|
+
c.strokewidth "2" # Thicker lines to highlight group boundaries
|
148
|
+
sorted_groups.each_with_index do |group, index|
|
149
|
+
color = colors[index % colors.size]
|
150
|
+
c.stroke color[:stroke]
|
151
|
+
first_line = group.first
|
152
|
+
last_line = group.last
|
153
|
+
c.draw "line #{first_line[:x]},0 #{first_line[:end_x]},#{height}"
|
154
|
+
c.draw "line #{last_line[:x]},0 #{last_line[:end_x]},#{height}"
|
155
|
+
|
156
|
+
# Add text labels for x coordinates
|
157
|
+
c.pointsize "12"
|
158
|
+
c.fill color[:stroke]
|
159
|
+
c.draw "text #{first_line[:x]},15 '#{first_line[:x]}'"
|
160
|
+
c.draw "text #{last_line[:x]},30 '#{last_line[:x]}'"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Save grouped version
|
165
|
+
grouped_path = File.join(Dir.pwd, 'tmp', "#{original_filename}_grouped.png")
|
166
|
+
grouped.write(grouped_path)
|
167
|
+
|
168
|
+
# Take the largest groups that are well-spaced
|
169
|
+
final_separators = []
|
170
|
+
min_distance = width / 10
|
171
|
+
|
172
|
+
sorted_groups.each do |group|
|
173
|
+
# Find a line from within the group that's closest to the middle
|
174
|
+
middle_x = (group.first[:x] + group.last[:x]) / 2.0
|
175
|
+
middle_end_x = (group.first[:end_x] + group.last[:end_x]) / 2.0
|
176
|
+
chosen_line = {
|
177
|
+
x: middle_x,
|
178
|
+
end_x: middle_end_x
|
179
|
+
}
|
180
|
+
|
181
|
+
# Check if this line is well-spaced from existing separators
|
182
|
+
too_close = final_separators.any? { |sep| (sep[:x] - chosen_line[:x]).abs < min_distance }
|
183
|
+
next if too_close
|
184
|
+
|
185
|
+
final_separators << chosen_line
|
186
|
+
break if final_separators.size == 5
|
187
|
+
end
|
188
|
+
|
189
|
+
# Sort final separators by x position
|
190
|
+
final_separators.sort_by! { |sep| sep[:x] }
|
191
|
+
|
192
|
+
# If we don't have enough separators, fall back to equal spacing
|
193
|
+
if final_separators.size < 5
|
194
|
+
char_width = width / 6.0
|
195
|
+
angle_rad = -15 * Math::PI / 180
|
196
|
+
final_separators = 5.times.map do |i|
|
197
|
+
x = ((i + 1) * char_width).round
|
198
|
+
end_x = x + (height * Math.tan(angle_rad)).round
|
199
|
+
{x: x, end_x: end_x}
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Create marked image with only selected split lines
|
204
|
+
marked = MiniMagick::Image.create(".png") do |f|
|
205
|
+
image.format "png"
|
206
|
+
image.write f.path
|
207
|
+
end
|
208
|
+
|
209
|
+
marked.combine_options do |c|
|
210
|
+
c.stroke "red"
|
211
|
+
c.strokewidth "1" # Make split lines more visible
|
212
|
+
final_separators.each_with_index do |sep, index|
|
213
|
+
c.draw "line #{sep[:x]},0 #{sep[:end_x]},#{height}"
|
214
|
+
# Add text label for x coordinate
|
215
|
+
c.pointsize "12"
|
216
|
+
c.fill "red"
|
217
|
+
c.draw "text #{sep[:x]},15 '#{sep[:x].round}'"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
# Save marked version
|
222
|
+
marked_path = File.join(Dir.pwd, 'tmp', "#{original_filename}_marked.png")
|
223
|
+
marked.write(marked_path)
|
224
|
+
|
225
|
+
# Create array for character images
|
226
|
+
char_images = []
|
227
|
+
|
228
|
+
# Split positions including image edges
|
229
|
+
split_positions = [{x: 0, end_x: 0}] + final_separators + [{x: width, end_x: width}]
|
230
|
+
|
231
|
+
# Create tmp directory if it doesn't exist
|
232
|
+
tmp_dir = File.join(Dir.pwd, 'tmp')
|
233
|
+
FileUtils.mkdir_p(tmp_dir)
|
234
|
+
|
235
|
+
# Create character images from the segments
|
236
|
+
6.times do |i|
|
237
|
+
# Create path for split character in tmp folder
|
238
|
+
char_path = File.join(tmp_dir, "#{original_filename}_#{i + 1}.png")
|
239
|
+
|
240
|
+
# Get current and next split lines
|
241
|
+
current_split = split_positions[i]
|
242
|
+
next_split = split_positions[i + 1]
|
243
|
+
|
244
|
+
# Calculate bounding box that contains both lines
|
245
|
+
x_start = [current_split[:x], current_split[:end_x]].min
|
246
|
+
x_end = [next_split[:x], next_split[:end_x]].max
|
247
|
+
crop_width = x_end - x_start
|
248
|
+
|
249
|
+
# Create a new image for the character
|
250
|
+
char_image = MiniMagick::Image.create(".png") do |f|
|
251
|
+
image.format "png"
|
252
|
+
image.write f.path
|
253
|
+
end
|
254
|
+
|
255
|
+
# First crop the bounding box
|
256
|
+
char_image.combine_options do |c|
|
257
|
+
c.crop "#{crop_width}x#{height}+#{x_start}+0"
|
258
|
+
c.repage.+
|
259
|
+
end
|
260
|
+
|
261
|
+
# Create a mask for the area between the lines
|
262
|
+
mask = MiniMagick::Image.create(".png") do |f|
|
263
|
+
char_image.format "png"
|
264
|
+
char_image.write f.path
|
265
|
+
end
|
266
|
+
|
267
|
+
# Draw white polygons to mask out areas outside the lines
|
268
|
+
mask.combine_options do |c|
|
269
|
+
# Convert coordinates to be relative to cropped image
|
270
|
+
left_line_start_x = current_split[:x] - x_start
|
271
|
+
left_line_end_x = current_split[:end_x] - x_start
|
272
|
+
right_line_start_x = next_split[:x] - x_start
|
273
|
+
right_line_end_x = next_split[:end_x] - x_start
|
274
|
+
|
275
|
+
# Mask out area above/left of first line
|
276
|
+
c.draw "fill white stroke none polygon 0,0 #{left_line_start_x},0 #{left_line_end_x},#{height} 0,#{height}"
|
277
|
+
|
278
|
+
# Mask out area below/right of second line
|
279
|
+
c.draw "fill white stroke none polygon #{right_line_start_x},0 #{crop_width},0 #{crop_width},#{height} #{right_line_end_x},#{height}"
|
280
|
+
end
|
281
|
+
|
282
|
+
# Apply the mask to the character image
|
283
|
+
char_image = char_image.composite(mask) do |c|
|
284
|
+
c.compose "over"
|
285
|
+
end
|
286
|
+
|
287
|
+
# Save the character image to tmp directory
|
288
|
+
char_image.write(char_path)
|
289
|
+
char_images << File.new(char_path)
|
290
|
+
end
|
291
|
+
|
292
|
+
# Create a joined image from all split characters
|
293
|
+
joined = MiniMagick::Image.create(".png") do |f|
|
294
|
+
first_char = MiniMagick::Image.open(char_images.first.path)
|
295
|
+
first_char.format "png"
|
296
|
+
first_char.write f.path
|
297
|
+
end
|
298
|
+
|
299
|
+
# Calculate total width needed
|
300
|
+
total_width = char_images.sum do |char_file|
|
301
|
+
MiniMagick::Image.open(char_file.path).width
|
302
|
+
end
|
303
|
+
|
304
|
+
# Create blank white canvas
|
305
|
+
canvas = MiniMagick::Image.open(char_images.first.path)
|
306
|
+
canvas.combine_options do |c|
|
307
|
+
c.gravity "center"
|
308
|
+
c.background "white"
|
309
|
+
c.extent "#{total_width}x#{height}"
|
310
|
+
end
|
311
|
+
|
312
|
+
# Composite each character onto the canvas
|
313
|
+
x_offset = 0
|
314
|
+
char_images.each do |char_file|
|
315
|
+
char = MiniMagick::Image.open(char_file.path)
|
316
|
+
canvas = canvas.composite(char) do |c|
|
317
|
+
c.compose "over"
|
318
|
+
c.geometry "+#{x_offset}+0"
|
319
|
+
end
|
320
|
+
x_offset += char.width
|
321
|
+
end
|
322
|
+
|
323
|
+
# Save joined version
|
324
|
+
joined_path = File.join(Dir.pwd, 'tmp', "#{original_filename}_joined.png")
|
325
|
+
canvas.write(joined_path)
|
326
|
+
|
327
|
+
char_images
|
328
|
+
end
|
329
|
+
|
330
|
+
private
|
331
|
+
|
332
|
+
def self.find_vertical_separators(image)
|
333
|
+
# Convert to black and white for easier pixel analysis
|
334
|
+
image.combine_options do |b|
|
335
|
+
b.colorspace "gray"
|
336
|
+
b.threshold "50%"
|
337
|
+
end
|
338
|
+
|
339
|
+
# Get image dimensions
|
340
|
+
width = image.width
|
341
|
+
height = image.height
|
342
|
+
|
343
|
+
# Create a histogram of black pixels in each column
|
344
|
+
column_scores = []
|
345
|
+
|
346
|
+
# Score each column based on number of black pixels
|
347
|
+
width.times do |x|
|
348
|
+
# Use identify to check pixels in this column
|
349
|
+
result = MiniMagick::Tool::Convert.new do |convert|
|
350
|
+
convert << image.path
|
351
|
+
convert.crop("1x#{height}+#{x}+0")
|
352
|
+
convert << "txt:-"
|
353
|
+
end
|
354
|
+
|
355
|
+
# Count black pixels in the column
|
356
|
+
black_count = result.split("\n").count { |line| line.include?("black") }
|
357
|
+
column_scores << { position: x, score: black_count }
|
358
|
+
end
|
359
|
+
|
360
|
+
# Calculate moving average to smooth the scores
|
361
|
+
window_size = 3
|
362
|
+
smoothed_scores = []
|
363
|
+
|
364
|
+
column_scores.each_with_index do |score, i|
|
365
|
+
start_idx = [0, i - window_size/2].max
|
366
|
+
end_idx = [column_scores.size - 1, i + window_size/2].min
|
367
|
+
window = column_scores[start_idx..end_idx]
|
368
|
+
avg_score = window.sum { |w| w[:score] } / window.size.to_f
|
369
|
+
smoothed_scores << { position: score[:position], score: avg_score }
|
370
|
+
end
|
371
|
+
|
372
|
+
# Find local minima in the smoothed scores
|
373
|
+
local_minima = []
|
374
|
+
smoothed_scores.each_with_index do |score, i|
|
375
|
+
next if i == 0 || i == smoothed_scores.size - 1
|
376
|
+
|
377
|
+
prev_score = smoothed_scores[i-1][:score]
|
378
|
+
next_score = smoothed_scores[i+1][:score]
|
379
|
+
current_score = score[:score]
|
380
|
+
|
381
|
+
if current_score < prev_score && current_score < next_score
|
382
|
+
local_minima << score
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
# Sort local minima by score
|
387
|
+
sorted_minima = local_minima.sort_by { |m| m[:score] }
|
388
|
+
|
389
|
+
# Get the 5 best separator positions, excluding edges and too-close positions
|
390
|
+
separators = []
|
391
|
+
min_distance = width / 8 # Minimum distance between separators
|
392
|
+
|
393
|
+
sorted_minima.each do |minima|
|
394
|
+
pos = minima[:position]
|
395
|
+
|
396
|
+
# Skip if too close to edges
|
397
|
+
next if pos < width * 0.15 || pos > width * 0.85
|
398
|
+
|
399
|
+
# Skip if too close to existing separator
|
400
|
+
too_close = separators.any? { |sep| (sep - pos).abs < min_distance }
|
401
|
+
next if too_close
|
402
|
+
|
403
|
+
separators << pos
|
404
|
+
break if separators.size == 5
|
405
|
+
end
|
406
|
+
|
407
|
+
# Sort separators by position
|
408
|
+
separators.sort
|
409
|
+
end
|
410
|
+
end
|
411
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require "mini_magick"
|
2
|
+
require "fileutils"
|
3
|
+
|
4
|
+
module UdiseCaptchaReader
|
5
|
+
class ImagePreprocessor
|
6
|
+
def self.preprocess_image(image_path, approach)
|
7
|
+
# Get original filename for trimming
|
8
|
+
original_filename = File.basename(image_path, ".*")
|
9
|
+
|
10
|
+
# Open and process the image
|
11
|
+
image = MiniMagick::Image.open(image_path)
|
12
|
+
|
13
|
+
# First trim the whitespace and get the trimmed image
|
14
|
+
trimmed_path = File.join(Dir.pwd, 'tmp', "#{original_filename}_trimmed.png")
|
15
|
+
FileUtils.mkdir_p(File.dirname(trimmed_path))
|
16
|
+
|
17
|
+
# Clone and trim the image
|
18
|
+
trimmed = MiniMagick::Image.create(".png") do |f|
|
19
|
+
image.format "png"
|
20
|
+
image.write f.path
|
21
|
+
end
|
22
|
+
|
23
|
+
# Trim white space from left and right
|
24
|
+
trimmed.combine_options do |b|
|
25
|
+
b.fuzz "10%" # Allow some tolerance for off-white pixels
|
26
|
+
b.trim "+repage"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Save trimmed image
|
30
|
+
trimmed.write(trimmed_path)
|
31
|
+
|
32
|
+
# Open trimmed image for further processing
|
33
|
+
image = MiniMagick::Image.open(trimmed_path)
|
34
|
+
temp_output = Tempfile.new(['processed', '.png'])
|
35
|
+
|
36
|
+
case approach
|
37
|
+
when :split_characters
|
38
|
+
# Split characters preprocessing - optimized for white bg, black text
|
39
|
+
image.combine_options do |b|
|
40
|
+
b.resize "300%"
|
41
|
+
b.threshold "50%" # Simple threshold for already well-contrasted images
|
42
|
+
b.border "5x5"
|
43
|
+
b.bordercolor "white"
|
44
|
+
end
|
45
|
+
when :standard
|
46
|
+
# Standard preprocessing - optimized for white bg, black text
|
47
|
+
image.combine_options do |b|
|
48
|
+
b.resize "250%"
|
49
|
+
b.threshold "50%" # Simple threshold for black text
|
50
|
+
b.border "5x5"
|
51
|
+
b.bordercolor "white"
|
52
|
+
end
|
53
|
+
when :high_contrast
|
54
|
+
# High contrast preprocessing - optimized for white bg, black text
|
55
|
+
image.combine_options do |b|
|
56
|
+
b.resize "300%"
|
57
|
+
b.threshold "50%" # Simple threshold for black text
|
58
|
+
b.border "10x10"
|
59
|
+
b.bordercolor "white"
|
60
|
+
end
|
61
|
+
when :character_separation
|
62
|
+
# Character separation focused preprocessing
|
63
|
+
image.combine_options do |b|
|
64
|
+
b.resize "400%"
|
65
|
+
b.threshold "50%" # Simple threshold for black text
|
66
|
+
b.border "15x15"
|
67
|
+
b.bordercolor "white"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
image.write(temp_output.path)
|
72
|
+
temp_output
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.trim_whitespace(image)
|
76
|
+
# Create tmp directory if it doesn't exist
|
77
|
+
tmp_dir = File.join(Dir.pwd, 'tmp')
|
78
|
+
FileUtils.mkdir_p(tmp_dir)
|
79
|
+
|
80
|
+
# Get original filename from the image path
|
81
|
+
original_filename = File.basename(image.path, ".*")
|
82
|
+
|
83
|
+
# Create path for trimmed image in tmp folder
|
84
|
+
trimmed_path = File.join(tmp_dir, "#{original_filename}_trimmed.png")
|
85
|
+
|
86
|
+
# Clone the image to avoid modifying the original
|
87
|
+
trimmed = MiniMagick::Image.create(".png") do |f|
|
88
|
+
image.format "png"
|
89
|
+
image.write f.path
|
90
|
+
end
|
91
|
+
|
92
|
+
# Trim white space from left and right
|
93
|
+
trimmed.combine_options do |b|
|
94
|
+
b.fuzz "10%" # Allow some tolerance for off-white pixels
|
95
|
+
b.trim "+repage"
|
96
|
+
end
|
97
|
+
|
98
|
+
# Save to tmp folder
|
99
|
+
trimmed.write(trimmed_path)
|
100
|
+
|
101
|
+
# Return as a File object
|
102
|
+
File.new(trimmed_path)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative "image_preprocessor"
|
2
|
+
require_relative "character_splitter"
|
3
|
+
require_relative "character_recognizer"
|
4
|
+
|
5
|
+
module UdiseCaptchaReader
|
6
|
+
class Reader
|
7
|
+
def initialize(options = {})
|
8
|
+
@options = {
|
9
|
+
lang: "eng",
|
10
|
+
processor: "mini_magick",
|
11
|
+
psm: 7, # Treat image as a single text line
|
12
|
+
oem: 1 # Use LSTM OCR Engine
|
13
|
+
}.merge(options)
|
14
|
+
|
15
|
+
@recognizer = CharacterRecognizer.new(@options)
|
16
|
+
end
|
17
|
+
|
18
|
+
def read_text(image_path)
|
19
|
+
raise Error, "Image file not found" unless File.exist?(image_path)
|
20
|
+
|
21
|
+
# Get original filename without extension
|
22
|
+
original_filename = File.basename(image_path, ".*")
|
23
|
+
|
24
|
+
# First preprocess the image
|
25
|
+
processed = ImagePreprocessor.preprocess_image(image_path, :standard)
|
26
|
+
|
27
|
+
# Open the processed image for splitting
|
28
|
+
image = MiniMagick::Image.open(processed.path)
|
29
|
+
|
30
|
+
# Split into characters
|
31
|
+
char_images = CharacterSplitter.split_into_characters(image, original_filename)
|
32
|
+
|
33
|
+
# Recognize each character
|
34
|
+
recognized_chars = char_images.map { |char_image| @recognizer.recognize_character(char_image) }
|
35
|
+
|
36
|
+
# Join the characters
|
37
|
+
recognized_chars.join
|
38
|
+
rescue RTesseract::Error => e
|
39
|
+
raise Error, "OCR processing failed: #{e.message}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "rtesseract"
|
2
|
+
require "mini_magick"
|
3
|
+
require "fileutils"
|
4
|
+
require_relative "udise_captcha_reader/version"
|
5
|
+
require_relative "udise_captcha_reader/paths"
|
6
|
+
require_relative "udise_captcha_reader/image_preprocessor"
|
7
|
+
require_relative "udise_captcha_reader/character_splitter"
|
8
|
+
require_relative "udise_captcha_reader/character_recognizer"
|
9
|
+
require_relative "udise_captcha_reader/reader"
|
10
|
+
|
11
|
+
module UdiseCaptchaReader
|
12
|
+
class Error < StandardError; end
|
13
|
+
end
|
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: udise_captcha_reader
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Syed Fazil Basheer
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-01-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rtesseract
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: mini_magick
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '4.12'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '4.12'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: test-unit
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.5'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.5'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '13.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '13.0'
|
69
|
+
description: A Ruby gem that uses Tesseract OCR to extract text from UDISE captcha
|
70
|
+
images. This gem helps in automating UDISE-related tasks by providing captcha reading
|
71
|
+
capabilities.
|
72
|
+
email:
|
73
|
+
- fazil@fazn.co
|
74
|
+
executables: []
|
75
|
+
extensions: []
|
76
|
+
extra_rdoc_files: []
|
77
|
+
files:
|
78
|
+
- lib/udise_captcha_reader.rb
|
79
|
+
- lib/udise_captcha_reader/character_recognizer.rb
|
80
|
+
- lib/udise_captcha_reader/character_splitter.rb
|
81
|
+
- lib/udise_captcha_reader/image_preprocessor.rb
|
82
|
+
- lib/udise_captcha_reader/paths.rb
|
83
|
+
- lib/udise_captcha_reader/reader.rb
|
84
|
+
- lib/udise_captcha_reader/version.rb
|
85
|
+
homepage: https://github.com/UDISE-Plus/udise_captcha_reader
|
86
|
+
licenses:
|
87
|
+
- MIT
|
88
|
+
metadata:
|
89
|
+
homepage_uri: https://github.com/UDISE-Plus/udise_captcha_reader
|
90
|
+
source_code_uri: https://github.com/UDISE-Plus/udise_captcha_reader
|
91
|
+
changelog_uri: https://github.com/UDISE-Plus/udise_captcha_reader/blob/main/CHANGELOG.md
|
92
|
+
bug_tracker_uri: https://github.com/UDISE-Plus/udise_captcha_reader/issues
|
93
|
+
post_install_message:
|
94
|
+
rdoc_options: []
|
95
|
+
require_paths:
|
96
|
+
- lib
|
97
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: 2.6.0
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
|
+
requirements:
|
104
|
+
- - ">="
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: '0'
|
107
|
+
requirements: []
|
108
|
+
rubygems_version: 3.5.11
|
109
|
+
signing_key:
|
110
|
+
specification_version: 4
|
111
|
+
summary: Extract text from UDISE captcha images using OCR
|
112
|
+
test_files: []
|