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 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,11 @@
1
+ module UdiseCaptchaReader
2
+ module Paths
3
+ def self.root
4
+ @root ||= File.expand_path('../..', __dir__)
5
+ end
6
+
7
+ def self.samples
8
+ File.join(root, 'samples')
9
+ end
10
+ end
11
+ 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,3 @@
1
+ module UdiseCaptchaReader
2
+ VERSION = "0.1.0"
3
+ 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: []