package com.github.kilianB.benchmark; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collections; import java.util.DoubleSummaryStatistics; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.logging.Logger; import com.github.kilianB.ArrayUtil; import com.github.kilianB.MathUtil; import com.github.kilianB.hash.Hash; import com.github.kilianB.hashAlgorithms.HashingAlgorithm; import com.github.kilianB.matcher.TypedImageMatcher.AlgoSettings; import com.github.kilianB.matcher.categorize.supervised.LabeledImage; import com.github.kilianB.matcher.exotic.SingleImageMatcher; import javafx.application.Application; import javafx.application.Platform; import javafx.geometry.Rectangle2D; import javafx.scene.Scene; import javafx.scene.image.Image; import javafx.scene.web.WebView; import javafx.stage.Screen; import javafx.stage.Stage; /** * The algorithm benchmark is a utility class allowing to evaluate the * performance of multiple hashing algorithms for a set of user supplied test * images. * * <p> * The report is generated as HTML and can either be saved as a file to later be * viewed in a browser of your choice, or be viewed in a javafx window. * Additionally the generated HTML can be displayed in the console. * * <p> * <b>Charting</b> * <p> * The benchmark allows a small chart overview visualizing the results. Due to * javafx not fully supporting this feature charting is disabled for the * {@link #display()} method. * <p> * <a href="https://www.chartjs.org/">ChartJS</a> is used to display the chart. * While a copy is distributed via the resource folder the class may have issues * locating the requested file due to the way future jars are packaged. As a * fallback the js may be loaded from a cdn provider. * * <p> * For full functionality it is advice to only use the normalized hamming * distance * * * @author Kilian * @since 2.0.0 */ public class AlgorithmBenchmarker { private static final Logger LOGGER = Logger.getLogger(AlgorithmBenchmarker.class.getName()); /** HTML base template */ private static String htmlBase = buildHtmlBase(); /** Format used to cut off decimal numbers */ private DecimalFormat df = new DecimalFormat("0.000"); /** Shall a speed benchmark be added to the report **/ private boolean timming; // Charting /** The number of buckets to sort the distance into for charting */ private int buckets; /** Stepped or linear interpolated chart? */ private boolean stepped; // Internal state /** The image matcher holding individual hashing algorithms and settings */ private SingleImageMatcher imageMatcher; /** The images to test */ private List<LabeledImage> imagesToTest = new ArrayList<>(); /** Algorithms extracted from the image matcher */ private Map<HashingAlgorithm, AlgoSettings> algorithmsToTest; /** * Construct an algorithm benchmarker with default settings for charting. * * @param imageMatcher the image matcher object holding all hashing algorithms * which will be tested * @param speedBenchmark if true a speed benchmark will be added to the table. * The speed benchmark tries to compute the time a single * hashing operation (without reading the image from disk) * takes. Be aware that micro benchmarks are hard to * design and if a true representation is required more * suited libraries like Oracle's JMH should be used * instead. * <p> * This benchmark either does 600 passes over every * supplied images per algorithm or allows for a total * computation time of 90 seconds. Whichever takes less. */ public AlgorithmBenchmarker(SingleImageMatcher imageMatcher, boolean speedBenchmark) { this.timming = speedBenchmark; this.imageMatcher = imageMatcher; this.buckets = 10; this.stepped = true; } /** * Construct an algorithm benchmarker * * @param imageMatcher the image matcher object holding all hashing algorithms * which will be tested * @param speedBenchmark if true a speed benchmark will be added to the table. * The speed benchmark tries to compute the time a single * hashing operation (without reading the image from disk) * takes. Be aware that micro benchmarks are hard to * design and if a true representation is required more * suited libraries like Oracle's JMH should be used * instead. * <p> * This benchmark either does 600 passes over every * supplied images per algorithm or allows for a total * computation time of 90 seconds. Whichever takes less. * @param bucket the number of histogram buckets the hash distances will * be sorted into. Default: 10. A higher number gives a * more detailed view on the individual distances. * @param stepped Weather to use stepped chart to visualize the buckets * or to use an interpolated bezier curve. Curves may look * more esthetic but are not as accurate as the stepped * version. */ public AlgorithmBenchmarker(SingleImageMatcher imageMatcher, boolean speedBenchmark, int bucket, boolean stepped) { this.timming = speedBenchmark; this.imageMatcher = imageMatcher; this.buckets = bucket; this.stepped = stepped; } /** * Add labeled test images. * * <p> * Images which are expected to be matched should carry the same group id. * * @param testImages the images which will be tested */ public void addTestImages(LabeledImage... testImages) { for (LabeledImage t : testImages) { imagesToTest.add(t); } } /** * Compute the benchmark and output it's content as HTML to the console. Every * call of this method recomputes the benchmark. * * <p> * Charting is enabled for this output mode. */ public void toConsole() { System.out.println(constructHTML(true)); } /** * Compute the benchmark and save the content to an HTML file located at the * base of this project with the name Benchmark_TimeInMS.html Every call of this * method recomputes the benchmark and creates a new file. * * <p> * Charting is enabled for this output mode. * */ public void toFile() { toFile(new File("Benchmark_" + System.currentTimeMillis() + ".html")); } /** * /** Compute the benchmark and save it to file. The supplied file will be * overwritten and contain the benchmark in html format. Every call of this * method recomputes the benchmark. * * <p> * Charting is enabled for this output mode. * * @param outputFile The file the content will be saved to */ public void toFile(File outputFile) { String output = constructHTML(true); try (FileWriter fw = new FileWriter(outputFile)) { fw.write(output); LOGGER.info("HTML File Created: " + outputFile.getAbsolutePath()); } catch (IOException e) { LOGGER.severe("Can't create benchmark file: " + e.getCause()); // e.printStackTrace(); // output to console if we can't print to file System.out.println(output); } } /** * Compute the benchmark and display the content in a JavaFX window. Every call * of this method recomputes the benchmark and creates a new file. * * <p> * Due to JavaFXs webview not being able to handle javascript properly no charts * will be displayed in the report */ public void display() { String html = constructHTML(false); new Thread(() -> { // As long as https://bugs.openjdk.java.net/browse/JDK-8090933 isn't fixed we // have to use this construct try { Application.launch(BenchmarkApplication.class, html); } catch (IllegalStateException state) { // JavaFX already running new BenchmarkApplication().spawnWindow(html); } }, "Display Algorithm Benchmark").start(); } /** * Construct the of this benchmark * * @param initChart if charting should be attempted * @return the benchmark report as html document */ protected String constructHTML(boolean initChart) { // Setup // Sort test images to group by categories Collections.sort(imagesToTest); /* * Statistics for each hashing algorithm * @formatter:off * 0 Supposed matches distances * 1 Supposed distinct distance * 2 True positive * 3 False Positive * 4 False Negative * 5 True Negative * @formatter:on */ Map<HashingAlgorithm, DoubleSummaryStatistics[]> statMap = new HashMap<>(); algorithmsToTest = imageMatcher.getAlgorithms(); /* Keep track of every produced distance if we want to chart it */ Map<HashingAlgorithm, List<Double>[]> scatterMap = new HashMap<>(); // Init Map for (HashingAlgorithm h : algorithmsToTest.keySet()) { DoubleSummaryStatistics[] stats = new DoubleSummaryStatistics[6]; ArrayUtil.fillArrayMulti(stats, () -> { return new DoubleSummaryStatistics(); }); statMap.put(h, stats); if (initChart) { @SuppressWarnings("unchecked") List<Double>[] l = new List[2]; l[0] = new ArrayList<>(); l[1] = new ArrayList<>(); scatterMap.put(h, l); } } // 0. Compute all hashes for each image and hashing algorithm Map<HashingAlgorithm, Map<LabeledImage, Hash>> hashes = new HashMap<>(); for (HashingAlgorithm h : algorithmsToTest.keySet()) { HashMap<LabeledImage, Hash> algorithmSpecificHash = new HashMap<>(); hashes.put(h, algorithmSpecificHash); for (LabeledImage t : imagesToTest) { algorithmSpecificHash.put(t, h.hash(t.getbImage())); } } // Header StringBuilderI htmlBuilder = new StringBuilderI(); htmlBuilder.row("<div>"); // Table header appendHeader(htmlBuilder); // Algorithm bit resolution and algo settings appendAlgorithmInformation(htmlBuilder); // Append distance section of each image / algorithm appendHashingDistances(htmlBuilder, hashes, statMap, scatterMap, initChart); // Append statistics section appendStatistics(htmlBuilder, statMap); // Run benchmark and append it to the table if (timming) { appendTimmingBenchmark(htmlBuilder); } // Finalize htmlBuilder.append("</tbody></table>"); // Additional information? if (initChart) { appendChartSection(htmlBuilder, scatterMap, statMap); } // Construct final html return htmlBase.replace("$body", htmlBuilder.append("</div>").toString()); } /** * Construct the header of the table and append it to the html builder * * @param htmlBuilder the builder used to construct the output */ protected void appendHeader(StringBuilderI htmlBuilder) { htmlBuilder .append("<table id='rootTable'>\n").append("<thead><tr> <th>Images</th> <th>Category</th> <th colspan=" + algorithmsToTest.size() + ">Distance</th> </tr></thead>\n") .append("<tbody><tr><td colspan = 2></td>"); // KeySet call is cached in hashmap for (HashingAlgorithm h : algorithmsToTest.keySet()) { htmlBuilder.append("<td id='" + h.algorithmId() + "'>" + h.toString() + "</td>"); } htmlBuilder.append("</tr>\n"); } /** * Append meta information to the table and append it to the html builder * * @param htmlBuilder the builder used to construct the output */ protected void appendAlgorithmInformation(StringBuilderI htmlBuilder) { htmlBuilder.append("<tr><td colspan = 2><b>Actual Resolution:</b></td>"); for (HashingAlgorithm h : algorithmsToTest.keySet()) { htmlBuilder.append("<td>").append(h.getKeyResolution()).append("bits</td>"); } htmlBuilder.append("</tr>\n"); htmlBuilder.append("<tr><td colspan = 2><b>Threshold:</b></td>"); for (HashingAlgorithm h : algorithmsToTest.keySet()) { htmlBuilder.append("<td>").append(df.format(algorithmsToTest.get(h).getThreshold())).append("</td>"); } htmlBuilder.append("</tr>\n"); emptyTableRow(htmlBuilder); } /** * Append the hashing distances between the individual images for each algorithm * * @param htmlBuilder the builder used to construct the output * @param statMap map to store the distances in * @param hashes the precomputed hashes * @param initChart should the chart be appeneded. (e.g. javafx does is not * able to display the chart) * @param scatterMap map with all distance data points sorted by algorithm */ protected void appendHashingDistances(StringBuilderI htmlBuilder, Map<HashingAlgorithm, Map<LabeledImage, Hash>> hashes, Map<HashingAlgorithm, DoubleSummaryStatistics[]> statMap, Map<HashingAlgorithm, List<Double>[]> scatterMap, boolean initChart) { int lastCategory = 0; List<LabeledImage> sortedKeys = new ArrayList<>(imagesToTest); for (LabeledImage base : imagesToTest) { // Compute the distance for all crosses we have not looked at. // It's symmetric no need to check it twice sortedKeys.remove(base); if (base.getCategory() != lastCategory) { lastCategory = base.getCategory(); emptyTableRow(htmlBuilder); } for (LabeledImage cross : sortedKeys) { htmlBuilder.append("<tr>"); boolean first = true; for (Entry<HashingAlgorithm, AlgoSettings> entry : algorithmsToTest.entrySet()) { HashingAlgorithm h = entry.getKey(); AlgoSettings algoSettings = entry.getValue(); boolean supposedToMatch = base.getCategory() == cross.getCategory(); Hash baseHash = hashes.get(h).get(base); Hash crossHash = hashes.get(h).get(cross); double distance; if (algoSettings.isNormalized()) { distance = baseHash.normalizedHammingDistance(crossHash); } else { distance = baseHash.hammingDistance(crossHash); } // TODO normal distance for distancemap if (initChart) { scatterMap.get(h)[supposedToMatch ? 0 : 1].add(distance); } boolean consideredMatch = algoSettings.getThreshold() >= distance; String backgroundColor = ""; if (consideredMatch) { if (supposedToMatch) { statMap.get(h)[2].accept(1); } else { statMap.get(h)[3].accept(1); backgroundColor = "fault"; } } else { if (supposedToMatch) { statMap.get(h)[4].accept(1); backgroundColor = "fault"; } else { statMap.get(h)[5].accept(1); } } if (first) { htmlBuilder.append("<td>").append(base.getName()).append("-").append(cross.getName()) .append("</td><td class='category'>").append("[").append(base.getCategory()).append("-") .append(cross.getCategory()).append("]</td>"); first = false; } htmlBuilder.append("<td class='").append(backgroundColor).append("'>"); if (algoSettings.isNormalized()) { htmlBuilder.append(df.format(distance)); } else { htmlBuilder.append((int) distance); } htmlBuilder.append("</td>"); statMap.get(h)[supposedToMatch ? 0 : 1].accept(distance); } htmlBuilder.append("</tr>\n"); } } emptyTableRow(htmlBuilder); } /** * Append statistics regarding the hashing algorithms to the table * * @param htmlBuilder The stringbuilder to append the output to * @param statMap map holding statistics */ protected void appendStatistics(StringBuilderI htmlBuilder, Map<HashingAlgorithm, DoubleSummaryStatistics[]> statMap) { int numberOfPairs = MathUtil.triangularNumber(imagesToTest.size() - 1); String[] columnNames = { "Avg match:", "Avg distinct:", "Min/Max match:", "Min/Max distinct:", "True Positive / False Positive", "", "False Negative / True Negative", "Accuracy", "Precision" }; for (int i = 0; i < columnNames.length; i++) { htmlBuilder.append("<tr>"); htmlBuilder.append("<td colspan = 2><b>").append(columnNames[i]).append("</b></td>"); for (HashingAlgorithm h : algorithmsToTest.keySet()) { if (i < 2) { htmlBuilder.append("<td>").append(df.format(statMap.get(h)[i].getAverage())).append("</td>"); } else if (i < 4) { DoubleSummaryStatistics dSum0 = statMap.get(h)[i - 2]; if (algorithmsToTest.get(h).isNormalized()) { htmlBuilder.append("<td>").append(df.format(dSum0.getMin())).append("/") .append(df.format(dSum0.getMax())).append("</td>"); } else { htmlBuilder.append("<td>").append((int) dSum0.getMin()).append("/").append((int) dSum0.getMax()) .append("</td>"); } } else if (i < 7) { DoubleSummaryStatistics dSum0 = statMap.get(h)[i - 2]; DoubleSummaryStatistics dSum1 = statMap.get(h)[i - 1]; htmlBuilder.append("<td>").append(dSum0.getCount()).append("/").append(dSum1.getCount()) .append("</td>"); } else if (i == 7) { // Accuracy = (TP + TN) / (TP + TN + FP + FN) => TP + TN / (testData.size()) int truePositive = (int) statMap.get(h)[2].getCount(); int trueNegative = (int) statMap.get(h)[5].getCount(); htmlBuilder.append("<td>").append(df.format((truePositive + trueNegative) / (double) numberOfPairs)) .append("</td>"); } else if (i == 8) { // Precision int truePositive = (int) statMap.get(h)[2].getCount(); int falsePositive = (int) statMap.get(h)[3].getCount(); htmlBuilder.append("<td>"); if (truePositive > 0 || falsePositive > 0) { htmlBuilder.append(df.format(truePositive / (double) (truePositive + falsePositive))); } else { htmlBuilder.append("-"); } htmlBuilder.append("</td>"); } } if (i == 4) { i++; } htmlBuilder.append("</tr>\n"); } } /** * Append an empty table row to the table. Used as spacer. * @param builder The stringbuilder to append the output tos */ private void emptyTableRow(StringBuilderI builder) { builder.append("<tr class='spacerRow'><td colspan='2'></td><td colspan='").append(algorithmsToTest.size()) .row("'></td></tr>"); } // /** * Pseudo micro benchmark * * @param htmlBuilder The stringbuilder to append the output to * @return dummy value. Sum of bits of all calculated hashes */ protected long appendTimmingBenchmark(StringBuilderI htmlBuilder) { // Avoid dead code elimination long sum = 0; // Keep track of the average time; Map<HashingAlgorithm, Double> averageRuntime = new HashMap<>(algorithmsToTest.size()); /* * Keep track of the actual loop count performed for each algorithm. May be * smaller due to time cutoff */ Map<HashingAlgorithm, Integer> actualLoops = new HashMap<>(); // Init for (HashingAlgorithm hasher : algorithmsToTest.keySet()) { actualLoops.put(hasher, 0); } // Perform a few warmup cycles sum += performWarmup(); /* * Perform benchmark */ sum += performBenchmark(averageRuntime, actualLoops); /* * Compute average */ for (HashingAlgorithm hasher : algorithmsToTest.keySet()) { double avg = averageRuntime.get(hasher) / actualLoops.get(hasher); averageRuntime.put(hasher, avg); } /* * Build report */ htmlBuilder.append("<tr><td colspan=2><b>Timming</b> (ms/picture): *</td>"); for (HashingAlgorithm hasher : algorithmsToTest.keySet()) { htmlBuilder.append("<td>").append(df.format(averageRuntime.get(hasher)) + " ms").append("</td>"); } htmlBuilder.append("</tr>"); // Disclaimer htmlBuilder.append("<tfoot><tr>" + "<td style='text-align:center;' colspan =") .append(algorithmsToTest.size() + 2).append(">") .append("* Please note that speed benchmarks are not representative and should only be used to get a rough estimated of the magnitude of the speed.") .append("</td></tr></tfoot>"); return sum; } /** * Performs a few warmup cycles. During benchmarking this is usually done to get * the JIT to do it's magic. We don't have much time during report generation. * The loop count is tiny and may not accomplish the desired effect. * * @return dummy value */ private long performWarmup() { // In seconds long warmUpCutoff = (5 * (long) 1e9); long sum = 0; for (HashingAlgorithm hasher : algorithmsToTest.keySet()) { long start = System.nanoTime(); for (int i = 0; i < 100; i++) { for (LabeledImage testData : imagesToTest) { sum += hasher.hash(testData.getbImage()).getHashValue().bitCount(); } if (System.nanoTime() - start > warmUpCutoff) { LOGGER.info("warmup cutoff surpassed."); return sum; } } } return sum; } /** * Perform the actual benchmark * * @param averageRuntime map saving the overall runtime for this algorithm * @param actualLoops the actual loops performed. Used to calculate average * valuzes * @return dummy value */ private long performBenchmark(Map<HashingAlgorithm, Double> averageRuntime, Map<HashingAlgorithm, Integer> actualLoops) { // Testing int loops = 500; long testCutoff = (60 * (long) 1e9); long start = System.nanoTime(); /* * Try to counter dead code elimination. Should not occur with so few loops but * better be save ... */ long sum = 0; // Randomize order by looping throught the algorithms one at a time. for (int i = 0; i < loops; i++) { for (HashingAlgorithm hasher : algorithmsToTest.keySet()) { long startIndividual = System.nanoTime(); for (LabeledImage testData : imagesToTest) { sum += hasher.hash(testData.getbImage()).getHashValue().bitCount(); } double elapsed = (((System.nanoTime() - startIndividual))) / (double) 1e6 / imagesToTest.size(); averageRuntime.merge(hasher, elapsed, (old, newVal) -> { return old + newVal; }); actualLoops.merge(hasher, 1, (old, newVal) -> { return old + newVal; }); if (System.nanoTime() - start > testCutoff) { LOGGER.info("test cutoff surpassed. finish with: " + i + " loops executed"); return sum; } } } return sum; } protected void appendChartSection(StringBuilderI htmlBuilder, Map<HashingAlgorithm, List<Double>[]> scatterMap, Map<HashingAlgorithm, DoubleSummaryStatistics[]> statMap) { // Inject javascript charting library boolean success = false; URL jScript = AlgorithmBenchmarker.class.getClassLoader().getResource("Chart.bundle.min.js"); if (jScript != null) { File javascriptScript = new File(jScript.getFile()); htmlBuilder.append("<script src=\"").append(javascriptScript.getAbsolutePath()).append("\"></script>"); success = true; } else { // Fallback attempt cdn via internet LOGGER.info("Could not find js file. fallback to cdn"); try { String cdn = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"; HttpURLConnection connection = (HttpURLConnection) new URL(cdn).openConnection(); connection.setRequestMethod("HEAD"); if (connection.getResponseCode() == 200) { success = true; htmlBuilder.append("<script src=\"").append(cdn).append("\"/></script>"); } } catch (IOException e) { /* We handle exception via success var */} } if (success) { // Build data js objects for chart // Do some clustering StringBuilderI positiveDatBuilder = new StringBuilderI(); StringBuilderI negativeDatBuilder = new StringBuilderI(); StringBuilderI scatterDatBuilder = new StringBuilderI(); StringBuilderI centeroidBuilder = new StringBuilderI(); positiveDatBuilder.row("var dataDictMatch = {};"); negativeDatBuilder.row("var dataDictDistinct = {};"); scatterDatBuilder.row("var dataDictScatter = {};"); centeroidBuilder.row("var dataDictCenter = {};"); // Create data dictionary for (Entry<HashingAlgorithm, List<Double>[]> e : scatterMap.entrySet()) { HashingAlgorithm hasher = e.getKey(); List<Double>[] occurances = e.getValue(); double[][] data = new double[occurances[0].size() + occurances[1].size()][1]; for (int i = 0; i < occurances[0].size(); i++) { data[i][0] = occurances[0].get(i); } for (int i = 0; i < occurances[1].size(); i++) { data[i + occurances[0].size()][0] = occurances[1].get(i); } // Compute centeroid // Compute buckets int[] matchBucket = new int[buckets]; int[] distinctBucket = new int[buckets]; // Two lines // TODO calculate the number of images we can not correctly predict no matter // where the threshold is. ROC curve as it's done in the forest image matcher // Average between centers. double avg = (statMap.get(hasher)[0].getAverage() + statMap.get(hasher)[1].getAverage()) / 2; // Average between max match and min distinct scatterDatBuilder.append("dataDictScatter['").append(hasher.algorithmId()).append("']=["); centeroidBuilder.append("dataDictCenter['").append(hasher.algorithmId()).append("']=[").append(avg) .row("];"); int dataPoints = occurances[0].size(); for (int i = 0; i < dataPoints; i++) { matchBucket[(int) (occurances[0].get(i) * buckets) + (stepped ? 0 : 1)]++; scatterDatBuilder.append("{x:").append(occurances[0].get(i)).append(",y:").append(i / 2d) .append("},"); } dataPoints = occurances[1].size(); if (dataPoints > 0) { scatterDatBuilder.append(","); } for (int i = 0; i < dataPoints; i++) { distinctBucket[(int) (occurances[1].get(i) * buckets) + (stepped ? 0 : 1)]++; scatterDatBuilder.append("{x:").append(occurances[1].get(i)).append(",y:").append(i / 2d) .append("}"); if (i != dataPoints - 1) { scatterDatBuilder.append(","); } } scatterDatBuilder.append("];\n"); positiveDatBuilder.append("dataDictMatch['").append(hasher.algorithmId()).append("']=["); negativeDatBuilder.append("dataDictDistinct['").append(hasher.algorithmId()).append("']=["); for (int i = 0; i < buckets; i++) { positiveDatBuilder.append("{x:").append(i / (double) buckets).append(",y:").append(matchBucket[i]) .append("}"); negativeDatBuilder.append("{x:").append(i / (double) buckets).append(",y:") .append(distinctBucket[i]).append("}"); if (i != buckets - 1) { positiveDatBuilder.append(","); negativeDatBuilder.append(","); } } positiveDatBuilder.row("];"); negativeDatBuilder.row("];"); } htmlBuilder.row("<div style='width:40%; margin:auto;'>") .row("<canvas id ='chartCanvas' width='600' height='400' style='background-color:' />") .row("</div>"); //@formatter:off //append javascript to initialize chart data htmlBuilder.row("<script>") .append(positiveDatBuilder.toString()) .append(negativeDatBuilder.toString()) .append(scatterDatBuilder.toString()) .append(centeroidBuilder.toString()) .row(" Chart.defaults.global.defaultFontColor='white';") .row("var canvas = document.getElementById('chartCanvas').getContext('2d')") .row("var chart = new Chart(canvas, {") .row(" data: {\n") //.append(" labels: [0.0,'0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9','1.0'],\n") .row(" datasets: [{\n") .row(" label: 'points',") .row(" type: 'scatter',") .row(" fill: false,") .row(" showLine: false,") .row(" pointRadius: 5,") .row(" backgroundColor: 'orange'") .row(" },") .row(" {") .row(" type: 'line',") .row(" label: 'match',") .row(" steppedLine: "+stepped+",") .row(" pointRadius: 0,") .row(" backgroundColor: 'rgba(32,178,170,0.65)'") .row(" },") .row(" {") .row(" type: 'line',") .row(" label: 'distinct',") .row(" steppedLine: "+stepped+",") .row(" pointRadius: 0,") .row(" backgroundColor: 'rgba(250,128,114,0.8)'") .row(" }") .row(" ]") .row(" },") .row(" options: {") .row(" responsive: true,") .row(" scales: {") .row(" yAxes: [{") .row(" gridLines: {") .row(" color: 'white'") .row(" },") .row(" scaleLabel: {") .row(" display: true,") .row(" labelString: 'Occurances',") .row(" }") .row(" }],") .row(" xAxes: [{") .row(" type:'linear',") .row(" gridLines: {") .row(" color: 'white'") .row(" },") .row(" ticks: {") .row(" min:0,") .row(" max:1,") .row(" stepSize:0.1") .row(" },") .row(" scaleLabel: {") .row(" display: true,") .row(" labelString: 'Distance',") .row(" }") .row(" }]") .row(" },") .row(" title:{") .row(" display:true,") .row(" text: 'Test'") .row(" }") .row(" }") .row("})") //Register javascript callback //tiny bit javascript magic .row("var table = document.getElementById('rootTable');") .row("var lastIndex = -1;") .row("var lastObject = undefined;") .row("table.addEventListener('mousemove',function(e){") .row(" var parentTr = e.path[1];") .row(" var target = e.path[0];") .row(" if(target == lastObject){") .row(" return;" ) .row(" }") .row(" lastObject = target;") .row(" var correctCellIndex = 0;") .row(" //Fix: Compute correct cellindex due to colspans") .row(" for(let cell of parentTr.cells){") .row(" if(cell == target){") .row(" break;") .row(" }else{") .row(" correctCellIndex += cell.colSpan;") .row(" }") .row(" }") .row(" if(correctCellIndex != lastIndex) {") .row(" lastIndex = correctCellIndex;") .row(" //First tr in tbody with cellIndex - 1") .row(" var id = table.rows[1].cells[correctCellIndex-1].id") .row(" if(id === undefined){") .row(" return;") .row(" }\n") .row(" //Repopulate table\n") .row(" chart.options.title.text = table.rows[1].cells[correctCellIndex-1].innerText;\n") .row(" chart.data.datasets[1].data = dataDictMatch[id];") .row(" chart.data.datasets[2].data = dataDictDistinct[id];") .row(" chart.data.datasets[0].data = dataDictScatter[id];") .row(" chart.config.verticalMarker = dataDictCenter[id]") .row(" chart.update();") .row(" }") //Adapted from https://stackoverflow.com/a/43092029/3244464 .row("const verticalLinePlugin = {\r\n" + " getLinePosition: function (chart, value) {\r\n" + " return chart.scales['x-axis-0'].getPixelForValue(value);"+ " },\r\n" + " renderVerticalLine: function (chartInstance, xValue) {\r\n" + " const lineLeftOffset = this.getLinePosition(chartInstance, xValue);\r\n" + " const scale = chartInstance.scales['y-axis-0'];\r\n" + " const context = chartInstance.chart.ctx;\r\n" + "\r\n" + " // render vertical line\r\n" + " context.beginPath();\r\n" + " context.strokeStyle = 'white';\r\n" + " context.lineWidth = 2;\n" + " context.moveTo(lineLeftOffset, scale.top);\r\n" + " context.lineTo(lineLeftOffset, scale.bottom);\r\n" + " context.stroke();\r\n" + "\r\n" + " // write label\r\n" + //" context.font =\"20px 'Helvetica Neue'\";\n"+ //" context.fillStyle = \"white\";\r\n" + //" context.textAlign = 'center';\r\n" + //" context.fillText('Centeroid:'+xValue.toFixed(3), lineLeftOffset, (scale.bottom - scale.top) / 8 + scale.top);\r\n" + " },\r\n" + "\r\n" + " afterDatasetsDraw: function (chart, easing) {\r\n" + " if (chart.config.verticalMarker) {\r\n" + " chart.config.verticalMarker.forEach(xValue => this.renderVerticalLine(chart, xValue));\r\n" + " }\r\n" + " }\r\n" + " };\r\n" + "\r\n" + " Chart.plugins.register(verticalLinePlugin);") .row("});</script>"); //@formatter:on } else { LOGGER.warning("Could not link to chartjs library. skip chart generation."); } } /** * Construct the html frame to embed the benchmark result into * * @return an html template */ private static String buildHtmlBase() { StringBuilderI htmlBuilder = new StringBuilderI(); htmlBuilder.row("<!DOCTYPE html>").row("<html>").row(" <head>") .row(" <title>Algorithm Benchmarking</title>") /* * CSS * @formatter:off */ .row(" <style>") .row(" html{min-height:100%;}") .row(" body{min-height:100%; margin: 0; background: linear-gradient(45deg, #49a09d, #5f2c82); font-family: sans-serif; font-weight: 100; margin-bottom:25px;}") .row(" table{margin:auto; border-collapse: collapse; box-shadow: 0 0 20px rgba(0,0,0,0.1); margin-top:40px;}") .row(" thead th, td:first-child, .category{background-color: #55608f;}") .row(" tr:nth-child(1) {background-color:#3d54b9;}") .row(" th,td{padding: 10px;background-color: rgba(255,255,255,0.2);color: #fff;}") .row(" tbody tr:hover{background-color: rgba(255,255,255,0.3);}") .row(" .spacerRow td{ padding:7px;}") .row(" .circle{width:10px; height: 10px; background-color:green; border-radius:50%; display:inline-block; margin-right:5px;}") .row(" .fault{color:#fff700;}") .row(" </style>") .row(" </head>") .row(" <body>") .row(" $body") .row(" </body>") .row("</html>"); return htmlBuilder.toString(); } /** * Utility StringBuilder to allow formating text * * @author Kilian * @since 2.0.0 */ private static class StringBuilderI { private StringBuilder internal = new StringBuilder(); public StringBuilderI append(String s) { internal.append(s); return this; } /** * @param double1 * @return */ public StringBuilderI append(double d) { internal.append(d); return this; } public StringBuilderI append(long l) { internal.append(l); return this; } public StringBuilderI append(int i) { internal.append(i); return this; } public StringBuilderI row(String s) { internal.append(s).append("\n"); return this; } public String toString() { return internal.toString(); } } /** * * @author Kilian * @since 2.0.0 */ public static class BenchmarkApplication extends Application { @Override public void start(Stage primaryStage) throws Exception { // Main entry point if javafx is not already started spawnNewWindow(primaryStage, getParameters().getRaw().get(0)); } /** * Spawn subsequent windows if Javafx is already spawned. Application.launch can * only be called once, therefore a work around is needed. * * @param html to display */ public void spawnWindow(String html) { Platform.runLater(() -> { spawnNewWindow(new Stage(), html); }); } /** * Spawn a full screen windows with a webview displaying the html content * * @param stage of the window * @param htmlContent the content to display */ private void spawnNewWindow(Stage stage, String htmlContent) { WebView webView = new WebView(); webView.getEngine().loadContent(htmlContent); // Fullscreen Rectangle2D screen = Screen.getPrimary().getVisualBounds(); double w = screen.getWidth(); double h = screen.getHeight(); Scene scene = new Scene(webView, w, h); stage.setTitle("Image Hash Benchmarker"); stage.getIcons().add(new Image("imageHashLogo.png")); stage.setScene(scene); stage.show(); } } }