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
......@@ -9,9 +9,11 @@ from flask_restful import Resource, reqparse
from werkzeug.datastructures import FileStorage
from sqlalchemy.orm import selectinload
from ..pdf_generation import generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size
from ..pdf_generation import generate_pdfs, output_pdf_filename_format, join_pdfs, page_is_size, make_pages_even
from ..database import db, Exam, ExamWidget, Submission
PAGE_FORMATS = {
"A4": (595.276, 841.89),
"US letter": (612, 792),
......@@ -25,6 +27,33 @@ def _get_exam_dir(exam_id):
)
def get_cb_data_for_exam(exam):
"""
Returns all multiple choice question check boxes for one specific exam
Parameters
----------
exam: the exam
Returns
-------
A list of tuples with checkbox data.
Each tuple is represented as (x, y, page, label)
Where
x: x position
y: y position
page: page number
label: checkbox label
"""
cb_data = []
for problem in exam.problems:
page = problem.widget.page
cb_data += [(cb.x, cb.y, page, cb.label) for cb in problem.mc_options]
return cb_data
class Exams(Resource):
def get(self, exam_id=None):
......@@ -111,30 +140,30 @@ class Exams(Resource):
return dict(status=404, message='Exam does not exist.'), 404
submissions = [
{
'id': sub.copy_number,
'student': {
'id': sub.student.id,
'firstName': sub.student.first_name,
'lastName': sub.student.last_name,
'email': sub.student.email
} if sub.student else None,
'validated': sub.signature_validated,
'problems': [
{
'id': sol.problem.id,
'graded_by': {
'id': sol.graded_by.id,
'name': sol.graded_by.name
} if sol.graded_by else None,
'graded_at': sol.graded_at.isoformat() if sol.graded_at else None,
'feedback': [
fb.id for fb in sol.feedback
],
'remark': sol.remarks if sol.remarks else ""
} for sol in sub.solutions # Sorted by sol.problem_id
],
} for sub in exam.submissions
{
'id': sub.copy_number,
'student': {
'id': sub.student.id,
'firstName': sub.student.first_name,
'lastName': sub.student.last_name,
'email': sub.student.email
} if sub.student else None,
'validated': sub.signature_validated,
'problems': [
{
'id': sol.problem.id,
'graded_by': {
'id': sol.graded_by.id,
'name': sol.graded_by.name
} if sol.graded_by else None,
'graded_at': sol.graded_at.isoformat() if sol.graded_at else None,
'feedback': [
fb.id for fb in sol.feedback
],
'remark': sol.remarks if sol.remarks else ""
} for sol in sub.solutions # Sorted by sol.problem_id
],
} for sub in exam.submissions
]
# Sort submissions by selecting those with students assigned, then by
# student number, then by copy number.
......@@ -171,8 +200,22 @@ class Exams(Resource):
'y': prob.widget.y,
'width': prob.widget.width,
'height': prob.widget.height,
'type': prob.widget.type
},
'graded': any([sol.graded_by is not None for sol in prob.solutions])
'graded': any([sol.graded_by is not None for sol in prob.solutions]),
'mc_options': [
{
'id': mc_option.id,
'label': mc_option.label,
'feedback_id': mc_option.feedback_id,
'widget': {
'name': mc_option.name,
'x': mc_option.x,
'y': mc_option.y,
'type': mc_option.type
}
} for mc_option in prob.mc_options
]
} for prob in exam.problems # Sorted by prob.id
],
'widgets': [
......@@ -181,6 +224,7 @@ class Exams(Resource):
'name': widget.name,
'x': widget.x,
'y': widget.y,
'type': widget.type
} for widget in exam.widgets # Sorted by widget.id
],
'finalized': exam.finalized,
......@@ -243,10 +287,9 @@ class Exams(Resource):
exam_dir = _get_exam_dir(exam.id)
pdf_path = os.path.join(exam_dir, 'exam.pdf')
os.makedirs(exam_dir, exist_ok=True)
pdf_data.save(pdf_path)
make_pages_even(pdf_path, args['pdf'])
print(f"Added exam {exam.id} (name: {exam_name}, token: {exam.token}) to database")
......@@ -332,13 +375,16 @@ class ExamGeneratedPdfs(Resource):
generated_pdfs_dir = self._get_generated_exam_dir(exam_dir)
os.makedirs(generated_pdfs_dir, exist_ok=True)
cb_data = get_cb_data_for_exam(exam)
generate_pdfs(
exam_path,
exam.token,
copy_nums,
pdf_paths,
student_id_widget.x, student_id_widget.y,
barcode_widget.x, barcode_widget.y
barcode_widget.x, barcode_widget.y,
cb_data
)
post_parser = reqparse.RequestParser()
......@@ -488,13 +534,15 @@ class ExamPreview(Resource):
exam_path = os.path.join(exam_dir, 'exam.pdf')
cb_data = get_cb_data_for_exam(exam)
generate_pdfs(
exam_path,
exam.token[:5] + 'PREVIEW',
[1519],
[output_file],
student_id_widget.x, student_id_widget.y,
barcode_widget.x, barcode_widget.y
barcode_widget.x, barcode_widget.y,
cb_data
)
output_file.seek(0)
......
......@@ -125,6 +125,9 @@ class Feedback(Resource):
problem = fb.problem
if problem.id != problem_id:
return dict(status=409, message="Feedback does not match the problem."), 409
if fb.mc_option and problem.exam.finalized:
return dict(status=401, message='Cannot delete feedback option'
+ ' attached to a multiple choice option in a finalized exam.'), 401
db.session.delete(fb)
......@@ -137,4 +140,10 @@ class Feedback(Resource):
solution.grader_id = None
solution.graded_at = None
# Delete mc_options associated with this feedback option
if fb.mc_option:
db.session.delete(fb.mc_option)
db.session.commit()
return dict(status=200, message=f"Feedback option with id {feedback_id} deleted."), 200
from flask_restful import Resource, reqparse
from ..database import db, MultipleChoiceOption, FeedbackOption
def set_mc_data(mc_entry, name, x, y, mc_type, feedback_id, label):
"""Sets the data of a MultipleChoiceOption ORM object.
Parameters:
-----------
mc_entry: The MultipleChoiceOption object
name: The name of the MultipleChoiceOption widget
x: the x-position of the MultipleChoiceOption object.
y: the y-position of the MultipleChoiceOption object.
type: the polymorphic type used to distinguish the MultipleChoiceOption widget
from other widgets
feedback_id: the feedback the MultipleChoiceOption refers to
label: label for the checkbox that this MultipleChoiceOption represents
"""
mc_entry.name = name
mc_entry.x = x
mc_entry.y = y
mc_entry.type = mc_type
mc_entry.feedback_id = feedback_id
mc_entry.label = label
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)
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)
put_parser.add_argument('fb_description', type=str, required=False)
put_parser.add_argument('fb_score', type=str, required=False)
put_parser.add_argument('problem_id', type=int, required=True) # Used for FeedbackOption
def put(self):
"""Adds a multiple choice option to the database
For each new multiple choice option, a feedback option that links to
the multiple choice option is inserted into the database. The new
feedback option also refers to same problem as the MultipleChoiceOption
"""
args = self.put_parser.parse_args()
# Get request arguments
name = args['name']
x = args['x']
y = args['y']
label = args['label']
fb_description = args['fb_description']
fb_score = args['fb_score']
problem_id = args['problem_id']
mc_type = 'mcq_widget'
# 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()
set_mc_data(mc_entry, name, x, y, mc_type, new_feedback_option.id, label)
db.session.add(mc_entry)
db.session.commit()
return dict(status=200, mult_choice_id=mc_entry.id, feedback_id=new_feedback_option.id,
message=f'New multiple choice question with id {mc_entry.id} inserted. '
+ f'New feedback option with id {new_feedback_option.id} inserted.'), 200
def get(self, id):
"""Fetches multiple choice option from the database
Parameters
----------
id: The ID of the multiple choice option in the database
Returns
-------
A JSON object with the multiple choice option data
"""
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
json = {
'id': mult_choice.id,
'name': mult_choice.name,
'x': mult_choice.x,
'y': mult_choice.y,
'type': mult_choice.type,
'feedback_id': mult_choice.feedback_id
}
# Nullable database fields
if mult_choice.label:
json['label'] = mult_choice.label
return json
def patch(self, id):
"""
Updates a multiple choice option
Parameters
----------
id: The id of the multiple choice option in the database.s
"""
args = self.put_parser.parse_args()
name = args['name']
x = args['x']
y = args['y']
label = args['label']
mc_type = 'mcq_widget'
mc_entry = MultipleChoiceOption.query.get(id)
if not mc_entry:
return dict(status=404, message=f"Multiple choice question with id {id} does not exist"), 404
set_mc_data(mc_entry, name, x, y, mc_type, mc_entry.feedback_id, label)
db.session.commit()
return dict(status=200, message=f'Multiple choice question with id {id} updated'), 200
def delete(self, id):
"""Deletes a multiple choice option from the database.
Also deletes the associated feedback option with this multiple choice option.
An error will be thrown if the user tries to delete a feedback option
associated with a multiple choice option in a finalized exam.
Parameters
----------
id: The ID of the multiple choice option in the database
Returns
-------
A message indicating success or failure
"""
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
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
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,
message=f'Multiple choice question with id {id} deleted.'
+ f'Feedback option with id {mult_choice.feedback_id} deleted.'), 200
......@@ -108,6 +108,9 @@ class Problems(Resource):
# Delete all solutions associated with this problem
for sol in problem.solutions:
db.session.delete(sol)
# Delete all multiple choice options associated with this problem
for mc_option in problem.mc_options:
db.session.delete(mc_option)
db.session.delete(problem.widget)
db.session.delete(problem)
db.session.commit()
......
......@@ -147,3 +147,43 @@ class Solutions(Resource):
db.session.commit()
return {'state': state}
class Approve(Resource):
""" add just a grader to a specifc problem on an exam """
put_parser = reqparse.RequestParser()
put_parser.add_argument('graderID', type=int, required=True)
def put(self, exam_id, submission_id, problem_id):
"""Takes an existing feedback checks if it is valid then gives the current graders id to the solution this is
usefull for approving pre graded solutions
Parameters
----------
graderID: int
Returns
-------
state: boolean
"""
args = self.put_parser.parse_args()
grader = Grader.query.get(args.graderID)
sub = Submission.query.filter(Submission.exam_id == exam_id,
Submission.copy_number == submission_id).one_or_none()
if sub is None:
return dict(status=404, message='Submission does not exist.'), 404
solution = Solution.query.filter(Solution.submission_id == sub.id,
Solution.problem_id == problem_id).one_or_none()
if solution is None:
return dict(status=404, message='Solution does not exist.'), 404
graded = len(solution.feedback)
if graded:
solution.graded_at = datetime.now()
solution.graded_by = grader
return {'state': graded}
......@@ -8,6 +8,7 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Foreign
from flask_sqlalchemy.model import BindMetaMixin, Model
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
from sqlalchemy.orm.session import object_session
from sqlalchemy.ext.hybrid import hybrid_property
# Class for NOT automatically determining table names
......@@ -100,6 +101,10 @@ class Problem(db.Model):
solutions = db.relationship('Solution', backref='problem', lazy=True)
widget = db.relationship('ProblemWidget', backref='problem', uselist=False, lazy=True)
@hybrid_property
def mc_options(self):
return [feedback_option.mc_option for feedback_option in self.feedback_options if feedback_option.mc_option]
class FeedbackOption(db.Model):
"""feedback option"""
......@@ -109,6 +114,7 @@ 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)
# Table for many to many relationship of FeedbackOption and Solution
......@@ -160,6 +166,18 @@ class Widget(db.Model):
}
class MultipleChoiceOption(Widget):
__tablename__ = 'mc_option'
id = Column(Integer, ForeignKey('widget.id'), primary_key=True, autoincrement=True)
label = Column(String, nullable=True)
feedback_id = Column(Integer, ForeignKey('feedback_option.id'), nullable=False)
__mapper_args__ = {
'polymorphic_identity': 'mcq_widget'
}
class ExamWidget(Widget):
__tablename__ = 'exam_widget'
id = Column(Integer, ForeignKey('widget.id'), primary_key=True, nullable=False)
......
......@@ -2,6 +2,8 @@
import numpy as np
from operator import sub, add
def guess_dpi(image_array):
h, *_ = image_array.shape
......@@ -34,3 +36,97 @@ 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 fix_corner_markers(corner_keypoints, shape):
"""
Corrects the list of corner markers if only three corner markers are found.
This function raises if less than three corner markers are detected.
Parameters
----------
corner_keypoints :
List of corner marker locations as tuples
shape :
Shape of the image in (x, y, dim)
Returns
-------
corner_keypoints :
A list of four corner markers.
"""
if len(corner_keypoints) == 4:
return corner_keypoints
if len(corner_keypoints) < 3:
raise RuntimeError("Fewer then 3 corner markers found")
x_sep = shape[1] / 2
y_sep = shape[0] / 2
top_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y < y_sep]
bottom_left = [(x, y) for x, y in corner_keypoints if x < x_sep and y > y_sep]
top_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y < y_sep]
bottom_right = [(x, y) for x, y in corner_keypoints if x > x_sep and y > y_sep]
missing_point = ()
if not top_left:
# Top left point is missing
(dx, dy) = tuple(map(sub, top_right[0], bottom_right[0]))
missing_point = tuple(map(add, bottom_left[0], (dx, dy)))
elif not bottom_left:
# Bottom left point is missing
(dx, dy) = tuple(map(sub, top_right[0], bottom_right[0]))
missing_point = tuple(map(sub, top_left[0], (dx, dy)))
elif not top_right:
# Top right point is missing
(dx, dy) = tuple(map(sub, top_left[0], bottom_left[0]))
missing_point = tuple(map(add, bottom_right[0], (dx, dy)))
elif not bottom_right:
# bottom right
(dx, dy) = tuple(map(sub, top_left[0], bottom_left[0]))
missing_point = tuple(map(sub, top_right[0], (dx, dy)))
corner_keypoints.append(missing_point)
return corner_keypoints
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
......@@ -12,7 +12,7 @@ output_pdf_filename_format = '{0:05d}.pdf'
def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
id_grid_y, datamatrix_x, datamatrix_y):
id_grid_y, datamatrix_x, datamatrix_y, cb_data=None):
"""
Generate the final PDFs from the original exam PDF.
......@@ -24,7 +24,6 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
If maximum interchangeability with version 1 QR codes is desired (error
correction level M), use exam IDs composed of only uppercase letters, and
composed of at most 12 letters.
Parameters
----------
exam_pdf_file : file object or str
......@@ -43,6 +42,9 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
The x coordinate where the DataMatrix code should be placed
datamatrix_y : int
The y coordinate where the DataMatrix code should be placed
cb_data : list[ (int, int, int, str)]
The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label
"""
exam_pdf = PdfReader(exam_pdf_file)
mediabox = exam_pdf.pages[0].MediaBox
......@@ -56,7 +58,7 @@ def generate_pdfs(exam_pdf_file, exam_id, copy_nums, output_paths, id_grid_x,
overlay_canv = canvas.Canvas(overlay_file.name, pagesize=pagesize)
_generate_overlay(overlay_canv, pagesize, exam_id, copy_num,
len(exam_pdf.pages), id_grid_x, id_grid_y,
datamatrix_x, datamatrix_y)
datamatrix_x, datamatrix_y, cb_data)
overlay_canv.save()
# Merge overlay and exam
......@@ -151,6 +153,38 @@ def generate_id_grid(canv, x, y):
textboxwidth, textboxheight)
def generate_checkbox(canvas, x, y, label):
"""
draw a checkbox and draw a singel character label ontop of the checkbox
Parameters
----------
canvas : reportlab canvas object
x : int
the x coordinate of the top left corner of the box in points (pt)
y : int
the y coordinate of the top left corner of the box in points (pt)
label: str
A string representing the label that is drawn on top of the box, will only take the first character
"""
fontsize = 11 # Size of font
margin = 5 # Margin between elements and sides
markboxsize = fontsize - 2 # Size of checkboxes boxes
x_label = x + 1 # location of the label
y_label = y + margin # remove fontsize from the y label since we draw from the bottom left up
box_y = y - markboxsize # remove the markboxsize because the y is the coord of the top
# and reportlab prints from the bottom
# check that there is a label to print
if (label and not (len(label) == 0)):
canvas.setFont('Helvetica', fontsize)
canvas.drawString(x_label, y_label, label[0])
canvas.rect(x, box_y, markboxsize, markboxsize)
def generate_datamatrix(exam_id, page_num, copy_num):
"""
Generates a DataMatrix code to be used on a page.
......@@ -187,7 +221,7 @@ def generate_datamatrix(exam_id, page_num, copy_num):
def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
id_grid_y, datamatrix_x, datamatrix_y):
id_grid_y, datamatrix_x, datamatrix_y, cb_data=None):
"""
Generates an overlay ('watermark') PDF, which can then be overlaid onto
the exam PDF.
......@@ -221,6 +255,9 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
The x coordinate where the DataMatrix codes should be placed
datamatrix_y : int
The y coordinate where the DataMatrix codes should be placed
cb_data : list[ (int, int, int, str)]
The data needed for drawing a checkbox, namely: the x coordinate; y coordinate; page number and label
"""
# Font settings for the copy number (printed under the datamatrix)
......@@ -233,6 +270,17 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
# ID grid on first page only
generate_id_grid(canv, id_grid_x, id_grid_y)
# create index for list of checkbox data and sort the data on page
if cb_data:
index = 0
max_index = len(cb_data)
cb_data = sorted(cb_data, key=lambda tup: tup[2])
# invert the y axis
cb_data = [(cb[0], pagesize[1] - cb[1], cb[2], cb[3]) for cb in cb_data]
else:
index = 0
max_index = 0
for page_num in range(num_pages):
_add_corner_markers_and_bottom_bar(canv, pagesize)
......@@ -246,6 +294,13 @@ def _generate_overlay(canv, pagesize, exam_id, copy_num, num_pages, id_grid_x,
datamatrix_x, datamatrix_y_adjusted - fontsize,
f" # {copy_num}"
)
# 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)
index += 1
canv.showPage()
......@@ -335,3 +390,19 @@ def page_is_size(exam_pdf_file, shape, tolerance=0):
pass
return not invalid
def make_pages_even(output_filename, exam_pdf_file):
exam_pdf = PdfReader(exam_pdf_file)
new = PdfWriter()
new.addpages(exam_pdf.pages)
pagecount = len(exam_pdf.pages)
if (pagecount % 2 == 1):
blank = PageMerge()
box = exam_pdf.pages[0].MediaBox
blank.mbox = box
blank = blank.render()
new.addpage(blank)
new.write(output_filename)
import cv2
import numpy as np
from .database import db, Solution
from .images import guess_dpi, get_box, fix_corner_markers
def add_feedback_to_solution(sub, exam, page, page_img, corner_keypoints):
"""
Adds the multiple choice options that are identified as marked as a feedback option to a solution
Parameters
------
sub : Submission
the current submission
exam : Exam
the current exam
page_img : Image
image of the page
corner_keypoints : array
locations of the corner keypoints as (x, y) tuples
"""
problems_on_page = [problem for problem in exam.problems if problem.widget.page == page]
fixed_corner_keypoints = fix_corner_markers(corner_keypoints, page_img.shape)
x_min = min(point[0] for point in fixed_corner_keypoints)
y_min = min(point[1] for point in fixed_corner_keypoints)
top_left_point = (x_min, y_min)
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:
box = (mc_option.x, mc_option.y)
if box_is_filled(box, page_img, top_left_point):
feedback = mc_option.feedback
sol.feedback.append(feedback)
db.session.commit()
def box_is_filled(box, page_img, corner_keypoints, marker_margin=72/2.54, threshold=225, cut_padding=0.1, box_size=11):
"""
A function that finds the checkbox in a general area and then checks if it is filled in.
Params
------
box: (int, int)
The coordinates of the top left (x,y) of the checkbox in points.
page_img: np.array
A numpy array of the image scan
corner_keypoints: (float,float)
The x coordinate of the left markers and the y coordinate of the top markers,
used as point of reference since scans can deviate from the original.
(x,y) are both in pixels.
marker_margin: float
The margin between the corner markers and the edge of a page when generated.
threshold: int
the threshold needed for a checkbox to be considered marked range is between 0 (fully black)
and 255 (absolutely white).
cut_padding: float
The extra padding when retrieving an area where the checkbox is in inches.
box_size: int
the size of the checkbox in points.
Output
------
True if the box is marked, else False.
"""
# shouldn't be needed, but some images are drawn a bit weirdly
y_shift = 5
# create an array with y top, y bottom, x left and x right. use the marker margin to allign to the page.
coords = np.asarray([box[1] - marker_margin + y_shift, box[1] + box_size - marker_margin + y_shift,
box[0] - marker_margin, box[0] + box_size - marker_margin])/72
# add the actually margin from the scan to corner markers to the coords in inches
dpi = guess_dpi(page_img)
coords[0] = coords[0] + corner_keypoints[1]/dpi
coords[1] = coords[1] + corner_keypoints[1]/dpi
coords[2] = coords[2] + corner_keypoints[0]/dpi
coords[3] = coords[3] + corner_keypoints[0]/dpi
# 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, 150, 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
# if the rectangle is bigger (higher) than expected, cut the image up a bit
if h > 1.5 * box_size_px:
print("in h resize")
y_partition = 0.333
# try getting another bounding box on bottom 2/3 of the screen
coords2 = cv2.findNonZero(flood_im[y + int(y_partition * h): y + h, x: x+w])
x2, y2, w2, h2 = cv2.boundingRect(coords2)
# add these coords to create a new bounding box we are looking at
new_y = y+y2 + int(y_partition * h)
new_x = x + x2
res_rect = bin_im[new_y:new_y + h2, new_x:new_x + w2]
else:
new_x, new_y, w2, h2 = x, y, w, h
# 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
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]
# if the found box is smaller than a certain threshold
# it means that we only found a little bit of white and the box is filled
res_x, res_y, *_ = res_rect.shape
if res_x < 0.333 * box_size_px or res_y < 0.333 * box_size_px:
return True
return np.average(res_rect) < threshold
......@@ -17,7 +17,7 @@ from .database import db, Scan, Exam, Page, Student, Submission, Solution, ExamW
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
ExtractedBarcode = namedtuple('ExtractedBarcode', ['token', 'copy', 'page'])
......@@ -54,7 +54,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', "Unexpected error: " + str(error))
write_pdf_status(scan_id, 'error', f"Unexpected error: {error}")
def _process_pdf(scan_id, app_config):
......@@ -91,8 +91,8 @@ def _process_pdf(scan_id, app_config):
print(description)
failures.append(page)
except Exception as e:
report_error(f'Error processing page {page}: {e}')
return
report_error(f'Error processing page {e}')
raise
except Exception as e:
report_error(f"Failed to read pdf: {e}")
raise
......@@ -337,7 +337,13 @@ def process_page(image_data, exam_config, output_dir=None, strict=False):
else:
return True, "Testing, image not saved and database not updated."
update_database(image_path, barcode)
sub, exam = update_database(image_path, barcode)
try:
add_feedback_to_solution(sub, exam, barcode.page, image_array, corner_keypoints)
except RuntimeError as e:
if strict:
return False, str(e)
if barcode.page == 0:
description = guess_student(
......@@ -385,8 +391,12 @@ def update_database(image_path, barcode):
Returns
-------
signature_validated : bool
If the corresponding submission has a validated signature.
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:
......@@ -406,6 +416,8 @@ def update_database(image_path, barcode):
db.session.commit()
return sub, exam
def decode_barcode(image, exam_config):
"""Extract a barcode from a PIL Image."""
......