diff --git a/ai/src/bubble_sheet_generator.py b/ai/src/bubble_sheet_generator.py
index 08bce834584fc925908408a3b4277630293fff37..7e01c8bd7c9879572bf94233853e6b2148b7019a 100644
--- a/ai/src/bubble_sheet_generator.py
+++ b/ai/src/bubble_sheet_generator.py
@@ -9,8 +9,8 @@ class BubbleSheet:
     def __init__(self):
         # Constants
         self.MAIN_COLOR = "black"
-        self.OFF_COLOR = "#eeeeee"
-        self.TEXT_COLOR = "lightgray"
+        self.OFF_COLOR = "lightgray"
+        self.TEXT_COLOR = "gray"
         self.RECT_WIDTH = 2
 
     def draw_rect(self, ax, rect_x, rect_y, width, height, grid_x, grid_y, rect_color=None,
@@ -168,15 +168,13 @@ def main():
     # Define the second answer field
     # THESE MAGIC NUMBERS SERVE AS EXAMPLES OF HOW THE USER MIGHT INPUT THIS FIELD
     x = x + width + offset_between_rect * 1.5
-    width += 0.05
 
-    bubble_sheet.draw_rect(ax, x, y, width, height, grid_x + 2, grid_y, big_label="Answers")
-    bubble_sheet.draw_grid(ax, x, y, width, height, grid_x + 2, grid_y, q_label, a_label + ["F", "G"])
+    bubble_sheet.draw_rect(ax, x, y, width, height, grid_x, grid_y, big_label="Answers")
+    bubble_sheet.draw_grid(ax, x, y, width, height, grid_x, grid_y, q_label, a_label, q_offset=0.04, a_offset=0.03)
 
     # Define the third answer field
     # THESE MAGIC NUMBERS SERVE AS EXAMPLES OF HOW THE USER MIGHT INPUT THIS FIELD
     x = x + width + offset_between_rect * 1.5
-    width -= 0.05
 
     bubble_sheet.draw_rect(ax, x, y, width, height, grid_x, grid_y)
     bubble_sheet.draw_grid(ax, x, y, width, height, grid_x, grid_y, q_label, a_label, q_offset=0.04, a_offset=0.03)
diff --git a/ai/src/preprocessor.py b/ai/src/preprocessor.py
new file mode 100644
index 0000000000000000000000000000000000000000..b683ca8ea4cdfc3608bafeb3b097d5b2bc9283f6
--- /dev/null
+++ b/ai/src/preprocessor.py
@@ -0,0 +1,217 @@
+import numpy as np
+import cv2
+import matplotlib.pyplot as plt
+import fitz
+import skimage.filters.thresholding as th
+import pythreshold.utils as putils
+import imutils.contours
+
+IMG_FOLDER = "tsp_zaznamove_archy"
+INPUT_FILE = "naskenovany_vyplneny.pdf"
+
+
+def load_pdf(file_path):
+    """
+    Load pdf file and return list of images
+    :param file_path: Path to pdf file
+    :return: List of images
+    """
+    pdf = fitz.open(file_path)
+    images = []
+    for page_num in range(len(pdf)):  # Iterate over all pages
+        page = pdf[page_num]
+        image = page.get_pixmap(dpi=300)
+        image = np.frombuffer(image.samples, dtype=np.uint8).reshape(image.h, image.w, 3)  # Convert to numpy array
+        images.append(image)
+    return images
+
+
+def show_images(titles, images):
+    """
+    Display images in a row with titles
+    :param titles: List of titles
+    :param images: List of images
+    """
+    fig, axs = plt.subplots(1, len(images), figsize=(len(images) * 10, 10))
+    fig.tight_layout()
+    if len(images) == 1:
+        axs = [axs]
+    for ax, title, image in zip(axs, titles, images):
+        ax.imshow(image, cmap="gray" if len(image.shape) == 2 else None)
+        ax.set_title(title)
+        ax.axis("off")
+    plt.show()
+
+
+def threshold_otsu(image, threshold=170):
+    """
+    Apply OTSU thresholding to the image
+    :param image: Grayscale image
+    :param threshold: Threshold value
+    :return: Thresholded image
+    """
+    _, threshed = cv2.threshold(image, threshold, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
+    return threshed
+
+
+def threshold_to_zero(image, threshold=170):
+    """
+    Apply thresholding to zero to the image
+    :param image: Grayscale image
+    :param threshold: Threshold value
+    :return: Thresholded image
+    """
+    _, threshed = cv2.threshold(image, threshold, 255, cv2.THRESH_TOZERO)
+    return cv2.bitwise_not(threshed)
+
+
+def threshold_yen(image):
+    """
+    Apply Yen thresholding to the image
+    :param image: Grayscale image
+    :return: Thresholded image
+    """
+    thresh = th.threshold_yen(image)
+    threshed = putils.apply_threshold(image, thresh)
+    return cv2.bitwise_not(np.array(threshed, dtype=np.uint8))
+
+
+def threshold_mean(image):
+    """
+    Apply mean thresholding to the image
+    :param image: Grayscale image
+    :return: Thresholded image
+    """
+    thresh = th.threshold_mean(image)
+    threshed = putils.apply_threshold(image, thresh)
+    return cv2.bitwise_not(np.array(threshed, dtype=np.uint8))
+
+
+def threshold_kapur(image):
+    """
+    Apply Kapur thresholding to the image
+    :param image: Grayscale image
+    :return: Thresholded image
+    """
+    th = putils.kapur_threshold(image)
+    threshed = putils.apply_threshold(image, th)
+    return cv2.bitwise_not(threshed)
+
+
+def find_contours(image):
+    """
+    Find contours in the image
+    :param image: Thresholded image
+    :return: List of contours
+    """
+    contours, _ = cv2.findContours(image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+    return contours
+
+
+def find_edges(image):
+    """
+    Find edges in the image
+    :param image: Grayscale image
+    :return: Edges
+    """
+    edges = cv2.Canny(image, 100, 200)
+    return edges
+
+
+scanned_filled = load_pdf(f"{IMG_FOLDER}/{INPUT_FILE}")[0]
+gray_filled = cv2.cvtColor(scanned_filled, cv2.COLOR_RGB2GRAY)
+threshed_filled = threshold_otsu(gray_filled, 170)
+
+# Find the big boxes around the answer bubbles
+contours = find_contours(threshed_filled)
+# Pick k largest contours
+# TODO: this will probably be redone with a configure file in the future (connect to generator)
+k = 4
+contours = sorted(contours, key=cv2.contourArea, reverse=True)[:k]
+
+# Create subimages from the big boxes
+subimages = []
+for contour in contours:
+    x, y, w, h = cv2.boundingRect(contour)
+
+    # Throw away 1% of the border of the image
+    diff = w // 100 if w > h else h // 100
+    x += diff
+    y += diff
+    w -= 2 * diff
+    h -= 2 * diff
+
+    subimage = scanned_filled[y:y+h, x:x+w]
+    subimages.append(subimage)
+
+# Detect filled bubbles
+answers = []
+
+# Number of circles in each subimage
+# TODO: this will probably be redone with a configure file in the future (connect to generator)
+how_many_circles = [100, 100, 100, 40]
+
+# For each big box
+for i, subimage in enumerate(subimages):
+    # Create array for answers
+    answers.append([])
+
+    # Find contours of circles
+    gray = cv2.cvtColor(subimage, cv2.COLOR_RGB2GRAY)
+    threshed = threshold_otsu(gray, 170)
+    contours = find_contours(threshed)
+    # Find specified number of circles
+    contours = sorted(contours, key=cv2.contourArea, reverse=True)[:how_many_circles[i]]
+    # Sort them top to bottom, for easier processing
+    contours = imutils.contours.sort_contours(contours, method="top-to-bottom")[0]
+
+    # Just for showcase purposes
+    circle_image = cv2.drawContours(subimage.copy(), contours, -1, (0, 255, 0), 2)
+
+    # Threshold the subimage
+    threshed_subimage = cv2.GaussianBlur(subimage, (5, 5), 0)
+    threshed_subimage = threshold_mean(threshed_subimage)
+
+    # TODO: this will probably be redone with a configure file in the future (connect to generator)
+    # But for now, ID has 4 columns, but answers have 5 columns
+    if i != len(subimages) - 1:
+        num_col = 5
+    else:
+        num_col = 4
+
+    # Iterate over the circles
+    for (q, j) in enumerate(np.arange(0, len(contours), num_col)):
+        # Sort the contours from left to right
+        subcontours = imutils.contours.sort_contours(contours[j:j + num_col])[0]
+        # Create array for answers
+        answers[i].append([])
+
+        # Iterate over the subcontours
+        for bubble in subcontours:
+            # Find the bounding box of the bubble
+            (x, y, w, h) = cv2.boundingRect(bubble)
+
+            # Just for showcase purposes
+            cv2.rectangle(circle_image, (x, y), (x+w, y+h), (255, 0, 0), 2)
+
+            # Find the bubble subimage and convert it to grayscale
+            bubble_subimage = threshed_subimage[y:y+h, x:x+w]
+            one_channel = cv2.cvtColor(bubble_subimage, cv2.COLOR_RGB2GRAY)
+            # Close the image using morphological operations
+            closed = cv2.morphologyEx(one_channel, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
+
+            # Count the number of white pixels and the total number of pixels
+            pixels = cv2.countNonZero(closed)
+            num_pixels = w * h
+
+            # We found out that the circle often takes up around 40-50% of the area
+            # So if the whole area is at least 75% filled, the student probably tried to at least fill the circle
+            if pixels > 0.75 * num_pixels:
+                answers[i][q].append(1)
+            else:
+                answers[i][q].append(0)
+
+    # Show the images (just for showcase purposes)
+    show_images([f'subimage{i+1}', f'threshed_subimage{i+1}', f'circle_image{i+1}'], [subimage, threshed_subimage, circle_image])
+    # Print the answers (just for showcase purposes)
+    print(answers[i])
diff --git a/ai/src/tsp_zaznamove_archy/naskenovany_prazdny.pdf b/ai/src/tsp_zaznamove_archy/naskenovany_prazdny.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..b4a5c06472cdc61f054c0c8c8cb1c393ecae595c
Binary files /dev/null and b/ai/src/tsp_zaznamove_archy/naskenovany_prazdny.pdf differ
diff --git a/ai/src/tsp_zaznamove_archy/naskenovany_vyplneny.pdf b/ai/src/tsp_zaznamove_archy/naskenovany_vyplneny.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..1218cc81849c35151444403738fd0746a1c44965
Binary files /dev/null and b/ai/src/tsp_zaznamove_archy/naskenovany_vyplneny.pdf differ
diff --git a/ai/src/tsp_zaznamove_archy/vygenerovany.pdf b/ai/src/tsp_zaznamove_archy/vygenerovany.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..aeb43d40ad47a2179c787d39040a15fc3c6a5340
Binary files /dev/null and b/ai/src/tsp_zaznamove_archy/vygenerovany.pdf differ