Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • zesje/zesje
  • jbweston/grader_app
  • dj2k/zesje
  • MrHug/zesje
  • okaaij/zesje
  • tsoud/zesje
  • pimotte/zesje
  • works-on-my-machine/zesje
  • labay11/zesje
  • reouvenassouly/zesje
  • t.v.aerts/zesje
  • giuseppe.deininger/zesje
12 results
Show changes
......@@ -60,19 +60,18 @@ def get(exam_id, problem_id, submission_id, full_page=False):
# pregrade highliting
solution = Solution.query.filter(Solution.submission_id == sub.id,
Solution.problem_id == problem_id).one_or_none()
if solution is not None:
dpi = guess_dpi(page_im)
fb = list(map(lambda x: x.id, solution.feedback))
for option in problem.mc_options:
if option.feedback_id in fb:
x = int(option.x / 72 * dpi)
y = int(option.y / 72 * dpi)
box_length = int(CHECKBOX_FORMAT["box_size"] / 72 * dpi)
x1 = x + box_length
y1 = y + box_length
page_im = cv2.rectangle(page_im, (x, y), (x1, y1), (0, 255, 0), 3)
Solution.problem_id == problem_id).one()
dpi = guess_dpi(page_im)
fb = list(map(lambda x: x.id, solution.feedback))
for option in problem.mc_options:
if option.feedback_id in fb:
x = int(option.x / 72 * dpi)
y = int(option.y / 72 * dpi)
box_length = int(CHECKBOX_FORMAT["box_size"] / 72 * dpi)
x1 = x + box_length
y1 = y + box_length
page_im = cv2.rectangle(page_im, (x, y), (x1, y1), (0, 255, 0), 3)
if not full_page:
raw_image = get_box(page_im, widget_area_in, padding=0.3)
......
......@@ -9,35 +9,28 @@ def update_mc_option(mc_option, args, feedback_id=None):
Parameters
----------
mc_option: The multiple choice option
args: The arguments supplied in the request body
feedback_id: The id of the feedback option related to the
mc_option : MultipleChoiceOption
The multiple choice option
args: dict
The arguments supplied in the request body
feedback_id : int
id of the feedback option coupled to the multiple choice option
"""
for attr, value in args.items():
try:
if value:
setattr(mc_option, attr, value)
except AttributeError:
msg = f"Multiple choice option doesn't have a property {attr}"
return dict(status=400, message=msg), 400
except (TypeError, ValueError) as error:
return dict(status=400, message=str(error)), 400
if value:
setattr(mc_option, attr, value)
if feedback_id:
mc_option.feedback_id = feedback_id
mc_option.type = 'mcq_widget'
db.session.commit()
class MultipleChoice(Resource):
put_parser = reqparse.RequestParser()
# Arguments that have to be supplied in the request body
put_parser.add_argument('name', type=str, required=True)
# Arguments that can be supplied in the request body
put_parser.add_argument('name', type=str, required=False)
put_parser.add_argument('x', type=int, required=True)
put_parser.add_argument('y', type=int, required=True)
put_parser.add_argument('label', type=str, required=False)
......@@ -59,19 +52,27 @@ class MultipleChoice(Resource):
fb_description = args['fb_description']
fb_score = args['fb_score']
problem_id = args['problem_id']
problem = Problem.query.get(problem_id)
if not Problem.query.get(problem_id):
if not problem:
return dict(status=404, message=f'Problem with id {problem_id} does not exist'), 404
if problem.exam.finalized:
return dict(status=405, message='Cannot create multiple choice option and corresponding feedback option'
+ ' in a finalized exam.'), 405
# Insert new empty feedback option that links to the same problem
new_feedback_option = FeedbackOption(problem_id=problem_id, text=label,
description=fb_description, score=fb_score)
db.session.add(new_feedback_option)
db.session.commit()
# Insert new entry into the database
mc_entry = MultipleChoiceOption()
update_mc_option(mc_entry, args, new_feedback_option.id)
args.pop('fb_description')
args.pop('fb_score')
args.pop('problem_id')
# Insert new multiple choice entry into the database
mc_entry = MultipleChoiceOption(**args, feedback_id=new_feedback_option.id)
db.session.add(mc_entry)
db.session.commit()
......@@ -94,7 +95,7 @@ class MultipleChoice(Resource):
mult_choice = MultipleChoiceOption.query.get(id)
if not mult_choice:
return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404
return dict(status=404, message=f'Multiple choice option with id {id} does not exist.'), 404
json = {
'id': mult_choice.id,
......@@ -125,7 +126,7 @@ class MultipleChoice(Resource):
Parameters
----------
id: The id of the multiple choice option in the database.s
id: The id of the multiple choice option in the database.
"""
args = self.patch_parser.parse_args()
......@@ -134,8 +135,13 @@ class MultipleChoice(Resource):
if not mc_entry:
return dict(status=404, message=f"Multiple choice question with id {id} does not exist"), 404
if mc_entry.feedback.problem.exam.finalized:
return dict(status=405, message=f'Exam is finalized'), 405
update_mc_option(mc_entry, args)
db.session.commit()
return dict(status=200, message=f'Multiple choice question with id {id} updated'), 200
def delete(self, id):
......@@ -158,19 +164,13 @@ class MultipleChoice(Resource):
if not mult_choice:
return dict(status=404, message=f'Multiple choice question with id {id} does not exist.'), 404
if not mult_choice.feedback:
return dict(status=404, message=f'Multiple choice question with id {id}'
+ ' is not associated with a feedback option.'), 404
# Check if the exam is finalized, do not delete the multiple choice option otherwise
exam = mult_choice.feedback.problem.exam
if exam.finalized:
return dict(status=401, message='Cannot delete feedback option'
+ ' attached to a multiple choice option in a finalized exam.'), 401
return dict(status=405, message='Cannot delete multiple choice option in a finalized exam.'), 405
db.session.delete(mult_choice)
db.session.delete(mult_choice.feedback)
db.session.commit()
return dict(status=200, mult_choice_id=id, feedback_id=mult_choice.feedback_id,
......
""" REST api for problems """
from flask_restful import Resource, reqparse, current_app
import os
from ..database import db, Exam, Problem, ProblemWidget, Solution
from flask_restful import Resource, reqparse, current_app
from zesje.pdf_reader import get_problem_title
from zesje.database import db, Exam, Problem, ProblemWidget, Solution
from zesje.pdf_reader import guess_problem_title, get_problem_page
class Problems(Resource):
......@@ -60,11 +61,14 @@ class Problems(Resource):
db.session.commit()
widget.name = f'problem_{problem.id}'
app_config = current_app.config
data_dir = app_config.get('DATA_DIRECTORY', 'data')
page_format = app_config.get('PAGE_FORMAT', 'A4')
data_dir = current_app.config.get('DATA_DIRECTORY', 'data')
pdf_path = os.path.join(data_dir, f'{problem.exam_id}_data', 'exam.pdf')
problem.name = get_problem_title(problem, data_dir, page_format)
page = get_problem_page(problem, pdf_path)
guessed_title = guess_problem_title(problem, page)
if guessed_title:
problem.name = guessed_title
db.session.commit()
......@@ -114,10 +118,6 @@ class Problems(Resource):
if any([sol.graded_by is not None for sol in problem.solutions]):
return dict(status=403, message=f'Problem has already been graded'), 403
else:
# delete mc options
for mc_option in problem.mc_options:
db.session.delete(mc_option)
# The widget and all associated solutions are automatically deleted
db.session.delete(problem)
db.session.commit()
......
......@@ -150,7 +150,7 @@ class Solutions(Resource):
class Approve(Resource):
""" add just a grader to a specifc problem on an exam """
"""Mark a solution as graded."""
put_parser = reqparse.RequestParser()
put_parser.add_argument('graderID', type=int, required=True)
......@@ -185,7 +185,6 @@ class Approve(Resource):
if graded:
solution.graded_at = datetime.now()
solution.graded_by = grader
db.session.commit()
db.session.commit()
return {'state': graded}
from flask_restful import Resource
from flask import request
from ..database import db, Widget, ExamWidget
from ..database import db, Widget, ExamWidget, MultipleChoiceOption
class Widgets(Resource):
......@@ -15,6 +15,8 @@ class Widgets(Resource):
return dict(status=404, message=msg), 404
elif isinstance(widget, ExamWidget) and widget.exam.finalized:
return dict(status=403, message=f'Exam is finalized'), 403
elif isinstance(widget, MultipleChoiceOption) and widget.feedback.problem.exam.finalized:
return dict(status=405, message=f'Exam is finalized'), 405
# will 400 on malformed json
body = request.get_json()
......
......@@ -7,6 +7,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from flask_sqlalchemy.model import BindMetaMixin, Model
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.orm import backref
from sqlalchemy.orm.session import object_session
from sqlalchemy.ext.hybrid import hybrid_property
......@@ -117,7 +118,8 @@ class FeedbackOption(db.Model):
text = Column(Text, nullable=False)
description = Column(Text, nullable=True)
score = Column(Integer, nullable=True)
mc_option = db.relationship('MultipleChoiceOption', backref='feedback', cascade='delete', uselist=False, lazy=True)
mc_option = db.relationship('MultipleChoiceOption', backref=backref('feedback', cascade='all'),
cascade='all', uselist=False, lazy=True)
# Table for many to many relationship of FeedbackOption and Solution
......
......@@ -3,43 +3,6 @@
import numpy as np
def add_tup(tup1, tup2):
"""
Adds two tuples
Parameters
----------
tup1 : tuple
Tuple 1
tup2 : tuple
Tuple 2
Returns
-------
tup : tuple
The tuple with the sum of the values in tup1 and tup2.
"""
return tup1[0] + tup2[0], tup1[1] + tup2[1]
def sub_tup(tup1, tup2):
"""Subtracts two tuples
Parameters
----------
tup1 : tuple
Tuple 1
tup2 : tuple
Tuple 2
Returns
-------
tup : tuple
The tuple with the difference between the values in tup1 and tup2.
"""
return tup1[0] - tup2[0], tup1[1] - tup2[1]
def guess_dpi(image_array):
h, *_ = image_array.shape
resolutions = np.array([1200, 600, 400, 300, 200, 150, 144, 120, 100, 75, 72, 60, 50, 40])
......@@ -71,147 +34,3 @@ def get_box(image_array, box, padding=0.3):
top, bottom = max(0, min(box[0], h)), max(1, min(box[1], h))
left, right = max(0, min(box[2], w)), max(1, min(box[3], w))
return image_array[top:bottom, left:right]
def get_corner_marker_sides(corner_markers, shape):
"""Divides a list of corner markers in the right sides:
Parameters
----------
corner_markers : list of tuples
The list of corner marker points
shape: tuple
The shape of an image
Returns
-------
tuples : tuple
The corner markers divided into sides
"""
def get_val(tup_list):
"""
Returns a tuple if present in the list.
Parameters
----------
tup_list : list of tuples
List with one tuple
Returns
-------
tup : tuple or None
Tuple in list or empty list
"""
return tup_list[0] if tup_list else None
x_sep = shape[1] / 2
y_sep = shape[0] / 2
top_left = get_val([(x, y) for x, y in corner_markers if x < x_sep and y < y_sep])
top_right = get_val([(x, y) for x, y in corner_markers if x > x_sep and y < y_sep])
bottom_left = get_val([(x, y) for x, y in corner_markers if x < x_sep and y > y_sep])
bottom_right = get_val([(x, y) for x, y in corner_markers if x > x_sep and y > y_sep])
return top_left, top_right, bottom_left, bottom_right
def get_delta(top_left, top_right, bottom_left, bottom_right):
"""Returns the absolute difference between the left or right points
Parameters
top_left : tuple
Top left point
top_right : tuple
Top right point
bottom_left : tuple
Bottom left point
bottom_right : tuple
Bottom right point
Returns
-------
delta : tuple
The absolute difference as an (x, y) tuple
"""
if not top_left or not bottom_left:
return sub_tup(top_right, bottom_right)
return sub_tup(top_left, bottom_left)
def fix_corner_markers(corner_keypoints, shape):
"""Corrects the list of corner markers if three corner markers are found.
This function raises if less than three corner markers are found.
Parameters
----------
corner_keypoints : list of tuples
List of corner marker locations as tuples
shape : (float, float, int)
Shape of the image in (x, y, dim)
Returns
-------
fixed_corners : (float, float)
A list of four corner markers.
"""
if len(corner_keypoints) == 4:
return corner_keypoints
if len(corner_keypoints) < 3:
raise RuntimeError("Fewer than 3 corner markers found while trying to fix corners")
top_left, top_right, bottom_left, bottom_right = get_corner_marker_sides(corner_keypoints, shape)
delta = get_delta(top_left, top_right, bottom_left, bottom_right)
if not top_left:
top_left = add_tup(bottom_left, delta)
if not top_right:
top_right = add_tup(bottom_right, delta)
if not bottom_left:
bottom_left = sub_tup(top_left, delta)
if not bottom_right:
bottom_right = sub_tup(top_right, delta)
return [top_left, top_right, bottom_left, bottom_right]
def box_is_filled(image_array, box_coords, padding=0.3, threshold=150, pixels=False):
"""
Determines if a box is filled
Parameters:
-----------
image_array : 2D or 3D array
The image source.
box_coords : 4 floats (top, bottom, left, right)
Coordinates of the bounding box in inches or pixels. By due to differing
traditions, box coordinates are counted from the bottom left of the
image, while image array coordinates are from the top left.
padding : float
Padding around box borders in inches.
threshold : int
Optional threshold value to determine minimal 'darkness'
to consider a box to be filled in
pixels : boolean
Whether the box coordinates are entered as pixels instead of inches.
"""
# Divide by DPI if pixel coordinates are used
if pixels:
box_coords /= guess_dpi(image_array)
box_img = get_box(image_array, box_coords, padding)
# Check if the coordinates are outside of the image
if box_img.size == 0:
raise RuntimeError("Box coordinates are outside of image")
avg = np.average(box_img)
return avg < threshold
......@@ -171,9 +171,9 @@ def generate_id_grid(canv, x, y):
textboxwidth, textboxheight)
def generate_checkbox(canvas, x, y, label):
def add_checkbox(canvas, x, y, label):
"""
draw a checkbox and draw a singel character label ontop of the checkbox
draw a checkbox and draw a single character on top of the checkbox
Parameters
----------
......@@ -314,7 +314,7 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
# call generate for all checkboxes that belong to the current page
while index < max_index and cb_data[index][2] <= page_num:
x, y, _, label = cb_data[index]
generate_checkbox(canv, x, y, label)
add_checkbox(canv, x, y, label)
index += 1
canv.showPage()
......@@ -407,7 +407,7 @@ def page_is_size(exam_pdf_file, shape, tolerance=0):
return not invalid
def make_pages_even(output_filename, exam_pdf_file):
def make_pages_even(exam_pdf_file):
exam_pdf = PdfReader(exam_pdf_file)
new = PdfWriter()
new.addpages(exam_pdf.pages)
......@@ -420,4 +420,4 @@ def make_pages_even(output_filename, exam_pdf_file):
blank = blank.render()
new.addpage(blank)
new.write(output_filename)
return new
import os
import itertools
from pdfminer3.converter import PDFPageAggregator
from pdfminer3.layout import LAParams
......@@ -10,38 +10,71 @@ from pdfminer3.pdfinterp import PDFPageInterpreter
from pdfminer3.pdfpage import PDFPage
from pdfminer3.pdfparser import PDFParser
from .api.exams import PAGE_FORMATS
def get_problem_title(problem, data_dir, page_format):
def get_problem_page(problem, pdf_path):
"""
Returns the title of a problem
Returns the pdf object belonging to the page of a problem widget
Parameters
----------
data_dir : str
Location of the data folder
page_format : str
Format of the current page
problem : Problem
The currently selected problem
Problem object in the database of the currently selected problem
pdf_path : str
Path to the PDF file of the exam for this problem
Returns
-------
title: str
The title of the problem, or an empty string if no text is found
page : PDFPage
PDFPage object with information about the current page
"""
pdf_path = os.path.join(data_dir, f'{problem.exam_id}_data', 'exam.pdf')
fp = open(pdf_path, 'rb')
parser = PDFParser(fp)
document = PDFDocument(parser)
rsrcmgr = PDFResourceManager()
laparams = LAParams()
device = PDFPageAggregator(rsrcmgr, laparams=laparams)
interpreter = PDFPageInterpreter(rsrcmgr, device)
page_number = problem.widget.page
return next(itertools.islice(PDFPage.create_pages(document), page_number, page_number + 1))
def layout(pdf_page):
"""
Returns the layout objects in a PDF page
Parameters
----------
pdf_page : PDFPage
PDFPage object with information about the current page
Returns
-------
layout : list of pdfminer3 layout objects
A list of layout objects on the page
"""
resource_manager = PDFResourceManager()
la_params = LAParams()
device = PDFPageAggregator(resource_manager, laparams=la_params)
interpreter = PDFPageInterpreter(resource_manager, device)
interpreter.process_page(pdf_page)
return device.get_result()
def guess_problem_title(problem, pdf_page):
"""
Tries to find the title of a problem
Parameters
----------
problem : Problem
The currently selected problem
pdf_page : PDFPage
Information extracted from the PDF page where the problem is located.
Returns
-------
title: str
The title of the problem, or an empty string if no text is found
"""
# Get the other problems on the same page
problems_on_page = [p for p in problem.exam.problems if p.widget.page == problem.widget.page]
......@@ -57,64 +90,58 @@ def get_problem_title(problem, data_dir, page_format):
y_above = problem_above.widget.y + problem_above.widget.height
y_current = problem.widget.y + problem.widget.height
page_height = pdf_page.mediabox[3]
for page in PDFPage.create_pages(document):
interpreter.process_page(page)
layout = device.get_result()
if layout.pageid == problem.widget.page + 1:
filtered_words = get_words(layout._objs, y_above, y_current, page_format)
layout_objects = layout(pdf_page)
filtered_words = read_lines(layout_objects._objs, y_above, y_current, page_height)
if not filtered_words:
return ''
if not filtered_words:
return ''
lines = filtered_words[0].split('\n')
return lines[0]
lines = filtered_words.split('\n')
return lines[0].strip()
return ''
def get_words(layout_objs, y_top, y_bottom, page_format):
def read_lines(layout_objs, y_top, y_bottom, page_height):
"""
Returns the text from a pdf page within a specified height.
Pdfminer orients the coordinates of a layout object from
the bottom left.
Adapted from https://github.com/euske/pdfminer/issues/171
obj.bbox returns the following values: (x0, y0, x1, y1)
With
x0: the distance from the left of the page to the left edge of the box.
y0: the distance from the bottom of the page to the lower edge of the box.
x1: the distance from the left of the page to the right edge of the box.
y1: the distance from the bottom of the page to the upper edge of the box.
Returns lines of text from a PDF page within a specified height.
Parameters
----------
page_format : str
Format of the current page
layout_objs : list of layout objects
The list of objects in the page.
y_top : double
Highest top coordinate of each word
y_bottom : double
Lowest bottom coordinate of each word
page_height : int
Height of the current page in points
Returns
-------
words : list of tuples
A list of tuples with the (y, text) values.
words : str
The first line of text that if it is found, or else an empty string
"""
page_height = PAGE_FORMATS[page_format][1]
words = []
# Adapted from https://github.com/euske/pdfminer/issues/171
#
# obj.bbox returns the following values: (x0, y0, x1, y1), where
#
# x0: the distance from the left of the page to the left edge of the box.
# y0: the distance from the bottom of the page to the lower edge of the box.
# x1: the distance from the left of the page to the right edge of the box.
# y1: the distance from the bottom of the page to the upper edge of the box.
for obj in layout_objs:
if isinstance(obj, LTTextBoxHorizontal):
if page_height - y_top > obj.bbox[1] > page_height - y_bottom:
if y_bottom > page_height - obj.bbox[1] > y_top:
words.append(obj.get_text())
elif isinstance(obj, LTFigure):
words.append(get_words(obj._objs, y_top, y_bottom, page_format))
words += read_lines(obj._objs, y_top, y_bottom, page_height)
if not words:
return ''
return words
return words[0]
import cv2
import numpy as np
from .database import db, Solution
from .database import db
from .images import guess_dpi, get_box
from .pdf_generation import CHECKBOX_FORMAT
def add_feedback_to_solution(sub, exam, page, page_img):
def grade_mcq(sub, page, page_img):
"""
Adds the multiple choice options that are identified as marked as a feedback option to a solution
......@@ -14,23 +14,25 @@ def add_feedback_to_solution(sub, exam, page, page_img):
------
sub : Submission
the current submission
exam : Exam
the current exam
page_img : Image
page : int
Page number of the submission
page_img : np.array
image of the page
"""
problems_on_page = [problem for problem in exam.problems if problem.widget.page == page]
solutions_to_grade = [
sol for sol in sub.solutions
if sol.graded_at and sol.problem.widget.page == page
]
for problem in problems_on_page:
sol = Solution.query.filter(Solution.problem_id == problem.id, Solution.submission_id == sub.id).one_or_none()
for mc_option in problem.mc_options:
for sol in solutions_to_grade:
for mc_option in sol.problem.mc_options:
box = (mc_option.x, mc_option.y)
if box_is_filled(box, page_img, box_size=CHECKBOX_FORMAT["box_size"]):
feedback = mc_option.feedback
sol.feedback.append(feedback)
db.session.commit()
db.session.commit()
def box_is_filled(box, page_img, threshold=225, cut_padding=0.05, box_size=9):
......@@ -60,35 +62,25 @@ def box_is_filled(box, page_img, threshold=225, cut_padding=0.05, box_size=9):
coords = np.asarray([box[1], box[1] + box_size,
box[0], box[0] + box_size])/72
# add the actually margin from the scan to corner markers to the coords in inches
dpi = guess_dpi(page_img)
# get the box where we think the box is
cut_im = get_box(page_img, coords, padding=cut_padding)
# convert to grayscale
gray_im = cv2.cvtColor(cut_im, cv2.COLOR_BGR2GRAY)
# apply threshold to only have black or white
_, bin_im = cv2.threshold(gray_im, 160, 255, cv2.THRESH_BINARY)
h_bin, w_bin, *_ = bin_im.shape
# create a mask that gets applied when floodfill the white
mask = np.zeros((h_bin+2, w_bin+2), np.uint8)
flood_im = bin_im.copy()
# fill the image from the top left
cv2.floodFill(flood_im, mask, (0, 0), 0)
# fill it from the bottom right just in case the top left doesn't cover all the white
cv2.floodFill(flood_im, mask, (h_bin-2, w_bin-2), 0)
# find white parts
coords = cv2.findNonZero(flood_im)
# Find a bounding box of the white parts
x, y, w, h = cv2.boundingRect(coords)
# cut the image to this box
res_rect = bin_im[y:y+h, x:x+w]
# the size in pixels we expect the drawn box to
box_size_px = box_size*dpi / 72
box_size_px = box_size * dpi / 72
# if the rectangle is bigger (higher) than expected, cut the image up a bit
if h > 1.5 * box_size_px:
......@@ -106,7 +98,7 @@ def box_is_filled(box, page_img, threshold=225, cut_padding=0.05, box_size=9):
# do the same for width
if w2 > 1.5 * box_size_px:
# usually the checkbox is somewhere in the bottom left of the bounding box
# usually the checkbox is somewhere in the bottom left of the bounding box after applying the previous steps
coords3 = cv2.findNonZero(flood_im[new_y: new_y + h2, new_x: new_x + int(0.66 * w2)])
x3, y3, w3, h3 = cv2.boundingRect(coords3)
res_rect = bin_im[new_y + y3: new_y + y3 + h3, new_x + x3: new_x + x3 + w3]
......
......@@ -9,17 +9,19 @@ import signal
import cv2
import numpy as np
from scipy import spatial
from pikepdf import Pdf, PdfImage
from PIL import Image
from wand.image import Image as WandImage
from pylibdmtx import pylibdmtx
from sqlalchemy.exc import InternalError
from .database import db, Scan, Exam, Page, Student, Submission, Solution, ExamWidget
from .datamatrix import decode_raw_datamatrix
from .images import guess_dpi, get_box
from .factory import make_celery
from .pregrader import add_feedback_to_solution
from .images import fix_corner_markers
from .pregrader import grade_mcq
from .pdf_generation import MARKER_FORMAT, PAGE_FORMATS
......@@ -58,7 +60,7 @@ def process_pdf(scan_id):
# TODO: When #182 is implemented, properly separate user-facing
# messages (written to DB) from developer-facing messages,
# which should be written into the log.
write_pdf_status(scan_id, 'error', f"Unexpected error: {error}")
write_pdf_status(scan_id, 'error', "Unexpected error: " + str(error))
def _process_pdf(scan_id, app_config):
......@@ -97,8 +99,8 @@ def _process_pdf(scan_id, app_config):
print(description)
failures.append(page)
except Exception as e:
report_error(f'Error processing page {e}')
raise
report_error(f'Error processing page {page}: {e}')
return
except Exception as e:
report_error(f"Failed to read pdf: {e}")
raise
......@@ -278,7 +280,8 @@ def process_page(image_data, exam_config, output_dir=None, strict=False):
3. Verify it satisfies the format required by zesje
4. Verify it belongs to the correct exam
5. Incorporate the page in the database
6. If the page contains student number, try to read it off the page
6. Perform pregrading
7. If the page contains student number, try to read it off the page
Parameters
----------
......@@ -316,11 +319,10 @@ def process_page(image_data, exam_config, output_dir=None, strict=False):
corner_keypoints = find_corner_marker_keypoints(image_array)
try:
check_corner_keypoints(image_array, corner_keypoints)
image_array = realign_image(image_array, corner_keypoints)
except RuntimeError as e:
if strict:
return False, str(e)
else:
image_array = realign_image(image_array, corner_keypoints)
try:
barcode, upside_down = decode_barcode(image_array, exam_config)
......@@ -338,11 +340,11 @@ def process_page(image_data, exam_config, output_dir=None, strict=False):
else:
return True, "Testing, image not saved and database not updated."
sub, exam = update_database(image_path, barcode)
sub = update_database(image_path, barcode)
try:
add_feedback_to_solution(sub, exam, barcode.page, image_array)
except RuntimeError as e:
grade_mcq(sub, barcode.page, image_array)
except InternalError as e:
if strict:
return False, str(e)
......@@ -392,12 +394,8 @@ def update_database(image_path, barcode):
Returns
-------
sub, exam where
sub : Submission
the current submission
exam : Exam
the current exam
"""
exam = Exam.query.filter(Exam.token == barcode.token).first()
if exam is None:
......@@ -417,7 +415,7 @@ def update_database(image_path, barcode):
db.session.commit()
return sub, exam
return sub
def decode_barcode(image, exam_config):
......@@ -693,8 +691,7 @@ def check_corner_keypoints(image_array, keypoints):
raise RuntimeError("Found multiple corner markers in the same corner")
def realign_image(image_array, keypoints=None,
reference_keypoints=None, page_format="A4"):
def realign_image(image_array, keypoints=None, page_format="A4"):
"""
Transform the image so that the keypoints match the reference.
......@@ -704,48 +701,48 @@ def realign_image(image_array, keypoints=None,
The image in the form of a numpy array.
keypoints : List[(int,int)]
tuples of coordinates of the found keypoints, (x,y), in pixels. Can be a set of 3 or 4 tuples.
if none are provided, they are calculated based on the image_array.
reference_keypoints: List[(int,int)]
Similar to keypoints, only these belong to the keypoints found on the original scan.
If none are provided, standard locations are used. Namely [(59, 59), (1179, 59), (59, 1693), (1179, 1693)],
which are from an ideal scan of an a4 at 200 dpi.
if none are provided, they are found by using find_corner_marker_keypoints on the input image.
returns
-------
return_array: numpy.array
The image realign properly.
return_keypoints: List[(int,int)]
New keypoints properly aligned.
The image realigned properly.
"""
if (not keypoints):
if not keypoints:
keypoints = find_corner_marker_keypoints(image_array)
check_corner_keypoints(image_array, keypoints)
if(len(keypoints) != 4):
keypoints = fix_corner_markers(keypoints, image_array.shape)
keypoints = np.asarray(keypoints)
if not len(keypoints):
raise RuntimeError("No keypoints provided for alignment.")
# generate the coordinates where the markers should be
dpi = guess_dpi(image_array)
reference_keypoints = original_corner_markers(page_format, dpi)
# create a matrix with the distances between each keypoint and match the keypoint sets
dists = spatial.distance.cdist(keypoints, reference_keypoints)
# use standard keypoints if no custom ones are provided
if (not reference_keypoints):
dpi = guess_dpi(image_array)
reference_keypoints = generate_perfect_corner_markers(page_format, dpi)
idxs = np.argmin(dists, 1) # apply to column 1 so indices for input keypoints
adjusted_markers = reference_keypoints[idxs]
if (len(reference_keypoints) != 4):
# this function assumes that the template has the same dimensions as the input image
reference_keypoints = fix_corner_markers(reference_keypoints, image_array.shape)
if len(adjusted_markers) == 1:
x_shift, y_shift = np.subtract(adjusted_markers[0], keypoints[0])
return shift_image(image_array, x_shift, y_shift)
rows, cols, _ = image_array.shape
# get the transformation matrix
M = cv2.getPerspectiveTransform(np.float32(keypoints), np.float32(reference_keypoints))
M = cv2.estimateAffinePartial2D(keypoints, adjusted_markers)[0]
# apply the transformation matrix and fill in the new empty spaces with white
return_image = cv2.warpPerspective(image_array, M, (cols, rows),
borderValue=(255, 255, 255, 255))
return_image = cv2.warpAffine(image_array, M, (cols, rows),
borderValue=(255, 255, 255, 255))
return return_image
def generate_perfect_corner_markers(format="A4", dpi=200):
def original_corner_markers(format, dpi):
left_x = MARKER_FORMAT["margin"]/72 * dpi
top_y = MARKER_FORMAT["margin"]/72 * dpi
right_x = (PAGE_FORMATS[format][0] - MARKER_FORMAT["margin"])/72 * dpi
......@@ -755,3 +752,28 @@ def generate_perfect_corner_markers(format="A4", dpi=200):
(right_x, top_y),
(left_x, bottom_y),
(right_x, bottom_y)])
def shift_image(image_array, x_shift, y_shift):
"""
take an image and shift it along the x and y axis using opencv
params:
-------
image_array: numpy.array
an array of the image to be shifted
x_shift: int
indicates how many pixels it has to be shifted on the x-axis, so from left to right
y_shift: int
indicates how many pixels it has to be shifted on the y-axis, so from top to bottom
returns:
--------
a numpy array of the image shifted where empty spaces are filled with white
"""
M = np.float32([[1, 0, x_shift],
[0, 1, y_shift]])
h, w, _ = image_array.shape
return cv2.warpAffine(image_array, M, (w, h),
borderValue=(255, 255, 255, 255))