diff --git a/scripts/screenshots.py b/scripts/screenshots.py index 55dc55e56d6a7294267d18bb7124599016b7f136..88751a2a1018c37c315d43cd61148cb159e35ba2 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. @@ -8,34 +8,392 @@ # 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 + + Returns: + str. CSS, not wrapped in style tags. + """ + return """ + /* 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"; + + /// 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) + """ -def img(sub_title, sub_src, sol_title, sol_src): return """ -{} | ".format(title_tagsafe) + result_content_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 += " | Diff | " + 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 += " |
---|