From ac3a5af2cffd56363906dd09f6fdb7863008ae4c Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Mon, 3 Jan 2022 23:13:38 -0800 Subject: [PATCH 1/4] Add diff tool to screenshots.py Adds a diff tool to the student and grader views for the HTML generated by screenshots.py --- scripts/screenshots.py | 382 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 356 insertions(+), 26 deletions(-) diff --git a/scripts/screenshots.py b/scripts/screenshots.py index 55dc55e..b3fc06d 100755 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -8,34 +8,364 @@ # Load test results. import os -import base64 +import base64, html from glob import glob from junitparser import JUnitXml -def getbase64(src): +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 -def img(sub_title, sub_src, sol_title, sol_src): + Returns: + str. CSS, not wrapped in style tags. + """ return """ -
-
-

{}

- {} -
- {} -
- """.format( - "40%" if sol_src else "80%", sub_title, getbase64(sub_src), sub_title, - """ -
-

{}

- {} -
- """.format(sol_title, getbase64(sol_src), sol_title) if sol_src else "") + /* Row with at least one image&title pair */ + .img_row { + border-bottom: 3px solid black; + margin-bottom: 5px; + + border-collapse: collapse; + border-spacing: 0; + } + + .img_row img, .img_row canvas { + /* Don't let any of the images be bigger than their container. */ + max-width: 100%; + } + + /* Row with both a student's submission and the expected submission */ + .sol_compare_row td, .sol_compare_row th { + width: 33.3%; + } + + @media (prefers-color-scheme: dark) { + :root { + background-color: #333; + color: white; + } + + .img_row { + border-bottom: 3px solid #ccc; + } + } + """ + +def get_diff_script() -> str: + return """ + "use strict"; + + // If deltaR² + deltaG² + deltaB² + deltaA² ≥ DIFF_TOLERANCE, count the pixel as + // different. + const DIFF_TOLERANCE = 64; + + // 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: + """Get an ID. Can be used to avoid collisions when giving HTML elements labels""" + global image_id_counter + image_id_counter += 1 + + return "id{}".format(image_id_counter) + + +def get_img_html(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: + 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: + class_list.append("sol_compare_row") + + result = "".format(" ".join(class_list)) + result_header_row = "" + result_content_row = "" + result_label_row = "" + + def write_img(src, title): + nonlocal result, result_header_row, result_content_row, result_label_row + + # Make the title html-safe. quote=True means that + # quote characters are also escaped + title_tagsafe = html.escape(title) + title_altsafe = html.escape(title) + + # Generate an ID for the image so that we can refer to it later. + img_id = get_id() + + # Append to the result we're accumulating + result_header_row += "".format(title_tagsafe) + result_content_row += "" \ + .format(img_id, getbase64(src), title_altsafe) + result_label_row += "" + + return img_id + + def write_diff(sub_id, sol_id): + nonlocal result, result_header_row, result_content_row, result_label_row + canvas_id = get_id() + description_id = get_id() + + result_header_row += "" + result_content_row += """ + + """.format(canvas_id) + result_label_row += ''' + + '''.format(description_id, canvas_id) + + result_label_row += """""" + + sub_id = write_img(sub_src, sub_title) + if sol_src: + sol_id = write_img(sol_src, sol_title) + write_diff(sub_id, sol_id) + + result_header_row += "" + result_content_row += "" + result_label_row += "" + result += result_header_row + result_content_row + result_label_row + result += "
{}{}Diff + Unable to view diff: Browser does not support the HTML5 Canvas +
" + return result tests = [] for file in glob("output/tests/*.xml"): @@ -47,8 +377,8 @@ print(tests) written = set() with open("grader_output.html", "a+") as grader, open("student_output.html", "a+") as student: - grader.write("") - student.write("") + 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. @@ -60,8 +390,8 @@ with open("grader_output.html", "a+") as grader, open("student_output.html", "a+ solution = submissions[submission] if not os.path.exists(solution): solution = None - grader.write(img(test, submission, test + " (solution)", solution)) - student.write(img(test, submission, test + " (solution)", solution)) + 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: @@ -69,8 +399,8 @@ with open("grader_output.html", "a+") as grader, open("student_output.html", "a+ solution = screenshot[:-5] + "_solution.jpeg" if not os.path.exists(solution): solution = None - grader.write(img(test, screenshot, test + " (solution)", solution)) - student.write(img(test, screenshot, test + " (solution)", solution)) - grader.write("") - student.write("") + grader.write(get_img_html(test, screenshot, test + " (solution)", solution)) + student.write(get_img_html(test, screenshot, test + " (solution)", solution)) + grader.write("") + student.write("") -- GitLab From b27d23bb777032ab7a201dede0deb29fb9f4471d Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Tue, 4 Jan 2022 11:06:04 -0800 Subject: [PATCH 2/4] Allow specifying diff tolerance in page URL --- scripts/screenshots.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/screenshots.py b/scripts/screenshots.py index b3fc06d..fe384f8 100755 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -64,9 +64,37 @@ 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 = 64; + const DIFF_TOLERANCE = parseInt(getPageArg('tolerance')) || 128; // Non-differing pixels are at most this intense. const DIFF_BACKGROUND_MAX_INTENSITY = 70; -- GitLab From 3e7217a81e72cae193effe2618cf9446cc222708 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Tue, 4 Jan 2022 11:19:57 -0800 Subject: [PATCH 3/4] Set default tolerance to 256 --- scripts/screenshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/screenshots.py b/scripts/screenshots.py index fe384f8..6ae3373 100755 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -94,7 +94,7 @@ def get_diff_script() -> str: // If deltaR² + deltaG² + deltaB² + deltaA² ≥ DIFF_TOLERANCE, count the pixel as // different. - const DIFF_TOLERANCE = parseInt(getPageArg('tolerance')) || 128; + const DIFF_TOLERANCE = parseInt(getPageArg('tolerance')) || 256; // Non-differing pixels are at most this intense. const DIFF_BACKGROUND_MAX_INTENSITY = 70; -- GitLab From 0f5126a51e661917b7b4cee86e21555cde69523b Mon Sep 17 00:00:00 2001 From: Jeremy Zhang Date: Tue, 4 Jan 2022 14:19:29 -0800 Subject: [PATCH 4/4] Update typo --- scripts/screenshots.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/screenshots.py b/scripts/screenshots.py index 6ae3373..88751a2 100755 --- a/scripts/screenshots.py +++ b/scripts/screenshots.py @@ -1,4 +1,4 @@ -11111#!/usr/bin/env python3 +#!/usr/bin/env python3 # # Loads JUnit test result XMLs and outputs grading commands according to # the assignment's critera.xml. @@ -375,8 +375,8 @@ def get_img_html(sub_title, sub_src, sol_title, sol_src) -> str: // Copy the solution and submission images -- we want both images' original sizes. const submissionCopy = new Image(); const solutionCopy = new Image(); - submissionCopy.src = """ + sub_id + """.src; - solutionCopy.src = """ + sol_id + """.src; + submissionCopy.src = document.getElementById('""" + sub_id + """').src; + solutionCopy.src = document.getElementById('""" + sol_id + """').src; generateDiff(submissionCopy, solutionCopy, """ + canvas_id + """, """ + description_id + """); })(); -- GitLab