From 09a49c62c00ce0a593828790d4747485007827c8 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 17 Jan 2022 19:34:35 -0800 Subject: [PATCH 1/2] screenshots.py: Create single-file output --- check.sh | 2 +- mark.sh | 4 +- scripts/screenshots.py | 340 +++------------------- scripts/util/.gitignore | 1 + scripts/util/html_page_writer.py | 144 +++++++++ scripts/util/image_diff.js | 220 ++++++++++++++ scripts/util/image_reader.js | 120 ++++++++ scripts/util/test_resources/layout_1.jpeg | Bin 0 -> 239673 bytes scripts/util/util.js | 59 ++++ 9 files changed, 591 insertions(+), 299 deletions(-) create mode 100644 scripts/util/.gitignore create mode 100644 scripts/util/html_page_writer.py create mode 100644 scripts/util/image_diff.js create mode 100644 scripts/util/image_reader.js create mode 100644 scripts/util/test_resources/layout_1.jpeg create mode 100644 scripts/util/util.js diff --git a/check.sh b/check.sh index 58e5148..18981b7 100755 --- a/check.sh +++ b/check.sh @@ -27,4 +27,4 @@ mv output/Pictures output/screenshots > /dev/null # Compile screenshots into HTML file to display python3 ~/scripts/screenshots.py > /dev/null -cp student_output.html /home/student_output.html +cp screenshots.html /home/student_output.html diff --git a/mark.sh b/mark.sh index 1c7bc18..8e3d710 100755 --- a/mark.sh +++ b/mark.sh @@ -34,7 +34,7 @@ mv output/Pictures output/screenshots >> /home/output.log # Compile screenshots into HTML file to display python3 ~/scripts/screenshots.py >> /home/output.log cp /home/html-output-index.html output/start-here.html >> /home/output.log -zip -FS -r /tmp/output_html.zip output/*/html/ output/start-here.html >> /home/output.log -zip /tmp/student_output.zip student_output.html >> /home/output.log +#zip -FS -r /tmp/output_html.zip output/*/html/ output/start-here.html >> /home/output.log +#zip /tmp/student_output.zip student_output.html >> /home/output.log echo "$(cat output.json)" diff --git a/scripts/screenshots.py b/scripts/screenshots.py index 1ef8cbe..1ac4003 100755 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -9,18 +9,10 @@ # Load test results. import os import base64, html -from glob import glob - +import util.html_page_writer as page_writer from junitparser import JUnitXml +from glob import glob -def getbase64(src) -> str: - """Convert data in a file to base64-encoded data. - - Returns: - str. base64-encoded file data. Not prepended with data:image/... - """ - with open(src, "rb") as img_file: - return base64.b64encode(img_file.read()).decode() def get_css() -> str: """Get CSS for the page's output @@ -60,250 +52,6 @@ def get_css() -> str: } """ -def get_diff_script() -> str: - return """ - "use strict"; - - /// Get an argument given through the page's URL bar. - /// E.g. if location.href = "https://example.com/a?thing=2,key=3 - /// and [key]=thing, then this returns 2. - /// Values must be numbers or strings of characters in [a-zA-Z0-9]. - function getPageArg(key) { - let argSepPos = location.href.indexOf('?'); - if (argSepPos == -1) return null; - - // Get everything after the '?' in the URL - let argSep = location.href.substring(argSepPos + 1); - let args = argSep.split(','); - - // For each key=val - for (const arg of args) { - let parts = arg.split('='); - if (parts.length != 2) { - continue; - } - - if (parts[0] == key) { - return parts[1]; - } - } - - // No argument found - return null; - } - - // If deltaR² + deltaG² + deltaB² + deltaA² ≥ DIFF_TOLERANCE, count the pixel as - // different. - const DIFF_TOLERANCE = parseInt(getPageArg('tolerance')) || 256; - - // Non-differing pixels are at most this intense. - const DIFF_BACKGROUND_MAX_INTENSITY = 70; - - // Differing pixels are at least this intense. - const DIFF_FOREGROUND_MIN_INTENSITY = 140; - - /// Display the diff between [submittedImg] and [solutionImg] in [canvasElem]. Describe it - /// by setting the contents of [descriptionElem]. - async function generateDiff(submittedImg, solutionImg, canvasElem, descriptionElem) { - /// Returns a promise that resolves when [img] has loaded. - const waitForLoad = (img) => { - return new Promise((resolve, reject) => { - if (img.complete) resolve(); - - let onload, onerror, cleanup; - onload = () => { - cleanup(); - resolve(img); - }; - - onerror = (e) => { - cleanup(); - reject(e); - }; - - cleanup = () => { - img.removeEventListener('load', onload); - img.removeEventListener('error', onerror); - }; - - img.addEventListener('load', onload); - img.addEventListener('error', onerror); - }); - }; - - /// Log information about the diff (user-visible). - const addDiffInfo = (message) => { - const messageElem = document.createElement('div'); - messageElem.appendChild(document.createTextNode(message)); - messageElem.classList.add('diff_info'); - descriptionElem.appendChild(messageElem); - }; - - const compareImageData = (outputData, givenData, trueData) => { - const givenWidth = givenData.width; - const desiredWidth = trueData.width; - const givenHeight = givenData.height; - const desiredHeight = trueData.height; - const outputWidth = outputData.width; - let diffCount = 0; - if (givenData.width != trueData.width) { - addDiffInfo("⚠ Ignoring extra pixels in the count of differing pixels! ⚠"); - } - - outputData = outputData.data; - givenData = givenData.data; - trueData = trueData.data; - - const getPixel = (x, y, data, width) => { - // Each pixel has four components: R, G, B, A - let startIdx = x * 4 + y * width * 4; - return [ data[startIdx], data[startIdx + 1], data[startIdx + 2], data[startIdx + 3] ]; - }; - - const writePixel = (x, y, r, g, b, a) => { - const outputIdx = x * 4 + y * outputWidth * 4; - outputData[outputIdx] = r; - outputData[outputIdx + 1] = g; - outputData[outputIdx + 2] = b; - outputData[outputIdx + 3] = a; - }; - - for (let x = 0; x < Math.min(givenWidth, desiredWidth); x++) { - for (let y = 0; y < Math.min(givenHeight, desiredHeight); y++) { - const given = getPixel(x, y, givenData, givenWidth); - const desired = getPixel(x, y, trueData, desiredWidth); - const diff = [ - given[0] - desired[0], - given[1] - desired[1], - given[2] - desired[2], - given[3] - desired[3], - ]; - - // https://en.wikipedia.org/wiki/Color_difference - const diffSquared = diff[0] * diff[0] + diff[1] * diff[1] + diff[2] * diff[2] + diff[3] * diff[3]; - if (diffSquared > DIFF_TOLERANCE) { - diffCount ++; - - /// Get the resultant value for the [idx]th component - /// of the output color. - const getPxValue = (idx) => { - // √(x) is monotonically increasing and √(0) = 0, - // and √(x) makes small increases near 0 have a - // bigger effect on its output than small increases - // far from zero. - let val = Math.sqrt(Math.abs(diff[idx])) / Math.sqrt(255); - - return val * (255 - DIFF_FOREGROUND_MIN_INTENSITY) + DIFF_FOREGROUND_MIN_INTENSITY; - }; - - const r = getPxValue(0); - const g = getPxValue(1); - const b = getPxValue(2); - - writePixel(x, y, r, g, b, 255); - } - else { - const r = given[0] / 255 * DIFF_BACKGROUND_MAX_INTENSITY; - const g = given[1] / 255 * DIFF_BACKGROUND_MAX_INTENSITY; - const b = given[2] / 255 * DIFF_BACKGROUND_MAX_INTENSITY; - writePixel(x, y, r, g, b, 255); - } - } - } - - return diffCount; - }; - - const loadingElem = document.createElement('div'); - loadingElem.innerHTML = "Generating the diff... ⏳"; - descriptionElem.replaceChildren(loadingElem); - - try { - const backgroundCanvas = document.createElement('canvas'); - let canvasDescription = ""; - - // Make sure both images have loaded before generating the diff... - await waitForLoad(submittedImg); - await waitForLoad(solutionImg); - - // Make sure the output can fit both images. - const canvasWidth = Math.max(submittedImg.width, solutionImg.width); - const canvasHeight = Math.max(submittedImg.height, solutionImg.height); - canvasElem.width = canvasWidth; - canvasElem.height = canvasHeight; - backgroundCanvas.width = canvasWidth; - backgroundCanvas.height = canvasHeight; - - const sizesDiffer = submittedImg.width != solutionImg.width || submittedImg.height != solutionImg.height; - - // Display a warning if the image sizes differ. - if (sizesDiffer) { - addDiffInfo(`⚠ The images are not the same size. ` + - `The submitted image is ${submittedImg.width}x${submittedImg.height}, ` + - `while the solution image is ${solutionImg.width}x${solutionImg.height}. ⚠`); - canvasDescription += `Submission and solution have different sizes. ` + - `Submission is ${submittedImg.width} by ${submittedImg.height} ` + - `while the solution is ${solutionImg.width} by ${solutionImg.height}.`; - } - - const ctx = canvasElem.getContext('2d'); - const backgroundCtx = backgroundCanvas.getContext('2d'); - - ctx.drawImage(submittedImg, 0, 0); - backgroundCtx.drawImage(solutionImg, 0, 0); - - let submittedData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); - let solutionData = backgroundCtx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); - - let differingPixels = compareImageData(submittedData, submittedData, solutionData); - - // Display the diff. - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - ctx.putImageData(submittedData, 0, 0); - - addDiffInfo(`Diff: In the two images, ${differingPixels} pixels differ.`); - canvasDescription += `${differingPixels} pixels differ between the two images. `; - - // Give the percent difference. - if (!sizesDiffer && differingPixels > 0) { - const totalPixels = canvasWidth * canvasHeight; - const percentDiff = differingPixels / totalPixels * 100; - - // Round to the nearest 10th - const roundedDiff = Math.floor(percentDiff * 10 + 0.5) / 10; - - const differMessage = `This is approximately ${roundedDiff}%.`; - - addDiffInfo(differMessage); - canvasDescription += differMessage; - } - - canvasElem.setAttribute('title', "Image Diff: " + canvasDescription); - } catch(e) { - addDiffInfo(`Failed to generate the diff: ${e}. Consider using an online comparison tool.`); - } - - loadingElem.remove(); - } - """ - -def get_html_setup(page_title) -> str: - """Get HTML that sets up the document (everything from to , inclusive) - """ - - return """ - - - - - - {} - - - - - """.format(html.escape(page_title), get_css(), get_diff_script()).strip() - # Used to prevent ID collisions image_id_counter = 0 def get_id() -> str: @@ -313,19 +61,16 @@ def get_id() -> str: return "id{}".format(image_id_counter) - -def get_img_html(sub_title, sub_src, sol_title, sol_src) -> str: +def write_image(writer, sub_title, sub_src, sol_title, sol_src) -> str: """Get base64-formatted html that displays students' submitted images and the corresponding solution images (if given). Assumes given images are PNG images. Args: + writer (PageWriter): Output document writer sub_title (str): Title for the submission sub_src (str): Path to the submission file sol_title (str): Descriptive title for the solution sol_src (str): Path to the solution file - - Returns: - str. HTML representation of the submission/solution """ class_list = [ "img_row" ] if sol_src: @@ -349,9 +94,13 @@ def get_img_html(sub_title, sub_src, sol_title, sol_src) -> str: # Append to the result we're accumulating result_header_row += "{}".format(title_tagsafe) - result_content_row += "{}" \ - .format(img_id, getbase64(src), title_altsafe) + result_content_row += "{}" \ + .format(img_id, title_altsafe) result_label_row += "" + result_label_row += "".format(img_id, img_id) + + with open(src, 'rb') as img: + writer.attach_image(img_id, img.read()) return img_id @@ -371,14 +120,11 @@ def get_img_html(sub_title, sub_src, sol_title, sol_src) -> str: '''.format(description_id, canvas_id) result_label_row += """""" @@ -393,7 +139,9 @@ def get_img_html(sub_title, sub_src, sol_title, sol_src) -> str: result += result_header_row + result_content_row + result_label_row result += "" - return result + writer.add_to_body(result) + + tests = [] for file in glob("output/tests/*.xml"): @@ -401,34 +149,34 @@ for file in glob("output/tests/*.xml"): # Build global pass/fail test name lists. tests += ["{}_{}".format(test.classname.split(".")[-1], test.name) for test in results] -print(tests) + +writer = page_writer.PageWriter("Screenshots") +writer.add_to_header("") +writer.add_to_header("") written = set() -with open("grader_output.html", "a+") as grader, open("student_output.html", "a+") as student: - grader.write(get_html_setup("Screenshots: Grader View")) - student.write(get_html_setup("Screenshots: Student View")) - for test in tests: - # We only care about images whose names match exactly (not scaled). - # Displaying all of the scaled tests would be overwhelming. - submissions = {sub: sub[:-5] + "_solution.jpeg" for sub in glob("output/screenshots/{}.jpeg".format(test))} - submissions.update({sub: sub[:-7] + "_solution" + sub[-7:-5] + ".jpeg" for sub in glob("output/screenshots/{}_[0-9].jpeg".format(test))}) - for submission in sorted(submissions): - written.add(submission) - if os.path.exists(submission): - solution = submissions[submission] - if not os.path.exists(solution): - solution = None - grader.write(get_img_html(test, submission, test + " (solution)", solution)) - student.write(get_img_html(test, submission, test + " (solution)", solution)) - # This is super hacky. Always include any image starting with PixelTest. - for screenshot in glob("output/screenshots/PixelTest*.jpeg"): - if screenshot not in written: - test = os.path.basename(screenshot[:-5]) - solution = screenshot[:-5] + "_solution.jpeg" +for test in tests: + # We only care about images whose names match exactly (not scaled). + # Displaying all of the scaled tests would be overwhelming. + submissions = {sub: sub[:-5] + "_solution.jpeg" for sub in glob("output/screenshots/{}.jpeg".format(test))} + submissions.update({sub: sub[:-7] + "_solution" + sub[-7:-5] + ".jpeg" for sub in glob("output/screenshots/{}_[0-9].jpeg".format(test))}) + for submission in sorted(submissions): + written.add(submission) + if os.path.exists(submission): + solution = submissions[submission] if not os.path.exists(solution): solution = None - grader.write(get_img_html(test, screenshot, test + " (solution)", solution)) - student.write(get_img_html(test, screenshot, test + " (solution)", solution)) - grader.write("") - student.write("") + write_image(writer, test, submission, test + " (solution)", solution) + +# This is super hacky. Always include any image starting with PixelTest. +for screenshot in glob("output/screenshots/PixelTest*.jpeg"): + if screenshot not in written: + test = os.path.basename(screenshot[:-5]) + solution = screenshot[:-5] + "_solution.jpeg" + if not os.path.exists(solution): + solution = None + write_image(writer, test, screenshot, test + " (solution)", solution) + +with open("screenshots.html", "wb") as outfile: + writer.save(outfile) diff --git a/scripts/util/.gitignore b/scripts/util/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/scripts/util/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/scripts/util/html_page_writer.py b/scripts/util/html_page_writer.py new file mode 100644 index 0000000..f8e00e7 --- /dev/null +++ b/scripts/util/html_page_writer.py @@ -0,0 +1,144 @@ + +import base64, tempfile, os +from html import escape as escape_html + +def load_resource(name, mode='r'): + """Load a resource with a path relative to this script's location + + Args: + name (str): Path to the resource relative to this + mode (str): Mode for reading the resource + + Returns: + str or bytes. Result of reading the resource + """ + # See https://stackoverflow.com/questions/4934806/how-can-i-find-scripts-directory + script_directory = os.path.dirname(os.path.realpath(__file__)) + result = "" + with open(os.path.join(script_directory, name), mode) as f: + result = f.read() + return result + +JS_LOAD_IMG_LIBRARY = load_resource("image_reader.js") + +class PageWriter: + """Create a single-file page with attached images""" + IMAGE_LIBRARY_LEN = 1024 + + def __init__(self, page_title: str): + self.header_ = [] + self.body_ = [] + self.image_keys_ = [] + self.images_ = {} + self.page_title_ = page_title + + def add_to_header(self, html: str): + """Add the given HTML to the page's header""" + self.header_.append(html) + + def add_to_body(self, text: str): + """Append the given text to the output page's body""" + self.body_.append(text) + + def attach_image(self, key: str, data: bytearray): + if key in self.images_: + raise Exception("Image already defined") + self.images_[key] = data + self.image_keys_.append(key) + + def add_image(self, image_id: str, image_description: str, path_to_image: str): + image_id = escape_html(image_id) + image_description = escape_html(image_description) + + with open(path_to_image, 'rb') as img: + self.attach_image(image_name, img.read()) + self.add_to_body(""" + {} + + """).format(image_description, image_id, image_id, image_id) + + def get_image_offsets_(self, html_len): + result = {} + acc = html_len + + for key in self.image_keys_: + img = self.images_[key] + result[key] = acc + acc += len(img) + + return result + + def save(self, file): + """Save HTML/attached images to the given file""" + + def get_image_def_placeholder(): + return '0' * self.IMAGE_LIBRARY_LEN + + def get_output_html(image_offset_defs=get_image_def_placeholder()): + header_html = "".join(self.header_) + body_html = "".join(self.body_) + + html = """ + + + + + {} + + + + + + + {} + + + {} + + + +