udise_captcha_reader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/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: []
|