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 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: []