├── requirements.txt ├── .gitignore ├── images ├── make_white.py └── make_transparent.py ├── ayat ├── marker_remover_v2.py ├── find_ayat_v2.py ├── header_remover.py ├── marker_remover.py └── ayat.py ├── loop.py ├── find_errors.pl ├── lines └── lines.py ├── main.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | numpy 3 | pillow 4 | opencv-python 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | wip 3 | output 4 | virtualenv 5 | .idea 6 | .envrc 7 | out 8 | orig 9 | transparent 10 | -------------------------------------------------------------------------------- /images/make_white.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PIL import Image 4 | 5 | # this script adds a white background to an image 6 | if len(sys.argv) != 3: 7 | print("usage: " + sys.argv[0] + " [image] [output]") 8 | sys.exit(1) 9 | 10 | img = Image.open(sys.argv[1]).convert('RGBA') 11 | width, height = img.size 12 | bg = Image.new(img.mode, img.size, (255, 255, 255)) 13 | i = Image.alpha_composite(bg, img) 14 | i.save(sys.argv[2]) 15 | -------------------------------------------------------------------------------- /ayat/marker_remover_v2.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import cv2 3 | import numpy as np 4 | 5 | from find_ayat_v2 import find_ayat 6 | 7 | 8 | def remove_markers(rgb_filename, gray_filename, output): 9 | img_rgb = cv2.imread(rgb_filename) 10 | (ayat, contours) = find_ayat(img_rgb) 11 | mask = np.ones(img_rgb.shape[:2], dtype="uint8") * 255 12 | cv2.drawContours(mask, contours, -1, 0, -1) 13 | cv2.drawContours(mask, contours, -1, 0, 2) 14 | # cv2.imshow("mask", mask) 15 | # cv2.waitKey(0) 16 | 17 | img_gray = cv2.imread(gray_filename, flags=cv2.IMREAD_UNCHANGED) 18 | img_gray = cv2.bitwise_and(img_gray, img_gray, mask=mask) 19 | # cv2.imshow("image", img_gray) 20 | # cv2.waitKey(0) 21 | 22 | print(output) 23 | cv2.imwrite(output, img_gray) 24 | 25 | 26 | def main(): 27 | rgb_filename = sys.argv[1] 28 | gray_filename = sys.argv[2] 29 | output = sys.argv[3] 30 | remove_markers(rgb_filename, gray_filename, output) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /loop.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import cv2 3 | from PIL import Image 4 | 5 | from ayat.find_ayat_v2 import find_ayat, draw 6 | from lines.lines import find_lines 7 | 8 | 9 | def verify_lines(image_dir, filename): 10 | image = Image.open(image_dir + filename).convert('RGBA') 11 | lines = find_lines(image, 110, 35, 0) 12 | if len(lines) != 15: 13 | print('failure: found %d lines on %s' % (len(lines), filename)) 14 | 15 | 16 | def count_ayat(image_dir, filename): 17 | img_rgb = cv2.imread(image_dir + filename) 18 | (ayat, contours) = find_ayat(img_rgb) 19 | print('found: %d in %s' % (len(ayat), filename)) 20 | draw(img_rgb, contours, 'out/' + filename) 21 | return ayat 22 | 23 | 24 | def main(): 25 | total = 0 26 | image_dir = sys.argv[1] + '/' 27 | prefix = 'page' 28 | 29 | for i in range(1, 605): 30 | filename = prefix + str(i).zfill(3) + '.jpg' 31 | print('processing %s' % filename) 32 | verify_lines(image_dir, filename) 33 | 34 | ayat = count_ayat(image_dir, filename) 35 | total = total + len(ayat) 36 | print('found a total of %d ayat.' % total) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /images/make_transparent.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import numpy as np 3 | import sys 4 | 5 | # this script is from http://stackoverflow.com/questions/5365589 6 | # it converts a white background to be transparent 7 | if len(sys.argv) != 3: 8 | print("usage: " + sys.argv[0] + " [image] [output]") 9 | sys.exit(1) 10 | 11 | # split the number into r, g, b 12 | # threshold is how high each of r/g/b must be considered to 13 | # remove it (keeping in mind that white is 255). 14 | threshold = 200 15 | 16 | # in theory, lowering dist ensures more gray colors 17 | # are eliminated - but in practice, i found that ignoring 18 | # the dist parameter returned better results. 19 | dist = 5 20 | use_dist = False 21 | 22 | img = Image.open(sys.argv[1]).convert('RGBA') 23 | # np.asarray(img) is read only. Wrap it in np.array to make it modifiable. 24 | arr = np.array(np.asarray(img)) 25 | r, g, b, a = np.rollaxis(arr, axis=-1) 26 | mask = ((r > threshold) 27 | & (g > threshold) 28 | & (b > threshold)) 29 | if use_dist: 30 | mask = (mask 31 | & (np.abs(r - g) < dist) 32 | & (np.abs(r - b) < dist) 33 | & (np.abs(g - b) < dist)) 34 | arr[mask, 3] = 0 35 | img = Image.fromarray(arr, mode='RGBA') 36 | img.save(sys.argv[2]) 37 | -------------------------------------------------------------------------------- /find_errors.pl: -------------------------------------------------------------------------------- 1 | #! /usr/bin/perl 2 | 3 | @hafs_counts = (7, 286, 200, 176, 120, 165, 206, 75, 129, 109, 123, 111, 4 | 43, 52, 99, 128, 111, 110, 98, 135, 112, 78, 118, 64, 77, 5 | 227, 93, 88, 69, 60, 34, 30, 73, 54, 45, 83, 182, 88, 75, 6 | 85, 54, 53, 89, 59, 37, 35, 38, 29, 18, 45, 60, 49, 62, 55, 7 | 78, 96, 29, 22, 24, 13, 14, 11, 11, 18, 12, 12, 30, 52, 52, 8 | 44, 28, 28, 20, 56, 40, 31, 50, 40, 46, 42, 29, 19, 36, 25, 9 | 22, 17, 19, 26, 30, 20, 15, 21, 11, 8, 8, 19, 5, 8, 8, 11, 10 | 11, 8, 3, 9, 5, 4, 7, 3, 6, 3, 5, 4, 5, 6); 11 | 12 | @warsh_counts = (7, 285, 200, 175, 122, 167, 206, 76, 130, 109, 121, 111, 13 | 44, 54, 99, 128, 110, 105, 99, 134, 111, 76, 119, 62, 77, 14 | 226, 95, 88, 69, 59, 33, 30, 73, 54, 46, 82, 182, 86, 72, 15 | 84, 53, 50, 89, 56, 36, 34, 39, 29, 18, 45, 60, 47, 61, 55, 16 | 77, 99, 28, 21, 24, 13, 14, 11, 11, 18, 12, 12, 31, 52, 52, 17 | 44, 30, 28, 18, 55, 39, 31, 50, 40, 45, 42, 29, 19, 36, 25, 18 | 22, 17, 19, 26, 32, 20, 15, 21, 11, 8, 8, 20, 5, 8, 9, 11, 19 | 10, 8, 3, 9, 5, 5, 6, 3, 6, 3, 5, 4, 5, 6); 20 | 21 | # important! 22 | @counts = @hafs_counts; 23 | 24 | $ayat = 0; 25 | $sura = 1; 26 | $current_target = $counts[0]; 27 | 28 | while (<>) { 29 | if ($_ =~ /found: (\d+) ayat on page (\d+)/) { 30 | if ($ayat + int($1) >= $current_target) { 31 | $ayat = $ayat + int($1); 32 | while ($ayat > $current_target && $sura < 115) { 33 | $ayat = $ayat - $current_target; 34 | $current_target = $counts[$sura++]; 35 | $page = int($2) + ($ayat == 0 ? 1 : 0); 36 | if ($sura < 115) { 37 | print "sura $sura starts on page $page\n"; 38 | } 39 | } 40 | } else { 41 | $ayat = $ayat + int($1); 42 | print "page $2 ends with $ayat\n"; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ayat/find_ayat_v2.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import sys 3 | 4 | # new madani 5 | WIDTH_MIN = 60 6 | WIDTH_MAX = 75 7 | HEIGHT_MIN = 88 8 | HEIGHT_MAX = 96 9 | 10 | 11 | def find_ayat(img_rgb): 12 | img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY) 13 | binarized = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1] 14 | contours = cv2.findContours(binarized.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0] 15 | 16 | results = [] 17 | selected_contours = [] 18 | for contour in contours: 19 | (x, y, w, h) = cv2.boundingRect(contour) 20 | if WIDTH_MIN < w < WIDTH_MAX and HEIGHT_MIN < h < HEIGHT_MAX: 21 | is_marker = False 22 | for row in range(y, y + h): 23 | if is_marker: 24 | break 25 | for col in range(x, x + int(h / 2)): 26 | if row >= img_rgb.shape[0] or col >= img_rgb.shape[1]: 27 | continue 28 | (b, g, r) = img_rgb[row, col] 29 | if b > 200 and g > 150 and g < 200 and r < 50: 30 | is_marker = True 31 | break 32 | if is_marker: 33 | results.append((x, y, w, h)) 34 | selected_contours.append(contour) 35 | # cv2.imshow("image", img_rgb) 36 | # cv2.waitKey(0) 37 | return [results, selected_contours] 38 | 39 | 40 | def draw(img_rgb, contours, output): 41 | for contour in contours: 42 | cv2.drawContours(img_rgb, [contour], -1, (240, 0, 159), 3) 43 | cv2.imwrite(output, img_rgb) 44 | 45 | 46 | def main(): 47 | if len(sys.argv) < 2: 48 | print("usage: " + sys.argv[0] + " image") 49 | sys.exit(1) 50 | 51 | filename = sys.argv[1] 52 | # filename = "new_madani/page003.png" 53 | img_rgb = cv2.imread(filename) 54 | (ayat, contours) = find_ayat(img_rgb) 55 | draw(img_rgb, contours, 'res.png') 56 | for ayah in ayat: 57 | (x, y, w, h) = ayah 58 | print("marker found at: (%d, %d) - %dx%d" % (x, y, w, h)) 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /ayat/header_remover.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import cv2 5 | from PIL import Image 6 | 7 | from ayat import find_ayat 8 | 9 | HEIGHT = 142 10 | WIDTH = 392 11 | TOP_BOTTOM_PIECE_HEIGHT = 12 12 | DEBUG = False 13 | 14 | 15 | def remove_markers(img, left, right, output): 16 | pixels = img.load() 17 | count = len(left) 18 | for i in range(0, count): 19 | header = left[i] 20 | right_header = right[i] 21 | 22 | x = int(header[0]) 23 | y = int(header[1]) 24 | 25 | rx = int(right_header[0]) 26 | ry = int(right_header[1]) 27 | 28 | for row in range(y, y + HEIGHT): 29 | for col in range(x, x + WIDTH): 30 | pixels[col, row] = (0, 0, 0, 0) 31 | for row in range(ry, ry + HEIGHT): 32 | for col in range(rx, rx + WIDTH): 33 | pixels[col, row] = (0, 0, 0, 0) 34 | for row in range(y, y + TOP_BOTTOM_PIECE_HEIGHT): 35 | for col in range(x, rx): 36 | pixels[col, row] = (0, 0, 0, 0) 37 | for row in range(y + HEIGHT - TOP_BOTTOM_PIECE_HEIGHT, y + HEIGHT): 38 | for col in range(x, rx): 39 | pixels[col, row] = (0, 0, 0, 0) 40 | 41 | if DEBUG: 42 | img.show() 43 | 44 | img.save(output) 45 | 46 | 47 | def main(): 48 | rgb_image_filename = sys.argv[1] 49 | left_template_filename = sys.argv[2] 50 | right_template_filename = sys.argv[3] 51 | image_path = sys.argv[4] 52 | 53 | # page = "520" 54 | # rgb_image_filename = "/Users/ahmedre/Desktop/warsh/page%s.jpg" % page 55 | # left_template_filename = "/Users/ahmedre/Desktop/left.jpg" 56 | # right_template_filename = "/Users/ahmedre/Desktop/right.jpg" 57 | # image_path = 'no_markers/page%s.png' % page 58 | 59 | img_rgb = cv2.imread(rgb_image_filename) 60 | img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY) 61 | template = cv2.imread(left_template_filename, 0) 62 | left = find_ayat(img_gray, template, 0.75) 63 | template = cv2.imread(right_template_filename, 0) 64 | right = find_ayat(img_gray, template, 0.75) 65 | 66 | image = Image.open(image_path).convert('RGBA') 67 | 68 | output_directory = "no_markers" 69 | if len(sys.argv) > 5: 70 | output_directory = sys.argv[5] 71 | os.makedirs(output_directory, exist_ok=True) 72 | 73 | # for debugging, can just hardcode image_path above and coordinates here 74 | # instead of doing the actual ayah detection. 75 | # ayat = [(203.5, 443.5)] 76 | if len(left) > 0 or len(right) > 0: 77 | print("processing %s" % image_path) 78 | remove_markers(image, left, right, os.path.join(output_directory, os.path.basename(image_path))) 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /ayat/marker_remover.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import cv2 5 | from PIL import Image 6 | 7 | from ayat import find_ayat 8 | 9 | DEBUG = False 10 | MARKER_EMPTY_TOP = 4 11 | MARKER_EMPTY_BOTTOM = 3 12 | 13 | 14 | def marker_lengths(marker_img): 15 | pixels = marker_img.load() 16 | result = {} 17 | for row in range(MARKER_EMPTY_TOP, marker_img.size[1] - MARKER_EMPTY_BOTTOM): 18 | least = -1 19 | for col in range(marker_img.size[0]): 20 | (red, green, blue, alpha) = pixels[col, row] 21 | if red == 0 and green == 0 and blue == 0: 22 | least = col 23 | break 24 | 25 | most = -1 26 | for col in range(marker_img.size[0] - 1, 0, -1): 27 | (red, green, blue, alpha) = pixels[col, row] 28 | if red == 0 and green == 0 and blue == 0: 29 | most = col 30 | break 31 | 32 | result[row] = (least, most) 33 | 34 | for row in range(0, MARKER_EMPTY_TOP): 35 | result[row] = result[MARKER_EMPTY_TOP] 36 | for row in range(marker_img.size[1] - MARKER_EMPTY_BOTTOM, marker_img.size[1]): 37 | result[row] = result[MARKER_EMPTY_BOTTOM] 38 | return result 39 | 40 | 41 | def remove_markers(template_marker_lengths, img, ayat, output): 42 | pixels = img.load() 43 | for ayah in ayat: 44 | x = int(ayah[0]) 45 | y = int(ayah[1]) 46 | 47 | min_y = y 48 | max_y = min_y + len(template_marker_lengths) 49 | actual_height = max_y - min_y 50 | template_height = len(template_marker_lengths) 51 | start_offset = template_height - actual_height 52 | 53 | for row in range(min_y, max_y): 54 | if start_offset < 0: 55 | (min_x, max_x) = template_marker_lengths[0] 56 | else: 57 | (min_x, max_x) = template_marker_lengths[start_offset] 58 | if start_offset > 0: 59 | (last_min_x, last_max_x) = template_marker_lengths[start_offset - 1] 60 | (min_x, max_x) = min(last_min_x, min_x), max(last_max_x, max_x) 61 | if start_offset > 1: 62 | (last_min_x, last_max_x) = template_marker_lengths[start_offset - 2] 63 | (min_x, max_x) = min(last_min_x, min_x), max(last_max_x, max_x) 64 | 65 | for col in range(x + min_x - 1, x + max_x + 2): 66 | pixels[col, row] = (0, 0, 0, 0) 67 | start_offset = start_offset + 1 68 | 69 | if DEBUG: 70 | img.show() 71 | 72 | img.save(output) 73 | 74 | 75 | def main(): 76 | rgb_image_filename = sys.argv[1] 77 | template_filename = sys.argv[2] 78 | bw_template_path = sys.argv[3] 79 | image_path = sys.argv[4] 80 | # rgb_image_filename = "/Users/ahmedre/Desktop/warsh/page264.jpg" 81 | # template_filename = "images/templates/warsh/1440_template.png" 82 | # bw_template_path = "bw_warsh_template.jpg" 83 | # image_path = 'images/warsh/page264.png' 84 | 85 | img_rgb = cv2.imread(rgb_image_filename) 86 | img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY) 87 | template = cv2.imread(template_filename, 0) 88 | ayat = find_ayat(img_gray, template) 89 | if len(ayat) == 0: 90 | print("no matches for %s" % rgb_image_filename) 91 | return 92 | 93 | bw_template = Image.open(bw_template_path).convert('RGBA') 94 | template_marker_lengths = marker_lengths(bw_template) 95 | 96 | image = Image.open(image_path).convert('RGBA') 97 | 98 | output_directory = "no_markers" 99 | if len(sys.argv) > 5: 100 | output_directory = sys.argv[5] 101 | os.makedirs(output_directory, exist_ok=True) 102 | 103 | # for debugging, can just hardcode image_path above and coordinates here 104 | # instead of doing the actual ayah detection. 105 | # ayat = [(203.5, 443.5), (894.5, 971.5), (1009.0, 1235.0), (172.0, 1224.5), (497.0, 1500.5), (41.0, 1896.5)] 106 | remove_markers(template_marker_lengths, image, ayat, os.path.join(output_directory, os.path.basename(image_path))) 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /lines/lines.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PIL import Image, ImageDraw 3 | 4 | """ 5 | algorithm for determining rows 6 | - find out the number of non-white pixels in each row 7 | - rows with low pixel count (20 and below, for example) are break points 8 | - combine them into ranges, such that we have line+1 groups 9 | - if midpoint[i+1] - height is within range, use it - otherwise, start at 10 | either low[i] and end at low[i] + height or at high[i] and end after height 11 | depending on where we are relative to the range at i 12 | 13 | then, for each line's ys, find the min x and max across them. 14 | we then have the bounding box for each line 15 | """ 16 | 17 | 18 | def is_not_blank(pt): 19 | return sum(pt) < 200 * len(pt) 20 | 21 | 22 | def find_lines(image, line_height, max_pixels, mode): 23 | ranges = [] 24 | range_end = -1 25 | range_start = -1 26 | restrict_lines = True 27 | if mode == 1: 28 | restrict_lines = False 29 | 30 | data = image.getdata() 31 | width, height = image.size 32 | for y in range(0, height): 33 | filled_pixels = 0 34 | for x in range(0, width): 35 | pt = data[y * width + x] 36 | if is_not_blank(pt): 37 | filled_pixels = filled_pixels + 1 38 | # print "line " + str(y) + " has " + str(filled_pixels) 39 | if filled_pixels < max_pixels: 40 | if range_start == -1: 41 | if restrict_lines and filled_pixels == 0 and len(ranges) == 0: 42 | continue 43 | range_start = y 44 | range_end = y 45 | elif y - range_end < 20: 46 | range_end = y 47 | else: 48 | # print "adding range " + str(range_start) + "," + str(range_end) 49 | ranges.append((range_start, range_end)) 50 | range_start = -1 51 | range_end = -1 52 | if range_start > -1: 53 | ranges.append((range_start, range_end)) 54 | 55 | line_ys = [] 56 | should_skip = False 57 | for i in range(0, len(ranges) - 1): 58 | if should_skip: 59 | should_skip = False 60 | continue 61 | top = ranges[i] 62 | bottom = ranges[i + 1] 63 | midpoint = (bottom[0] + bottom[1]) / 2 64 | 65 | if bottom[1] - top[0] < line_height: 66 | top_midpoint = ((top[0] + top[1]) / 2) 67 | top = (top_midpoint, midpoint) 68 | bottom = ranges[i + 2] 69 | midpoint = (bottom[0] + bottom[1]) / 2 70 | should_skip = True 71 | 72 | top_y = midpoint - line_height 73 | if top[0] <= top_y <= top[1]: 74 | # within range, we keep it 75 | line_ys.append((top_y, midpoint)) 76 | elif top_y < top[0]: 77 | if top[0] + line_height < height: 78 | line_ys.append((top[0], top[0] + line_height)) 79 | else: 80 | if top[1] + line_height < height: 81 | line_ys.append((top[1], top[1] + line_height)) 82 | 83 | lines = [] 84 | for yrange in line_ys: 85 | first_x = width 86 | last_x = 0 87 | for y in range(int(yrange[0]), int(yrange[1])): 88 | for x in range(0, width): 89 | pt = data[y * width + x] 90 | if is_not_blank(pt): 91 | if first_x > x: 92 | first_x = x - 1 93 | break 94 | for x in reversed(range(0, width)): 95 | pt = data[y * width + x] 96 | if is_not_blank(pt): 97 | if x > last_x: 98 | last_x = x 99 | break 100 | lines.append(((first_x, yrange[0]), (last_x, yrange[1]))) 101 | return lines 102 | 103 | 104 | # for debugging, this method draws boxes around each line 105 | def draw(image, lines, output): 106 | drawing = ImageDraw.Draw(image) 107 | for line in lines: 108 | drawing.rectangle([line[0], line[1]], None, (255, 0, 0)) 109 | del drawing 110 | image.save(output) 111 | 112 | 113 | def main(): 114 | if len(sys.argv) < 2: 115 | print("usage: " + sys.argv[0] + " [image]") 116 | sys.exit(1) 117 | image = Image.open(sys.argv[1]).convert('RGBA') 118 | 119 | # tweak this for the pages 120 | lines = find_lines(image, 110, 35, 0) 121 | for line in lines: 122 | print(line) 123 | draw(image, lines, 'test.png') 124 | print("lines: %d" % len(lines)) 125 | 126 | 127 | if __name__ == "__main__": 128 | main() 129 | -------------------------------------------------------------------------------- /ayat/ayat.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import sys 3 | import numpy as np 4 | 5 | 6 | # heavily based on the "template matching" tutorial for opencv python 7 | 8 | def is_x_in_range(x_range, pt): 9 | if x_range[0] <= pt[0] <= x_range[1]: 10 | return True, False 11 | elif pt[0] >= x_range[0] and pt[0] - x_range[1] < 10: 12 | return True, True 13 | elif pt[0] <= x_range[1] and x_range[0] - pt[0] < 10: 14 | return True, True 15 | return False, False 16 | 17 | 18 | def is_y_in_range(y_range, pt): 19 | if y_range[0] <= pt[1] <= y_range[1]: 20 | return True, False 21 | elif pt[1] >= y_range[0] and pt[1] - y_range[1] < 10: 22 | return True, True 23 | elif pt[1] <= y_range[1] and y_range[1] - pt[1] < 10: 24 | return True, True 25 | return False, False 26 | 27 | 28 | def process(ayat): 29 | result = [] 30 | cur_y = ayat[0][1] 31 | same_line = [] 32 | for ayah in ayat: 33 | if abs(ayah[1] - cur_y) < 20: 34 | same_line.append(ayah) 35 | else: 36 | same_line.sort(key=lambda tup: tup[0]) 37 | for s in same_line[::-1]: 38 | result.append(s) 39 | cur_y = ayah[1] 40 | same_line = [ayah] 41 | 42 | same_line.sort(key=lambda tup: tup[0]) 43 | for s in same_line[::-1]: 44 | result.append(s) 45 | return result 46 | 47 | 48 | def find_ayat(img_gray, template, threshold=0.5): 49 | w, h = template.shape[::-1] 50 | 51 | res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED) 52 | loc = np.where(res >= threshold) 53 | 54 | points = list(zip(*loc[::-1])) 55 | ayat = [] 56 | if len(points) == 0: 57 | return ayat 58 | 59 | extras = [] 60 | x_range = (points[0][0], points[0][0]) 61 | y_range = (points[0][1], points[0][1]) 62 | actual_y_range = y_range 63 | 64 | for pt in points: 65 | x_in_range, should_expand_x = is_x_in_range(x_range, pt) 66 | y_in_range, should_expand_y = is_y_in_range(y_range, pt) 67 | 68 | if x_in_range and y_in_range: 69 | if should_expand_x: 70 | x_range = (min(pt[0], x_range[0]), max(pt[0], x_range[1])) 71 | if should_expand_y: 72 | y_range = (min(pt[1], y_range[0]), max(pt[1], y_range[1])) 73 | actual_y_range = y_range 74 | elif y_in_range: 75 | # more than one ayah lives on this line 76 | added = False 77 | for i in range(0, len(extras)): 78 | e = extras[i] 79 | in_range, expand_x = is_x_in_range(e, pt) 80 | if in_range: 81 | if expand_x: 82 | extras[i] = (min(e[0], pt[0]), max(pt[0], e[1]), e[2], e[3]) 83 | in_y_range, expand_y = is_y_in_range(e, pt) 84 | if expand_y: 85 | extras[i] = (e[0], e[1], min(e[2], pt[1]), max(e[3], pt[1])) 86 | added = True 87 | break 88 | if not added: 89 | extras.append((pt[0], pt[0], pt[1], pt[1])) 90 | if should_expand_y: 91 | y_range = (min(pt[1], y_range[0]), max(pt[1], y_range[1])) 92 | else: 93 | x_avg = (x_range[0] + x_range[1]) / 2 94 | y_avg = (actual_y_range[0] + actual_y_range[1]) / 2 95 | ayat.append((x_avg, y_avg)) 96 | x_range = (pt[0], pt[0]) 97 | y_range = (pt[1], pt[1]) 98 | actual_y_range = y_range 99 | for e in extras: 100 | e_x_avg = (e[0] + e[1]) / 2 101 | e_y_avg = (e[2] + e[3]) / 2 102 | ayat.append((e_x_avg, e_y_avg)) 103 | extras = [] 104 | 105 | y_avg = (actual_y_range[0] + actual_y_range[1]) / 2 106 | ayat.append(((x_range[1] + x_range[0]) / 2, y_avg)) 107 | for e in extras: 108 | e_x_avg = (e[0] + e[1]) / 2 109 | e_y_avg = (e[2] + e[3]) / 2 110 | ayat.append((e_x_avg, e_y_avg)) 111 | return process(ayat) 112 | 113 | 114 | def draw(img_rgb, template, ayat, output): 115 | w, h = template.shape[::-1] 116 | for point in ayat: 117 | pt = (int(point[0]), int(point[1])) 118 | cv2.rectangle(img_rgb, pt, (pt[0] + w, pt[1] + h), (0, 0, 255), thickness=2) 119 | cv2.imwrite(output, img_rgb) 120 | 121 | 122 | def main(): 123 | if len(sys.argv) < 3: 124 | print("usage: " + sys.argv[0] + " image template") 125 | sys.exit(1) 126 | 127 | img_rgb = cv2.imread(sys.argv[1]) 128 | img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY) 129 | template = cv2.imread(sys.argv[2], 0) 130 | ayat = find_ayat(img_gray, template) 131 | for ayah in ayat: 132 | print(ayah) 133 | draw(img_rgb, template, ayat, 'res.png') 134 | 135 | 136 | if __name__ == "__main__": 137 | main() 138 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import cv2 3 | from PIL import Image 4 | 5 | from ayat.find_ayat_v2 import find_ayat 6 | from lines.lines import find_lines 7 | 8 | hafs_ayat = [7, 286, 200, 176, 120, 165, 206, 75, 129, 109, 123, 111, 9 | 43, 52, 99, 128, 111, 110, 98, 135, 112, 78, 118, 64, 77, 10 | 227, 93, 88, 69, 60, 34, 30, 73, 54, 45, 83, 182, 88, 75, 11 | 85, 54, 53, 89, 59, 37, 35, 38, 29, 18, 45, 60, 49, 62, 55, 12 | 78, 96, 29, 22, 24, 13, 14, 11, 11, 18, 12, 12, 30, 52, 52, 13 | 44, 28, 28, 20, 56, 40, 31, 50, 40, 46, 42, 29, 19, 36, 25, 14 | 22, 17, 19, 26, 30, 20, 15, 21, 11, 8, 8, 19, 5, 8, 8, 11, 15 | 11, 8, 3, 9, 5, 4, 7, 3, 6, 3, 5, 4, 5, 6] 16 | warsh_ayat = [7, 285, 200, 175, 122, 167, 206, 76, 130, 109, 121, 111, 17 | 44, 54, 99, 128, 110, 105, 99, 134, 111, 76, 119, 62, 77, 18 | 226, 95, 88, 69, 59, 33, 30, 73, 54, 46, 82, 182, 86, 72, 19 | 84, 53, 50, 89, 56, 36, 34, 39, 29, 18, 45, 60, 47, 61, 55, 20 | 77, 99, 28, 21, 24, 13, 14, 11, 11, 18, 12, 12, 31, 52, 52, 21 | 44, 30, 28, 18, 55, 39, 31, 50, 40, 45, 42, 29, 19, 36, 25, 22 | 22, 17, 19, 26, 32, 20, 15, 21, 11, 8, 8, 20, 5, 8, 9, 11, 23 | 10, 8, 3, 9, 5, 5, 6, 3, 6, 3, 5, 4, 5, 6] 24 | 25 | sura = 1 26 | ayah = 1 27 | # number of lines to skip when the end of the sura is reached 28 | # for example, one for the basmallah and one for the header. 29 | # 1 is automatically deducted from this number for sura Tawbah. 30 | default_lines_to_skip = 2 31 | sura_ayat = hafs_ayat 32 | 33 | # by default, we don't increase the ayah on the top of this loop 34 | # to handle ayat that span multiple pages - this flag allows us to 35 | # override this. 36 | end_of_ayah = False 37 | 38 | 39 | def process_ayat(ayat): 40 | result = [] 41 | cur_y = ayat[0][1] 42 | same_line = [] 43 | for ayah in ayat: 44 | if abs(ayah[1] - cur_y) < 20: 45 | same_line.append(ayah) 46 | else: 47 | same_line.sort(key=lambda tup: tup[0]) 48 | for s in same_line[::-1]: 49 | result.append(s) 50 | cur_y = ayah[1] 51 | same_line = [ayah] 52 | 53 | same_line.sort(key=lambda tup: tup[0]) 54 | for s in same_line[::-1]: 55 | result.append(s) 56 | return result 57 | 58 | 59 | def main(): 60 | global end_of_ayah, sura, ayah, sura_ayat, default_lines_to_skip 61 | 62 | lines_to_skip = 0 63 | for i in range(3, 605): 64 | image_dir = sys.argv[1] + '/' 65 | filename = 'page' + str(i).zfill(3) + '.jpg' 66 | print(filename) 67 | 68 | # find lines 69 | image = Image.open(image_dir + filename).convert('RGBA') 70 | 71 | lines = find_lines(image, 110, 35, 0) 72 | print('found: %d lines on page %d' % (len(lines), i)) 73 | 74 | img_rgb = cv2.imread(image_dir + filename) 75 | (ayat, _) = find_ayat(img_rgb) 76 | ayat = sorted(ayat, key=lambda x: (x[1], x[0])) 77 | ayat = process_ayat(ayat) 78 | print('found: %d ayat on page %d' % (len(ayat), i)) 79 | 80 | line = 0 81 | current_line = 0 82 | x_pos_in_line = -1 83 | num_lines = len(lines) 84 | 85 | first = True 86 | end_of_sura = False 87 | for ayah_item in ayat: 88 | if (end_of_ayah or not first) and sura_ayat[sura - 1] == ayah: 89 | sura = sura + 1 90 | ayah = 1 91 | lines_to_skip = default_lines_to_skip 92 | if sura == 9: 93 | lines_to_skip = lines_to_skip - 1 94 | end_of_ayah = False 95 | elif end_of_ayah or not first: 96 | ayah = ayah + 1 97 | end_of_ayah = False 98 | first = False 99 | y_pos = ayah_item[1] 100 | 101 | pos = 0 102 | for line in range(current_line, num_lines): 103 | if lines_to_skip > 0: 104 | lines_to_skip = lines_to_skip - 1 105 | current_line = current_line + 1 106 | continue 107 | pos = pos + 1 108 | cur_line = lines[line] 109 | miny = cur_line[0][1] 110 | maxy = cur_line[1][1] 111 | if y_pos <= maxy: 112 | # we found the line with the ayah 113 | maxx = cur_line[1][0] 114 | if x_pos_in_line > 0: 115 | maxx = x_pos_in_line 116 | minx = ayah_item[0] 117 | vals = (i, line + 1, sura, ayah, pos, minx, maxx, miny, maxy) 118 | s = 'insert into glyphs values(NULL, ' 119 | print(s + '%d, %d, %d, %d, %d, %d, %d, %d, %d);' % vals) 120 | 121 | end_of_sura = False 122 | if sura_ayat[sura - 1] == ayah: 123 | end_of_sura = True 124 | 125 | if end_of_sura or abs(minx - cur_line[0][0]) < (ayah_item[2] / 2): 126 | x_pos_in_line = -1 127 | current_line = current_line + 1 128 | if current_line == num_lines: 129 | # last line, and no more ayahs - set it to increase 130 | end_of_ayah = True 131 | else: 132 | x_pos_in_line = minx 133 | break 134 | else: 135 | # we add this line 136 | maxx = cur_line[1][0] 137 | if x_pos_in_line > 0: 138 | maxx = x_pos_in_line 139 | x_pos_in_line = -1 140 | current_line = current_line + 1 141 | vals = (i, line + 1, sura, ayah, pos, cur_line[0][0], maxx, 142 | cur_line[0][1], cur_line[1][1]) 143 | s = 'insert into glyphs values(NULL, ' 144 | print(s + '%d, %d, %d, %d, %d, %d, %d, %d, %d);' % vals) 145 | 146 | # handle cases when the sura ends on a page, and there are no more 147 | # ayat. this could mean that we need to adjust lines_to_skip (as is 148 | # the case when the next sura header is at the bottom) or also add 149 | # some ayat that aren't being displayed at the moment. 150 | if end_of_sura: 151 | # end of sura always means x_pos_in_line is -1 152 | sura = sura + 1 153 | ayah = 1 154 | lines_to_skip = default_lines_to_skip 155 | if sura == 9: 156 | lines_to_skip = lines_to_skip - 1 157 | end_of_ayah = False 158 | while line + 1 < num_lines and lines_to_skip > 0: 159 | line = line + 1 160 | lines_to_skip = lines_to_skip - 1 161 | if lines_to_skip == 0 and line + 1 != num_lines: 162 | ayah = 0 163 | 164 | # we have some lines unaccounted for or stopped mid-line 165 | if x_pos_in_line != -1 or line + 1 != num_lines: 166 | if x_pos_in_line == -1: 167 | line = line + 1 168 | pos = 0 169 | ayah = ayah + 1 170 | for l in range(line, num_lines): 171 | cur_line = lines[l] 172 | pos = pos + 1 173 | maxx = cur_line[1][0] 174 | if x_pos_in_line > 0: 175 | maxx = x_pos_in_line 176 | x_pos_in_line = -1 177 | vals = (i, l + 1, sura, ayah, pos, cur_line[0][0], maxx, 178 | cur_line[0][1], cur_line[1][1]) 179 | s = 'insert into glyphs values(NULL, ' 180 | print(s + '%d, %d, %d, %d, %d, %d, %d, %d, %d);' % vals) 181 | 182 | 183 | if __name__ == "__main__": 184 | main() 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quran Utilities 2 | 3 | بسم الله الرحمن الرحيم 4 | 5 | Quran utils is a set of scripts for detecting ayat in quran images. it's very rough, but it definitely works (tested on 3 sets of images - shamerly, qaloon, and warsh images). 6 | 7 | ## Files 8 | 9 | * `ayat.py` - detects ayah images in a particular image. 10 | * `lines.py` - "detects" lines in a particular image 11 | * `loop.py` - a verification utility. 12 | * `main.py` - main loop for generating a database from images 13 | * `make_transparent.py` - make an image with a white background transparent 14 | * `make_white.py` - make an image with a transparent background white 15 | 16 | ## Setup 17 | 18 | ideally, run something like this: 19 | 20 | ```sh 21 | python3 -m venv virtualenv 22 | source virtualenv/bin/activate 23 | pip install -r requirements.txt 24 | ``` 25 | 26 | ## Suggested Workflow 27 | 28 | - crop the images first (if necessary). 29 | - assuming images with a white background, make the images transparent. 30 | - if the images were already transparent, make a copy with white backgrounds. 31 | - using an image editor, cut out an ayah marker from any page. 32 | - while in the image editor, figure out the approximate height of each line. 33 | - run ayah detection on a few pages, validating by looking at `res.png`. 34 | - run line detection on a few pages, validating by looking at `temp.png`. 35 | - run the loop script across all images to validate the data (search for pages 36 | with no images found, for example). 37 | 38 | ```sh 39 | # -u so it's unbuffered 40 | python -u loop.py /path/ template.png | tee output.txt 41 | ``` 42 | 43 | - when done, validate output.txt using the error checking script. this is 44 | really important since it helps find errors (ex missed pages, not enough 45 | ayahs parsed on a page, etc). 46 | - when everything looks fine, run main.py after tweaking values. 47 | - revalidate the output using the error checking script. 48 | - note: if main.py breaks due to index out of bounds, etc, double check the 49 | values. chances are something is off (check to ensure that the correct start 50 | sura, end sura, and number of ayahs per sura are set). 51 | - in some cases, having multiple ayah templates helps (or otherwise reducing 52 | the accuracy, but this could lead to false positives). 53 | - note: reading the sql output can also help pinpoint issues - ex if you 54 | expect page 50 to be the start of sura Al-i-'Imran and it's actually writing 55 | sql that indicates its for an ayah in sura Baqarah, then you know that an 56 | ayah might not have been detected in sura Baqarah for example. 57 | 58 | 59 | ## Scripts 60 | 61 | ### make_transparent.py 62 | 63 | `make_transparent.py` takes in an image with a white background and attempts 64 | to make the background transparent. there are some tweakable parameters within 65 | the script, and it can theoretically be used with other background colors 66 | given the correct amount of tweaking. 67 | 68 | 69 | ### make_white.py 70 | 71 | `make_white.py` takes in a transparent image and makes all the transparent 72 | pixels white. this is helpful when images with white backgrounds are needed 73 | for better accuraccy while running `ayat.py` or `lines.py`. 74 | 75 | 76 | ### ayat.py 77 | 78 | `ayat.py` is responsible for detecting ayah images inside a page image. note that it works best on images with a white background (see `make_white.py` if your image has a transparent background). 79 | 80 | requirements: 81 | 82 | * opencv and python bindings (`brew install homebrew/science/opencv`) 83 | * matplotlib (`pip install matplotlib`) 84 | * numpy (`pip install numpy`) 85 | 86 | you also need a template image. you make one by cutting out an ayah marker image from one of your pages. the threshold is set low enough such that it will match all of the marker images despite the different numbers. some examples exist under `images/templates`. 87 | 88 | ### lines.py 89 | 90 | `lines.py` attempts to figure out where the lines are in a certain image. it does this by searching for white space between the images. consequently, it's the least accurate of the scripts. i typically verify it by running it across all images and making sure i get 15 lines for each page. run this on pages with white backgrounds for better results. 91 | 92 | requirements: 93 | 94 | * pillow (`pip install pillow`) 95 | 96 | there are 3 numbers you'll find configured in the main - line height (approximate height of each line), max pixels (a threshold - how many pixels in a line make the line a quran line vs a line of tashkeel between two lines), and mode (0 or 1 - i think this is used for how to handle the very first line - pass 0 for most cases). 97 | 98 | ### main.py 99 | 100 | `main.py` is what outputs sql from a set of images. before running it, you want to make sure you can run `ayat.py` and validate its output, along with `lines.py` and validate its output. `main.py` is just a wrapper that combines the results from the above scripts to generate sql, which it prints to the command line. 101 | 102 | to run it: 103 | `python main.py images/shamerly images/template/shamerly.png > shamerly.out` 104 | 105 | ### loop.py 106 | 107 | `loop.py` is used in conjunction with things like `find_errors.pl` to do some basic validation. i was using it to figure out where each sura starts/ends, so i could then 108 | check that particular page and verify. 109 | 110 | ### Quran Android 111 | 112 | in order to be compatible with Quran Android, just generate a database file with similar structure to the existing ayahinfo database files. 113 | 114 | ```sql 115 | CREATE TABLE glyphs( 116 | glyph_id int primary key, 117 | page_number int not null, 118 | line_number int not null, 119 | sura_number int not null, 120 | ayah_number int not null, 121 | position int not null, 122 | min_x int not null, 123 | max_x int not null, 124 | min_y int not null, 125 | max_y int not null 126 | ); 127 | CREATE INDEX sura_ayah_idx on glyphs(sura_number, ayah_number); 128 | CREATE INDEX page_idx on glyphs(page_number); 129 | ``` 130 | 131 | note: currently, `glyph_id` is set to `NULL` in the script, which is problematic. we can just put a number and increase it as need be, since using `AUTOINCREMENT` in sqlite has performance implications. 132 | 133 | 134 | ### Experimental Scripts 135 | 136 | #### marker_remover.py 137 | 138 | `marker_remover.py` is an experimental script to remove markers. it uses the template marker as a "blueprint" by which it tries to remove existing markers. it figures out how amny pixels there are in each row of the template. it then tries to use that as an input to figure out how many pixels to remove from that particular row of the actual image. this is experimental and pretty dangerous, since it can eat pieces of touching ayahs, especially when things are really close together. really recommend using a tool like [kaleidoscope](https://www.kaleidoscopeapp.com) to overlay the image without the ayahs on top of the ones with the ayahs and see that nothing extra is cut out, etc. configure its values at the top of the script. 139 | 140 | 141 | #### header_remover.py 142 | 143 | `header_remover.py` experimentally removes headers. it's safer than `marker_remover.py` and easier to validate, but still pretty experimental. configure its values in the top of the script. 144 | 145 | 146 | #### find_ayat_v2.py 147 | 148 | `find_ayat_v2.py` is a simpler version of `ayat.py` that uses OpenCV to find contours instead. consequently, this script doesn't need a template image. the downside of this approach, however, is that it can have many false positives since certain letters can still be detected as contours. this can greatly be reduced if the ayah markers have colors, which most do - simply checking for colors in a certain range greatly reduces (if not completely eliminates) these false positives. this is the recommended approach moving forward in sha' Allah. the old `ayat.py` will stay around since, in some cases, `ayat.py` works better than `find_ayat_v2.py` (also, `ayat.py` powers the `header_remover.py` script). whenever possible, use `find_ayat_v2.py`, but if it doesn't work well for the particular type of image, use `ayat.py`. 149 | 150 | 151 | #### marker_remover_v2.py 152 | 153 | `marker_remover_v2.py` is an updated version of `marker_remover.py` that uses `find_ayat_v2.py` to find and remove markers from pages. initial tests are very promising al7amdulillah. whenever v2 works, this is the recommended approach. fallback to v1 only when v2 doesn't work. 154 | --------------------------------------------------------------------------------