Fork me on GitHub

Source: utils.js

  1. /* globals
  2. G_DEBUG
  3. NRMSE
  4. ImageMSSSIM
  5. G_GUI_Controller
  6. UTIF
  7. G_AUTO_PREVIEW_LIMIT
  8. G_IMG_METRIC_ENABLED
  9. G_APP_NAME
  10. */
  11. /* exported GetOptimalBoxWidth */
  12. /**
  13. * Used for display, for number.toFixed() rounding.
  14. * @namespace G_MATH_TOFIXED
  15. */
  16. const G_MATH_TOFIXED = {
  17. /**
  18. * @type {number}
  19. * @description The minimum number of decimal digits.
  20. */
  21. MIN: 1,
  22. /** The short or standard number of decimal digits. */
  23. SHORT: 2,
  24. /** The maximum or "longest" number of decimal digits. */
  25. LONG: 4
  26. };
  27. /**
  28. * Calculated the size to use for each drawing box/stage.
  29. * Edit the values in the functions to change the box sizing.
  30. * @returns The size to use.
  31. */
  32. function GetOptimalBoxWidth(){
  33. // Values used to calculate the size of each box/stage
  34. var boxesPerPageWidth = 5;
  35. // count-in the width of the borders of the boxes
  36. var boxBorderW = 2 * (parseInt($('.box:first').css('border-width')) || 1);
  37. var scrollBarW = 15; // scroll bar width
  38. var boxSizeMax = 300; //max width for the boxes
  39. // make sure to have an integer value to prevent slight sizing differences between each box
  40. var calculatedBoxSize = Math.ceil(Math.max(
  41. (document.body.clientWidth / boxesPerPageWidth) - boxBorderW - scrollBarW,
  42. boxSizeMax));
  43. return calculatedBoxSize;
  44. }
  45. /**
  46. * Various utility and helper functions
  47. * @namespace Utils
  48. */
  49. const Utils = {
  50. /**
  51. * Makes a new stage 'box' with a layer added, and some settings
  52. * @param {Element} parentContainer the DOM element of the parent container in which to add a stage 'box'.
  53. * @param {*} w the width of the stage
  54. * @param {*} h the height of the stage
  55. * @returns the drawing stage.
  56. */
  57. newStageTemplate: function(parentContainer, w, h) {
  58. var $e = $('<div/>').addClass('box').appendTo(parentContainer);
  59. var stage = new Konva.Stage({
  60. container: $e.get(0),
  61. width: w,
  62. height: h
  63. });
  64. // then create layer and to stage
  65. var layer = new Konva.Layer({
  66. listening: false // faster render
  67. });
  68. // add and push
  69. stage.add(layer);
  70. // turn off by default antialiasing/smoothing
  71. // important do that AFTER you added layer to a stage
  72. // https://github.com/konvajs/konva/issues/306#issuecomment-351263036
  73. layer.imageSmoothingEnabled(false);
  74. return stage;
  75. },
  76. /**
  77. * Initiates the image resource load with a callback once the image is loaded.
  78. * @param {string} url The url pointing to the image to load.
  79. * @param {function} callback The callback function to call/run once the image is loaded.
  80. * @param {boolean} _allowRetryAsUTIF Used internally to prevent a recursive retry loop with the UTIF decoder.
  81. */
  82. loadImage: function(url, callback, _allowRetryAsUTIF = true) {
  83. var imageObj = new Image();
  84. imageObj.onload = callback;
  85. imageObj.onerror = function(e){
  86. // eslint-disable-next-line no-magic-numbers
  87. if (_allowRetryAsUTIF && e.target.src.substring(0,22) == "data:image/tiff;base64") {
  88. console.warn("ERROR: could not load the given TIFF image. Retrying with UTIF decoder.", e);
  89. Utils._loadImageUTIF(url, callback);
  90. } else {
  91. console.error("ERROR: could not load the given image.", e);
  92. }
  93. };
  94. imageObj.src = url;
  95. },
  96. /**
  97. * Used internally by @see {@link Utils.loadImage} to retry loading TIFFs with the
  98. * UTIF.js decoder that otherwise failed with the built-in decoder.
  99. * @param {*} url The image base64 URL/URI.
  100. * @param {*} callback a function to call when image.onload happens.
  101. */
  102. _loadImageUTIF: async function(url, callback) {
  103. // useful links
  104. // https://github.com/photopea/UTIF.js/
  105. // https://observablehq.com/@ehouais/decoding-tiff-image-data
  106. // https://stackoverflow.com/a/52410044/883015
  107. //
  108. // let blob = await fetch(url).then(r => r.blob());
  109. await fetch(url).then(response => response.blob()) // get the url as a blob
  110. .then(blob => blob.arrayBuffer()) // get the data as a array/buffer
  111. .then(UTIF.bufferToURI) // decode the data as an RGBA8 image data URI
  112. .then(function(decoded_as_rgba8_url){
  113. // load the image once again as usual...
  114. Utils.loadImage(decoded_as_rgba8_url, callback, false);
  115. });
  116. },
  117. /**
  118. * Attempts to get the value or text within a given element/control.
  119. * @param {object|jQuery} $e the jquery wrapped DOM element.
  120. * @returns the value contained or represented in the given control/element.
  121. */
  122. getInputValueInt: function($e){
  123. var rawValue = parseInt($e.val());
  124. if (isNaN(rawValue))
  125. return parseInt($e.attr('placeholder'));
  126. return rawValue;
  127. },
  128. getRowsInput: function(){ return G_GUI_Controller.pixelCountY; },
  129. getColsInput: function(){ return G_GUI_Controller.pixelCountX; },
  130. getBrightnessInput: function(){ return G_GUI_Controller.brightness; },
  131. getContrastInput: function(){ return G_GUI_Controller.contrast; },
  132. getGlobalBCInput: function(){ return G_GUI_Controller.globalBC; },
  133. getCellWInput: function(){ return this.getInputValueInt($('#iCellW')); },
  134. getCellHInput: function(){ return this.getInputValueInt($('#iCellH')); },
  135. getSpotXInput: function(){ return this.getInputValueInt($('#iSpotX')); },
  136. getSpotYInput: function(){ return this.getInputValueInt($('#iSpotY')); },
  137. getSpotAngleInput: function(){ return this.getInputValueInt($('#iSpotAngle')); },
  138. getGroundtruthImage: function(){ return G_GUI_Controller.groundTruthImg; },
  139. getPixelSizeNmInput: function(){ return G_GUI_Controller.pixelSize_nm; },
  140. setPixelSizeNmInput: function(val){ G_GUI_Controller.controls.pixelSize_nm.setValue(val); },
  141. getShowRulerInput: function(){ return G_GUI_Controller.showRuler; },
  142. getSpotLayoutOpacityInput: function(){ return G_GUI_Controller.previewOpacity; },
  143. getImageMetricAlgorithm: function(){ return G_GUI_Controller.imageMetricAlgo; },
  144. getImageSmoothing: function(){ return G_GUI_Controller.imageSmoothing; },
  145. getImageFillMode: function(){
  146. // TODO: maybe add a GUI option to toggle between fit, fill, stretch modes...
  147. // just a default for now, until support for this is implemented
  148. // https://github.com/joedf/ImgBeamer/issues/7
  149. // TODO: likely have a global 'enum' of all the fill modes?
  150. // return "fit";
  151. return "squish";
  152. },
  153. /**
  154. * Creates a Zoom event handler to be used on a stage.
  155. * Holding the shift key scales at half the rate.
  156. * @param {object} stage the drawing stage
  157. * @param {object} konvaObj the figure or object on the stage to change.
  158. * @param {function} callback a callback for when the zoom event handler is called.
  159. * @param {number} scaleFactor the scale factor per "tick"
  160. * @param {number|function} scaleMin the scale minimum allowed defined as a number or function.
  161. * @param {number|function} scaleMax the scale maximum allowed defined as a number or function.
  162. * @returns the created event handler
  163. */
  164. // eslint-disable-next-line no-magic-numbers
  165. MakeZoomHandler: function(stage, konvaObj, callback=null, scaleFactor=1.2, scaleMin=0, scaleMax=Infinity){
  166. var _self = this;
  167. var handler = function(e){
  168. // modified from https://konvajs.org/docs/sandbox/Zooming_Relative_To_Pointer.html
  169. e.evt.preventDefault(); // stop default scrolling
  170. var scaleBy = scaleFactor;
  171. // Do half rate scaling, if shift is pressed
  172. if (e.evt.shiftKey) {
  173. scaleBy = 1 +((scaleBy-1) / 2);
  174. }
  175. // how to scale? Zoom in? Or zoom out?
  176. let direction = e.evt.deltaY > 0 ? -1 : 1;
  177. var oldScale = konvaObj.scaleX();
  178. var pointer = stage.getPointerPosition();
  179. var newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
  180. // Allow scale[Min/Max] to be functions or numbers...
  181. var _scaleMin = (typeof scaleMin == 'function') ? scaleMin(oldScale, newScale) : scaleMin;
  182. var _scaleMax = (typeof scaleMax == 'function') ? scaleMax(oldScale, newScale) : scaleMax;
  183. // Limit scale based on given bounds
  184. var finalScale = Math.min(_scaleMax, Math.max(_scaleMin, newScale));
  185. if (pointer != null)
  186. _self.scaleCenteredOnPoint(pointer, konvaObj, oldScale, finalScale);
  187. else {
  188. if (G_DEBUG) {
  189. console.warn("MakeZoomHandler got a null pointer...");
  190. }
  191. }
  192. stage.draw();
  193. if (typeof callback == 'function')
  194. callback(e);
  195. };
  196. return handler;
  197. },
  198. /**
  199. * Creates a ruler control / drawable on the given layer of a konva stage.
  200. * Scaling is calculated using the stage size, the input image size,
  201. * and the (globally set) image pixel size in "real" / physical units.
  202. * @param {*} layer The layer of the stage to draw on.
  203. * @param {*} oImg The input image.
  204. * @param {*} x1 the starting x coordinate of the ruler line.
  205. * @param {*} y1 the starting y coordinate of the ruler line.
  206. * @param {*} x2 the ending x coordinate of the ruler line.
  207. * @param {*} y2 the ending y coordinate of the ruler line.
  208. * @returns an object with the property "element" for the drawable control,
  209. * a getLengthNm() method to get the current length of the ruler in physical units,
  210. * a getPixelSize(lengthNm) method to calculate the pixel size in physical (nm) units
  211. * based on the new specified length of the ruler in physical (nm) units,
  212. * and a doUpdate() method to update the ruler to represent the its latest state.
  213. */
  214. CreateRuler: function(layer, oImg, x1 = 0, y1 = 100, x2 = 100, y2 = 100) {
  215. var stage = layer.getStage();
  216. var lengthNm = 0;
  217. var updateCalc = function(){
  218. //TODO: maybe have scaling function for this...?
  219. var linePts = line.points();
  220. var pxSizeNmX = Utils.getPixelSizeNmInput();
  221. // we need to scale by stage size as well and image size...
  222. var pt1 = Utils.stageToImagePixelCoordinates(linePts[0], linePts[1], stage, oImg);
  223. var pt2 = Utils.stageToImagePixelCoordinates(linePts[2], linePts[3], stage, oImg);
  224. // and convert to "real" units
  225. // currently we only have pixel size in X direction
  226. var nm1 = Utils.imagePixelToRealCoordinates(pt1.x, pt1.y, pxSizeNmX);
  227. var nm2 = Utils.imagePixelToRealCoordinates(pt2.x, pt2.y, pxSizeNmX);
  228. // before we make the distance calculation
  229. // this is done to support non-square pixels
  230. var distNm = Utils.distance(nm1.x, nm1.y, nm2.x, nm2.y);
  231. var fmt = Utils.formatUnitNm(distNm);
  232. text.text(fmt.value.toFixed(G_MATH_TOFIXED.SHORT) + " " + fmt.unit);
  233. lengthNm = distNm;
  234. };
  235. var calculateNewPixelSize = function(lengthNm){
  236. // does the "reverse" calculation for the pixel size
  237. // get points in image pixel coordinates
  238. var linePts = line.points();
  239. var pt1 = Utils.stageToImagePixelCoordinates(linePts[0], linePts[1], stage, oImg);
  240. var pt2 = Utils.stageToImagePixelCoordinates(linePts[2], linePts[3], stage, oImg);
  241. // compute x/y components and scale it accordingly
  242. var dx = Math.abs(pt1.x - pt2.x);
  243. var dy = Math.abs(pt1.y - pt2.y);
  244. // compute angle
  245. // https://stackoverflow.com/a/9614122/883015
  246. var radAngle = Math.atan2(dy, dx);
  247. // decompose the given length in to x/y components
  248. // this is done to support non-square pixels
  249. var length = {
  250. x: lengthNm * Math.cos(radAngle),
  251. y: lengthNm * Math.sin(radAngle),
  252. };
  253. // calculate pixel size
  254. var pxSizeNm = {
  255. x: length.x / dx,
  256. y: length.y / dy,
  257. };
  258. return pxSizeNm;
  259. };
  260. var anchorMove = function(e, anchor){
  261. // shift-key makes straight horizontal line
  262. if (e.evt.shiftKey) {
  263. if (anchor == anchors.start) {
  264. anchors.start.y(anchors.end.y());
  265. } else {
  266. anchors.end.y(anchors.start.y());
  267. }
  268. }
  269. // ctrl-key makes straight vertical line
  270. else if (e.evt.ctrlKey) {
  271. if (anchor == anchors.start) {
  272. anchors.start.x(anchors.end.x());
  273. } else {
  274. anchors.end.x(anchors.start.x());
  275. }
  276. }
  277. line.points([
  278. anchors.start.x() - line.x(),
  279. anchors.start.y() - line.y(),
  280. anchors.end.x() - line.x(),
  281. anchors.end.y() - line.y()
  282. ]);
  283. };
  284. var anchors = {
  285. start: this._CreateAnchor(x1, y1, anchorMove),
  286. end: this._CreateAnchor(x2, y2, anchorMove),
  287. };
  288. var group = new Konva.Group({
  289. draggable: true,
  290. });
  291. var line = new Konva.Arrow({
  292. pointerAtBeginning: true,
  293. points: [x1, y1, x2, y2],
  294. strokeWidth: 2,
  295. fill: "lime",
  296. stroke: 'lime',
  297. });
  298. line.on("mouseover", function(){ this.strokeWidth(4); });
  299. line.on("mouseout", function(){ this.strokeWidth(2); });
  300. group.on('mouseover', function(){ document.body.style.cursor = "pointer"; });
  301. group.on('mouseout', function(){
  302. document.body.style.cursor = "default";
  303. tooltip.hide();
  304. });
  305. group.on('mousemove', function(){
  306. var mousePos = stage.getPointerPosition();
  307. tooltip.position(mousePos);
  308. var offset = 5;
  309. tooltip.offsetX(-offset);
  310. tooltip.offsetY(-offset);
  311. tooltip.show();
  312. });
  313. var updateLabel = function(){
  314. updateCalc();
  315. var linePts = line.points();
  316. label.position({
  317. x: (linePts[0] + linePts[2]) / 2,
  318. y: (linePts[1] + linePts[3]) / 2,
  319. });
  320. label.offsetX(label.width() / 2);
  321. label.offsetY(label.height() / 2);
  322. };
  323. group.on('dragmove', updateLabel);
  324. var label = new Konva.Label();
  325. var text = new Konva.Text({
  326. text: '0.00 nm',
  327. fontFamily: 'monospace',
  328. fontSize: 12,
  329. // fontStyle: 'bold',
  330. padding: 5,
  331. fill: 'lime',
  332. fillAfterStrokeEnabled: true,
  333. stroke: 'black',
  334. listening: false,
  335. });
  336. label.add(text);
  337. var tooltip = new Konva.Text({
  338. text: 'Double-click to set the pixel size / scaling.',
  339. fontSize: 12,
  340. width: 150,
  341. padding: 8,
  342. fill: 'white',
  343. fillAfterStrokeEnabled: true,
  344. stroke: 'black',
  345. visible: false,
  346. listening: false,
  347. });
  348. group.add(line, anchors.start, anchors.end, label);
  349. layer.add(group, tooltip);
  350. updateLabel();
  351. // return {"group": group, "archors": anchors, "line": line};
  352. return {
  353. element: group,
  354. getLengthNm: function(){ return lengthNm; },
  355. getPixelSize: function(lengthNm){
  356. return calculateNewPixelSize(lengthNm);
  357. },
  358. doUpdate: function(){ updateLabel(); },
  359. };
  360. },
  361. _CreateAnchor: function(x, y, onMove, strokeWidth = 2) {
  362. // modified from:
  363. // https://konvajs.org/docs/sandbox/Modify_Curves_with_Anchor_Points.html
  364. var anchor = new Konva.Circle({
  365. x: x,
  366. y: y,
  367. radius: 5,
  368. stroke: "#666",
  369. fill: "#ddd",
  370. strokeWidth: strokeWidth,
  371. draggable: true,
  372. opacity: 0.4,
  373. });
  374. // add hover styling
  375. anchor.on("mouseover", function () {
  376. document.body.style.cursor = "pointer";
  377. this.strokeWidth(strokeWidth + 2);
  378. });
  379. anchor.on("mouseout", function () {
  380. document.body.style.cursor = "default";
  381. this.strokeWidth(strokeWidth);
  382. });
  383. anchor.on("dragmove", function (e) {
  384. if (typeof onMove == 'function')
  385. onMove(e, this);
  386. });
  387. return anchor;
  388. },
  389. /**
  390. * Creates a random integer between 0 and the given maximum.
  391. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
  392. * @param {number} max the largest value possible.
  393. * @returns a random number.
  394. */
  395. getRandomInt: function(max) {
  396. return Math.floor(Math.random() * max);
  397. },
  398. /**
  399. * Initiates a download of the given resource, like programmatically clicking on a download link.
  400. * function from:
  401. * https://konvajs.org/docs/data_and_serialization/High-Quality-Export.html
  402. * https://stackoverflow.com/a/15832662/512042
  403. * @param {string} uri a url pointing to the resource to download.
  404. * @param {*} name the filename to use for the downloaded file.
  405. */
  406. downloadURI: function(uri, name) {
  407. var link = document.createElement('a');
  408. link.download = name;
  409. link.href = uri;
  410. document.body.appendChild(link);
  411. link.click();
  412. document.body.removeChild(link);
  413. // delete link;
  414. },
  415. /**
  416. * Updates the displayed statistics or parameters on the Spot profile.
  417. * @param {*} stage the drawing stage for the spot profile and where to display the values.
  418. * @param {*} beam the beam used for the spot layout and sampling of the image (after scaling).
  419. * @param {*} cellSize the size of a cell in the raster grid of the resulting image.
  420. * @param {*} userImage the scaled image by the user (in spot content) used to size the beam.
  421. */
  422. updateDisplayBeamParams: function(stage, beam, cellSize, userImage, onDblClick) {
  423. // calculate and display the values
  424. const infoclass = "parameterDisplay";
  425. var element = this.ensureInfoBox(stage, infoclass, onDblClick);
  426. if (element) {
  427. var beamSizeA = beam.radiusX() * beam.scaleX(),
  428. beamSizeB = beam.radiusY() * beam.scaleY();
  429. // swap them so that is beamSizeA the larger one, for convention
  430. if (beamSizeA < beamSizeB) { [beamSizeA, beamSizeB] = [beamSizeB, beamSizeA]; }
  431. // https://www.cuemath.com/geometry/eccentricity-of-ellipse/
  432. var eccentricity = Math.sqrt(1 - (Math.pow(beamSizeB,2) / Math.pow(beamSizeA,2)));
  433. var spotSizeX = NaN, spotSizeY = NaN;
  434. if (typeof userImage != 'undefined'){
  435. var bw = (beam.width()*beam.scaleX()) / userImage.scaleX();
  436. var bh = (beam.height()*beam.scaleY()) / userImage.scaleY();
  437. spotSizeX = (bw / cellSize.w)*100;
  438. spotSizeY = (bh / cellSize.h)*100;
  439. }
  440. // display it
  441. element.innerHTML = 'Eccentricity: '+eccentricity.toFixed(G_MATH_TOFIXED.SHORT) +'<br>'
  442. + 'Rotation: '+beam.rotation().toFixed(G_MATH_TOFIXED.MIN)+"°" +'<br>'
  443. + 'Width: '+spotSizeX.toFixed(G_MATH_TOFIXED.MIN)+'%' +'<br>'
  444. + 'Height: '+spotSizeY.toFixed(G_MATH_TOFIXED.MIN)+'%';
  445. // tooltip
  446. element.title = 'Double-click to change the spot width.';
  447. }
  448. },
  449. /**
  450. * Calculates cell size based on given object (eg. rect, stage, or image), rows and cols
  451. * @param {*} rect a Konva object that has a width and height, usually a rect, image, or stage.
  452. * @param {number} rows (optional) the number of rows to split the area into.
  453. * If not is provided, attempts to get it from gui/input.
  454. * @param {number} cols (optional) the number of columns to split the area into.
  455. * If not is provided, attempts to get it from gui/input.
  456. * @returns the size (w,h) of a cell in the raster grid.
  457. */
  458. computeCellSize: function(rect, rows = -1, cols = -1){
  459. if (rows <= 0) { rows = this.getRowsInput(); }
  460. if (cols <= 0) { cols = this.getColsInput(); }
  461. var cellSize = {
  462. w: rect.width() / cols,
  463. h: rect.height() / rows,
  464. };
  465. return cellSize;
  466. },
  467. /**
  468. * Calculates the magnification based on the given rectangles' width and scaleX
  469. * @param {*} rectBase The original object
  470. * @param {*} rectScaled The scaled object
  471. * @returns The magnification (ratio)
  472. */
  473. computeMagLevel: function(rectBase, rectScaled) {
  474. var rW = (rectScaled.width() * rectScaled.scaleX()) / (rectBase.width() * rectBase.scaleX());
  475. return rW;
  476. },
  477. /**
  478. * Displays and updates the magnification.
  479. * @param {*} destStage The stage to display the info on.
  480. * @param {*} scaledRect The shape to calculate the magnification from compared to its stage.
  481. */
  482. updateMagInfo: function(destStage, scaledRect) {
  483. // add/update the mag disp. text
  484. const infoclass = "magDisplay";
  485. var element = this.ensureInfoBox(destStage, infoclass);
  486. if (element) {
  487. var magLevel = this.computeMagLevel(scaledRect.getStage(), scaledRect);
  488. var fmtMag = magLevel.toFixed(G_MATH_TOFIXED.SHORT) + 'X';
  489. // display it
  490. element.innerHTML = fmtMag;
  491. G_GUI_Controller.digitalMag = fmtMag;
  492. }
  493. },
  494. /**
  495. * Displays and updates the Image metrics, if {@link G_IMG_METRIC_ENABLED} is true.
  496. * @param {*} sourceStage The stage for the ground truth / reference image
  497. * @param {*} destStage The stage for the image to compare
  498. */
  499. updateImageMetricsInfo: function(sourceStage, destStage) {
  500. // create info dialog as needed
  501. const dialogId = "dialog-imgMetric";
  502. const eTitle = "Double-click for more information.";
  503. var onDblClick = function(){
  504. let dialog = $('#'+dialogId);
  505. if (dialog.length) {
  506. dialog.dialog('open');
  507. } else {
  508. var elem = $("<div/>")
  509. .attr({
  510. 'id': dialogId,
  511. 'title': G_APP_NAME + " - Image Quality Metric"
  512. })
  513. .css({'display':'none'})
  514. .addClass('jui')
  515. .html(`
  516. <div>
  517. <input type="hidden" autofocus="autofocus" />
  518. <p><b>Image Quality</b></p>
  519. <p>
  520. The intended use of an image metric in this application is more of a qualitative
  521. nature, rather than quantitative. The user should be able to grasp any trends in the
  522. change of the image quality metric when the imaging parameters are changed.
  523. </p>
  524. <p>
  525. That said, it is the trends or change in the image quality metric values that
  526. are important, more so than the values themselves.
  527. Other than the MSE and PSNR algorithms, a value of 0.0 indicates the lowest
  528. score or match when compared to the original (ground truth) image. Whereas
  529. a maximum score of 1.0 indicates a perfect match. Naturally, the ground truth image
  530. is assumed to be of optimum quality for this comparison.
  531. <p>
  532. <details>
  533. <summary><b>Additional Information</b></p></summary>
  534. <p>For performance reasons, the metric is only updated at every quarter of the image
  535. drawn, or if the draw-rate is fast, <i>i.e.</i>, less than 50 ms/row
  536. (for non SSIM-based algorithms).
  537. </p>
  538. <p>Unfortunately, there is no flawless or foolproof image quality metric.
  539. Over 20 different image metrics have been reviewed and compared by
  540. <a href="https://www.sciencedirect.com/science/article/pii/S2214241X15000206">
  541. Jagalingam and Hegde in a 2015 paper</a>, each with
  542. their different strengths and weaknesses.
  543. </p>
  544. <ul>
  545. <li>More information on the purpose and intended use can be found
  546. <a href="https://github.com/joedf/CAS741_w23/blob/main/docs/SRS/SRS.pdf">here</a>.</li>
  547. <li>A comparison of various image quality metrics used in this application is available
  548. <a href="https://github.com/joedf/CAS741_w23/blob/main/docs/VnVReport/VnVReport.pdf">here</a>.</li>
  549. </ul>
  550. </details>
  551. </div>
  552. `);
  553. elem.dialog({
  554. modal: true,
  555. width: 540,
  556. buttons: {
  557. Ok: function() {
  558. $( this ).dialog( "close" );
  559. }
  560. }
  561. });
  562. }
  563. };
  564. // calculate and display
  565. const infoclass = "metricsDisplay";
  566. var element = this.ensureInfoBox(destStage, infoclass, onDblClick, eTitle);
  567. if (element) {
  568. // Show/hide the img-metric based on the global boolean
  569. $(element).toggle(G_IMG_METRIC_ENABLED);
  570. // only do the calc, if enabled
  571. if (G_IMG_METRIC_ENABLED) {
  572. // compare without Image Smoothing
  573. const imageSmoothing = false;
  574. // get ground truth image
  575. var refImage = this.getFirstImageFromStage(sourceStage);
  576. var refData = this.getKonvaImageData(refImage, imageSmoothing);
  577. // get the image without the row/draw indicator
  578. var finalImage = this.getVirtualSEM_KonvaImage(destStage);
  579. var finalData = this.getKonvaImageData(finalImage, imageSmoothing);
  580. // Do the metric calculation here
  581. // based on the algorithm/metric chosen...
  582. var metricValue = 0;
  583. var algo = Utils.getImageMetricAlgorithm();
  584. if (algo.indexOf('SSIM') >= 0) {
  585. // needed for the SSIM / MS-SSIM library
  586. const img_channel_count = 4;
  587. refData.channels = img_channel_count;
  588. finalData.channels = img_channel_count;
  589. let metrics = ImageMSSSIM.compare(refData, finalData);
  590. if (algo == "MS-SSIM") {
  591. metricValue = metrics.msssim;
  592. } else {
  593. metricValue = metrics.ssim;
  594. }
  595. } else { // 'MSE', 'PSNR', 'iNRMSE', 'iNMSE'
  596. let metrics = NRMSE.compare(refData, finalData);
  597. switch(algo) {
  598. case 'MSE': metricValue = metrics.mse; break;
  599. case 'PSNR': metricValue = metrics.psnr; break;
  600. case 'iNMSE': metricValue = metrics.inmse; break;
  601. default: metricValue = metrics.inrmse;
  602. }
  603. }
  604. // display it
  605. element.innerHTML = algo + " = " + metricValue.toFixed(G_MATH_TOFIXED.LONG);
  606. }
  607. }
  608. },
  609. updateSubregionPixelSize: function(destStage, subregionImage, imageObj){
  610. var rows = Utils.getRowsInput(), cols = Utils.getColsInput();
  611. var rect = {
  612. w: subregionImage.width() * subregionImage.scaleX(),
  613. h: subregionImage.height() * subregionImage.scaleY(),
  614. };
  615. // TODO: maybe get the ground truth image stage for the size info instead,
  616. // we are likely "cheating" here because all stages share the same size
  617. // in the current design...
  618. var gt_stage_size = destStage.size();
  619. var pxSizeNm = Utils.getPixelSizeNmInput();
  620. var fullImgSize = {
  621. w: imageObj.naturalWidth * pxSizeNm,
  622. h: imageObj.naturalHeight * pxSizeNm,
  623. };
  624. // similar formula to the used for the subregion rect in the groundtruth view
  625. var subregionSizeNm = {
  626. w: (gt_stage_size.width / rect.w) * fullImgSize.w,
  627. h: (gt_stage_size.height / rect.h) * fullImgSize.h,
  628. };
  629. // get optimal / formated unit
  630. // TODO: maybe use "this." instead of "Utils."
  631. // do it for all functions too?
  632. var fmtPxSize = Utils.formatUnitNm(
  633. subregionSizeNm.w / cols,
  634. subregionSizeNm.h / rows
  635. );
  636. // display coords & FOV size
  637. Utils.updateExtraInfo(destStage,
  638. fmtPxSize.value.toFixed(G_MATH_TOFIXED.SHORT) + ' x '
  639. + fmtPxSize.value2.toFixed(G_MATH_TOFIXED.SHORT)
  640. + ' ' + fmtPxSize.unit + '/px'
  641. );
  642. },
  643. /**
  644. * Displays and updates additional info on the given stage
  645. * @param {*} destStage The stage to display info on.
  646. * @param {string} infoText Text to display.
  647. */
  648. updateExtraInfo: function(destStage, infoText) {
  649. const infoclass = "extraInfoDisplay";
  650. var element = this.ensureInfoBox(destStage, infoclass);
  651. if (element) {
  652. // display it
  653. element.innerHTML = infoText;
  654. }
  655. },
  656. /**
  657. * Conditionally displays a small warning icon if it meets the G_AUTO_PREVIEW_LIMIT.
  658. * This icon can be hovered or double-clicked to obtain
  659. * a message explaining the drawing is done row-by-row instead of frame-by-frame
  660. * for performance / responsiveness.
  661. * @param {*} stage The stage to display it on.
  662. * @todo Currently, only used for the Subregion resampled stage, could be used elsewhere?
  663. */
  664. updateVSEM_ModeWarning: function(stage) {
  665. // add Row-by-row / vSEM mode warning
  666. var vSEM_note = $(stage.getContainer()).find('.vsem_mode').first();
  667. var alreadyAdded = vSEM_note.length > 0;
  668. // check if we should create it and add the UI element
  669. if (!alreadyAdded) {
  670. vSEM_note = Utils.ensureInfoBox(stage, 'vsem_mode',
  671. function(){
  672. alert($(this).attr('title'));
  673. }
  674. );
  675. if (vSEM_note) {
  676. // warn icon from GitHub's octicons (https://github.com/primer/octicons)
  677. // eslint-disable-next-line max-len
  678. const warnIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#FFFF00" d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>';
  679. vSEM_note.innerHTML = warnIcon;
  680. vSEM_note.title = "For higher pixel counts, the drawing is done "
  681. + "row-by-row instead of frame-by-frame "
  682. + "for improved performance / responsiveness.";
  683. $(vSEM_note).hide();
  684. }
  685. }
  686. // check if we should show/hide it
  687. var rows = Utils.getRowsInput(), cols = Utils.getColsInput();
  688. var showWarnVSEM = (rows*cols > G_AUTO_PREVIEW_LIMIT);
  689. $(vSEM_note).toggle(showWarnVSEM);
  690. },
  691. /**
  692. * Updates the displayed element to be shown/hidden according
  693. * to the advanced mode setting. Affects all HTML elements with
  694. * the class "advancedMode".
  695. */
  696. updateAdvancedMode: function(){
  697. var isAdvModeON = false;
  698. if (typeof G_GUI_Controller !== 'undefined' || G_GUI_Controller !== null){
  699. isAdvModeON = G_GUI_Controller.advancedMode;
  700. }
  701. $('.advancedMode').toggle(isAdvModeON);
  702. },
  703. /**
  704. * Gets or creates an info-box element on the given stage.
  705. * @param {*} stage The stage on which display/have the info-box.
  706. * @param {string} className The class name of the info-box DOM element.
  707. * @param {function} [onDblClick] bound on creation, the event handler / callback for on-doubleclick event
  708. * @param {string} [title] Optional title / tooltip text.
  709. * @returns the info-box DOM element.
  710. */
  711. ensureInfoBox: function(stage, className, onDblClick, title) {
  712. // get stage container
  713. var eStage = $(stage.getContainer());
  714. // check if we create the element already
  715. var e = eStage.children('.'+className+':first');
  716. if (e.length <= 0) {
  717. // not found, so create it
  718. eStage.prepend('<span class="infoBox '+className+'"></span>');
  719. e = eStage.children('.'+className+':first');
  720. if (typeof title == 'string') {
  721. e.attr('title', title);
  722. }
  723. }
  724. // return the non-jquery-wrapped DOM element
  725. var element = e.get(0);
  726. // but return false if not found or unsuccessful
  727. if (typeof element == 'undefined')
  728. return false;
  729. // attach the dbclick event handler if one was given
  730. // we bind everytime instead of only on-creation to prevent
  731. // any issues with stale references in the given handler function.
  732. if (typeof onDblClick == 'function') {
  733. // ensure we remove all previous dblclick handlers so dont end up
  734. // with multiple instances of the handler being triggered...
  735. e.unbind('dblclick').on('dblclick', onDblClick);
  736. }
  737. return element;
  738. },
  739. /**
  740. * Calculates a new size (width and height) for the given object to fit in a stage's view bounds.
  741. * @param {number} w the original width
  742. * @param {number} h the original height
  743. * @param {number} maxDimension The largest dimension (whether width or height) to fit in.
  744. * @param {boolean} fillMode Whether to do a "fill", "fit" / "letterbox", "crop", or "squish" fit.
  745. * @returns the new calculated size
  746. * @todo fillMode is not yet fully supported, see https://github.com/joedf/ImgBeamer/issues/7
  747. */
  748. fitImageProportions: function(w, h, maxDimension, fillMode="squish"){
  749. var mode = fillMode.toLowerCase().trim();
  750. // image ratio to "fit" in canvas
  751. var ratio = (w > h ? (w / maxDimension) : (h / maxDimension)); // fit
  752. if (mode == "fill"){
  753. ratio = (w > h ? (h / maxDimension) : (w / maxDimension)); // fill
  754. }
  755. if (mode == "squish") {
  756. return {w: maxDimension, h: maxDimension};
  757. }
  758. var iw = w/ratio; //, ih = h/ratio;
  759. return {w: iw, h: iw};
  760. },
  761. /**
  762. * Scales the given shape, and moves it to preserve original center
  763. * @todo maybe, no need to have oldScale specified, can be obtained from shape.scale() ...
  764. * @todo possibly simplify this like {@link Utils.centeredScale} and remove the other?
  765. * @param {*} stage The stage of the shape object
  766. * @param {*} shape the shape object itself
  767. * @param {*} oldScale the shapes old scale
  768. * @param {*} newScale the new scale
  769. */
  770. scaleOnCenter: function(stage, shape, oldScale, newScale){
  771. var stageCenter = {
  772. x: stage.width()/2 - stage.x(),
  773. y: stage.height()/2 - stage.y()
  774. };
  775. this.scaleCenteredOnPoint(stageCenter, shape, oldScale, newScale);
  776. },
  777. /**
  778. * shorthand for @see {@link Utils.scaleOnCenter}.
  779. * Gets the stage and original scale from the shape directly.
  780. * @param {*} shape the shape to scale
  781. * @param {*} newScale the new scale.
  782. */
  783. centeredScale: function(shape, newScale){
  784. this.scaleOnCenter(shape.getStage(), shape, shape.scaleX(), newScale);
  785. },
  786. /**
  787. * Scales the given shaped while keeping it centered on the given point.
  788. * @param {*} point the centering point.
  789. * @param {*} shape the shape to scale and position.
  790. * @param {*} oldScale the shape's original scale
  791. * @param {*} newScale the shape's new scale
  792. */
  793. scaleCenteredOnPoint: function(point, shape, oldScale, newScale){
  794. // could be expanded to do both x and y scaling
  795. shape.scale({x: newScale, y: newScale});
  796. var oldPos = {
  797. x: (point.x - shape.x()) / oldScale,
  798. y: (point.y - shape.y()) / oldScale,
  799. };
  800. shape.position({
  801. x: point.x - oldPos.x * newScale,
  802. y: point.y - oldPos.y * newScale,
  803. });
  804. },
  805. /**
  806. * Computes the average pixel value assuming an RGBA format, with a max of 255 for each component.
  807. * @param {ImageData} raw The image data (access to pixel data).
  808. * @returns an array of the average pixel value [R,G,B,A].
  809. */
  810. get_avg_pixel_rgba: function(raw) {
  811. var blanks = 0;
  812. var d = raw.data;
  813. var sum = [0, 0, 0, 0];
  814. for (var i = 0; i < d.length; i += 4) {
  815. // Optimization note, with greyscale we only need to process one component...
  816. const px = [d[i], d[i+1], d[i+2], d[1+3]];
  817. // var r = px[0], g = px[1], b = px[2], a = px[3];
  818. if (px.every(c => c === 0)) {
  819. blanks += 1;
  820. } else {
  821. sum[0] += px[0];
  822. sum[1] += px[1];
  823. sum[2] += px[2];
  824. sum[3] += px[3];
  825. }
  826. }
  827. var total = raw.width * raw.height;
  828. // or eq... var total = d.length / 4;
  829. var fills = Math.max(1, total - blanks);
  830. var avg = [
  831. Math.round(sum[0] / fills),
  832. Math.round(sum[1] / fills),
  833. Math.round(sum[2] / fills),
  834. (255 - Math.round(sum[3] / fills)) / 255 // rgba - alpha is 0.0 to 1.0
  835. ];
  836. var percent = (blanks / total) * 100;
  837. if (G_DEBUG) {
  838. console.log(blanks, total, percent);
  839. console.log("avg px=", avg.toString());
  840. }
  841. return avg;
  842. },
  843. /**
  844. * Computes the average grayscale pixel value assuming an RGBA format, with a max of 255 for each component.
  845. * However, only the R (red) component is considered.
  846. * @param {ImageData} raw The image data (access to pixel data).
  847. * @returns a number representing the average pixel value intensity (0 to 255).
  848. */
  849. get_avg_pixel_gs: function(raw) {
  850. var blanks = 0;
  851. var d = raw.data;
  852. var sum = 0;
  853. // Optimization note, with greyscale we only need to process one component...
  854. for (var i = 0; i < d.length; i += 4) {
  855. const px = d[i];
  856. const alpha = d[i+3];
  857. if (px === 0 && alpha === 0) {
  858. blanks += 1;
  859. } else {
  860. sum += px;
  861. }
  862. }
  863. var total = raw.width * raw.height;
  864. var fills = Math.max(1, total - blanks);
  865. var avg = Math.round(sum / fills);
  866. return avg;
  867. },
  868. /**
  869. * Draws a grid on a given drawing stage.
  870. * Based on solution-1 from: https://longviewcoder.com/2021/12/08/konva-a-better-grid
  871. * @param {*} gridLayer a layer on the stage to use for drawing the grid on.
  872. * @param {*} rect a rectangle represing the size and position of the grid to draw.
  873. * @param {number} rows the number of rows in the grid.
  874. * @param {number} cols the number of columns in the grid.
  875. * @param {*} lineColor the line color of the grid.
  876. * @returns the cell size (width, height)
  877. */
  878. drawGrid: function(gridLayer, rect, rows, cols, lineColor) {
  879. if (typeof lineColor == 'undefined' || lineColor == null || lineColor.length < 1)
  880. lineColor = 'rgba(255, 255, 255, 0.8)';
  881. var startX = rect.x();
  882. var startY = rect.y();
  883. var stepSizeX = rect.width() / cols;
  884. var stepSizeY = rect.height() / rows;
  885. const xSize = gridLayer.width(), // stage.width(),
  886. ySize = gridLayer.height(), // stage.height(),
  887. xSteps = cols, //Math.round(xSize/ stepSizeX),
  888. ySteps = rows; //Math.round(ySize / stepSizeY);
  889. // draw vertical lines
  890. for (let i = 0; i <= xSteps; i++) {
  891. gridLayer.add(
  892. new Konva.Line({
  893. x: startX + (i * stepSizeX),
  894. points: [0, 0, 0, ySize],
  895. stroke: lineColor,
  896. strokeWidth: 1,
  897. })
  898. );
  899. }
  900. //draw Horizontal lines
  901. for (let i = 0; i <= ySteps; i++) {
  902. gridLayer.add(
  903. new Konva.Line({
  904. y: startY + (i * stepSizeY),
  905. points: [0, 0, xSize, 0],
  906. stroke: lineColor,
  907. strokeWidth: 1,
  908. })
  909. );
  910. }
  911. gridLayer.batchDraw();
  912. var cellInfo = {
  913. width: stepSizeX,
  914. height: stepSizeY,
  915. };
  916. return cellInfo;
  917. },
  918. /**
  919. * Draws a given shape repeatedly (clones) in a grid pattern.
  920. * The shape will be drawn {@link rows} by {@link cols} times.
  921. * Originally based from drawGrid() ...
  922. * @param {*} layer The layer to draw on
  923. * @param {*} rect The bounds of the grid pattern
  924. * @param {*} shape The shape to draw
  925. * @param {number} rows the number of rows for the grid
  926. * @param {number} cols the number of columns for the grid
  927. */
  928. repeatDrawOnGrid: function(layer, rect, shape, rows, cols) {
  929. var startX = rect.x();
  930. var startY = rect.y();
  931. var stepSizeX = rect.width() / cols;
  932. var stepSizeY = rect.height() / rows;
  933. var cellCenterX = stepSizeX / 2;
  934. var cellCenterY = stepSizeY / 2;
  935. // interate over X
  936. for (let i = 0; i < cols; i++) {
  937. // interate over Y
  938. for (let j = 0; j < rows; j++) {
  939. var shapeCopy = shape.clone();
  940. shapeCopy.x(startX + (i * stepSizeX) + cellCenterX);
  941. shapeCopy.y(startY + (j * stepSizeY) + cellCenterY);
  942. layer.add( shapeCopy );
  943. }
  944. }
  945. layer.batchDraw();
  946. },
  947. /**
  948. * Draws a "stenciled" version of the given image and probe/shape based the grid parameters.
  949. * "Stenciles" on a grid with an array of "cloned" spots.
  950. * @param {*} previewStage The stage to draw on.
  951. * @param {*} image The image to draw and "stencil".
  952. * @param {*} probe The shape to stencil image with repeatedly in a grid pattern
  953. * @param {number} rows The number of rows for the grid
  954. * @param {number} cols The number of columns for the grid
  955. * @param {*} rect The bounds for the grid
  956. * @todo Likely remove it, deprecated and no longer used by anything...
  957. */
  958. computeResampledPreview: function(previewStage, image, probe, rows, cols, rect){
  959. var previewLayer = previewStage.getLayers()[0];
  960. previewLayer.destroyChildren();
  961. var gr = image.clone();
  962. gr.globalCompositeOperation('source-in');
  963. this.repeatDrawOnGrid(previewLayer, rect, probe, rows, cols);
  964. previewLayer.add(gr);
  965. },
  966. /**
  967. * Draws a resampled image with the given spot/probe.
  968. * Samples on a grid with an array of "cloned" spots.
  969. * The sampling grid fits the full size of the destination stage.
  970. * @deprecated This has been replaced by {@link Utils.computeResampledSlow} due to accuracy concerns.
  971. * @todo review this function, maybe remove or improve.
  972. * @param {*} sourceStage The stage for the original image to pixel data from.
  973. * @param {*} destStage The destination stage to draw on.
  974. * @param {*} image The image size and position.
  975. * @param {*} probe The sampling shape or probe
  976. * @param {number} rows The number of rows for the sampling grid
  977. * @param {number} cols The number of columns for the sampling grid
  978. */
  979. computeResampledFast: function(sourceStage, destStage, image, probe, rows, cols){
  980. var destLayer = destStage.getLayers()[0];
  981. destLayer.destroyChildren();
  982. var layer = sourceStage.getLayers()[0];
  983. // layer.cache();
  984. var ctx = layer.getContext();
  985. var lRatio = ctx.getCanvas().pixelRatio;
  986. // process each grid cell
  987. var startX = image.x(), startY = image.y();
  988. var stepSizeX = image.width() / cols, stepSizeY = image.height() / rows;
  989. // interate over X
  990. for (let i = 0; i < cols; i++) {
  991. // interate over Y
  992. for (let j = 0; j < rows; j++) {
  993. var cellX = startX + (i * stepSizeX);
  994. var cellY = startY + (j * stepSizeY);
  995. var cellW = stepSizeX;
  996. var cellH = stepSizeY;
  997. var pxData = ctx.getImageData(
  998. cellX * lRatio,
  999. cellY * lRatio,
  1000. cellW * lRatio,
  1001. cellH * lRatio
  1002. );
  1003. if (!G_DEBUG) {
  1004. // var avgPx = this.get_avg_pixel_rgba(pxData);
  1005. // var avgColor = "rgba("+ avgPx.join(',') +")";
  1006. var avg = Utils.get_avg_pixel_gs(pxData);
  1007. var avgColor = "rgba("+[avg,avg,avg,1].join(',')+")";
  1008. var cPixel = new Konva.Rect({
  1009. listening: false,
  1010. x: cellX,
  1011. y: cellY,
  1012. width: cellW,
  1013. height: cellH,
  1014. fill: avgColor,
  1015. });
  1016. destLayer.add(cPixel);
  1017. } else {
  1018. const canvas = document.createElement('canvas');
  1019. canvas.width = cellW * lRatio;
  1020. canvas.height = cellH * lRatio;
  1021. canvas.getContext('2d').putImageData(pxData, 0, 0);
  1022. const cPixel = new Konva.Image({
  1023. image: canvas,
  1024. listening: false,
  1025. x: cellX,
  1026. y: cellY,
  1027. width: cellW,
  1028. height: cellH,
  1029. });
  1030. destLayer.add(cPixel);
  1031. }
  1032. }
  1033. }
  1034. if (G_DEBUG) {
  1035. this.drawGrid(destLayer, image, rows, cols);
  1036. }
  1037. },
  1038. /**
  1039. * Essentially, this is {@link Utils.computeResampledFast}, but corrected for spot size larger than the cell size.
  1040. * Samples on a grid with an array of "cloned" spots.
  1041. * {@link Utils.computeResampledFast} limits the sampling to the cell size, and takes in smaller version of the
  1042. * image that is already drawn and "compositied" in a Konva Stage, instead of the original larger image...
  1043. * @param {*} sourceStage The stage to get the subregion area
  1044. * @param {*} destStage The stage to draw on
  1045. * @param {*} oImage The ground truth/source/original image to get data from.
  1046. * @param {*} probe The spot/probe to sample with
  1047. * @param {number} rows The number of rows for the sampling grid
  1048. * @param {number} cols The number of columns for the sampling grid
  1049. * @param {number} rect The bounds of the sampling grid
  1050. * @param {number} rowStart The row to start iterating over.
  1051. * @param {number} rowEnd The row at which to stop iterating over.
  1052. * @param {number} colStart The column to start iterating over.
  1053. * @param {number} colEnd The column at which to stop iterating over.
  1054. * @param {boolean} doClear Whether the layer should be cleared before drawing.
  1055. * @param {boolean} useLastLayer Whether to use the last (true) or first (false) layer to draw on.
  1056. */
  1057. computeResampledSlow: function(sourceStage, destStage, oImage, probe, rows, cols, rect,
  1058. rowStart = 0, rowEnd = -1, colStart = 0, colEnd = -1, doClear = true, useLastLayer = false){
  1059. var layers = destStage.getLayers();
  1060. var destLayer = layers[0];
  1061. if (useLastLayer) { destLayer = layers[layers.length-1]; }
  1062. if (doClear) { destLayer.destroyChildren(); }
  1063. var pImage = oImage.image(),
  1064. canvas = document.createElement('canvas'),
  1065. // canvas = document.getElementById('testdemo'),
  1066. ctx = canvas.getContext('2d');
  1067. // get and transform cropped region based on user-sized konva-image for resampling
  1068. var sx = (sourceStage.x() - oImage.x()) / oImage.scaleX();
  1069. var sy = (sourceStage.y() - oImage.y()) / oImage.scaleY();
  1070. var sw = sourceStage.width() / oImage.scaleX();
  1071. var sh = sourceStage.height() / oImage.scaleY();
  1072. canvas.width = destStage.width();
  1073. canvas.height = destStage.height();
  1074. var rw = (oImage.width() / pImage.naturalWidth);
  1075. var rh = (oImage.height() / pImage.naturalHeight);
  1076. ctx.drawImage(pImage,
  1077. sx / rw, sy /rh, // crop x, y
  1078. sw / rw, sh /rh, // crop width, height
  1079. 0, 0,
  1080. destStage.width(),
  1081. destStage.height()
  1082. );
  1083. var image = canvas; //oImage.image();
  1084. /*
  1085. var sx = (oImage.x() - sourceStage.x());// * oImage.scaleX();
  1086. var sy = (oImage.y() - sourceStage.y());// * oImage.scaleY();
  1087. var sw = sourceStage.width() * oImage.scaleX();
  1088. var sh = sourceStage.height() * oImage.scaleY();
  1089. destLayer.add(new Konva.Image({
  1090. x: sx,
  1091. y: sy,
  1092. width: sw,
  1093. height: sh,
  1094. image: pImage,
  1095. }));
  1096. return;
  1097. */
  1098. // process each grid cell
  1099. var startX = 0, startY = 0;
  1100. // var stepSizeX = image.naturalWidth / cols, stepSizeY = image.naturalHeight / rows;
  1101. var stepSizeX = destStage.width() / cols, stepSizeY = destStage.height() / rows;
  1102. var startX_stage = rect.x(), startY_stage = rect.y();
  1103. var stepSizeX_stage = rect.width() / cols, stepSizeY_stage = rect.height() / rows;
  1104. if (colEnd < 0) { colEnd = cols; }
  1105. if (rowEnd < 0) { rowEnd = rows; }
  1106. // interate over X
  1107. for (let i = colStart; i < colEnd; i++) {
  1108. // interate over Y
  1109. for (let j = rowStart; j < rowEnd; j++) {
  1110. var cellX = startX + (i * stepSizeX);
  1111. var cellY = startY + (j * stepSizeY);
  1112. var cellW = stepSizeX;
  1113. var cellH = stepSizeY;
  1114. probe.x(cellX + cellW/2);
  1115. probe.y(cellY + cellH/2);
  1116. var avg = this.ComputeProbeValue_gs(image, probe);
  1117. var avgColor = "rgba("+[avg,avg,avg,1].join(',')+")";
  1118. // Konva drawing
  1119. var cellX_stage = startX_stage + (i * stepSizeX_stage);
  1120. var cellY_stage = startY_stage + (j * stepSizeY_stage);
  1121. var cellW_stage = stepSizeX_stage;
  1122. var cellH_stage = stepSizeY_stage;
  1123. var cPixel = new Konva.Rect({
  1124. listening: false,
  1125. x: cellX_stage,
  1126. y: cellY_stage,
  1127. width: cellW_stage,
  1128. height: cellH_stage,
  1129. fill: avgColor,
  1130. });
  1131. destLayer.add(cPixel);
  1132. }
  1133. }
  1134. },
  1135. /**
  1136. * Converts an angle in radians to degrees
  1137. * @param {number} angle the angle in radians.
  1138. * @returns the angle in degrees
  1139. */
  1140. toDegrees: function(angle) { return angle * (180 / Math.PI); },
  1141. /**
  1142. * Converts an angle in degrees to radians
  1143. * @param {number} angle the angle in degrees.
  1144. * @returns the angle in radians
  1145. */
  1146. toRadians: function(angle) { return angle * (Math.PI / 180); },
  1147. /**
  1148. * Calculates the euclidean distance.
  1149. * @param {*} x1
  1150. * @param {*} y1
  1151. * @param {*} x2
  1152. * @param {*} y2
  1153. * @returns the distance in the given coordinates' units.
  1154. */
  1155. distance: function(x1, y1, x2, y2) {
  1156. // https://stackoverflow.com/a/33743107/883015
  1157. var dist = Math.hypot(x2-x1, y2-y1);
  1158. return dist;
  1159. },
  1160. /**
  1161. * Gets the image scaling based on the Konva.Image size vs the image's true or 'natural' size.
  1162. * @param {*} konvaImage The konva image object
  1163. * @param {*} imageObj The actual image's HTML/DOM object
  1164. * @returns an object with the calculated values as properties 'x' and 'y'.
  1165. */
  1166. imagePixelScaling: function(konvaImage, imageObj) {
  1167. return {
  1168. x: (konvaImage.width() / imageObj.naturalWidth),
  1169. y: (konvaImage.height() / imageObj.naturalHeight),
  1170. };
  1171. },
  1172. /**
  1173. * Convert stage to unit square coordinates
  1174. * @param {*} x
  1175. * @param {*} y
  1176. * @param {*} stage coordinates source stage
  1177. * @returns unit square coordinates
  1178. */
  1179. stageToUnitCoordinates: function(x, y, stage){
  1180. var centered = {
  1181. x: x - (stage.width() / 2),
  1182. y: y - (stage.height() / 2),
  1183. };
  1184. var unit = {
  1185. x: centered.x / stage.width(),
  1186. y: centered.y / stage.height(),
  1187. };
  1188. return unit;
  1189. },
  1190. /**
  1191. * Convert unit square to image pixel coordinates.
  1192. * @param {*} x
  1193. * @param {*} y
  1194. * @param {*} imageObj the original image object (with a width and height property)
  1195. * @returns image pixel coordinates
  1196. */
  1197. unitToImagePixelCoordinates: function(x, y, imageObj) {
  1198. return {
  1199. x: x * imageObj.naturalWidth,
  1200. y: y * imageObj.naturalHeight,
  1201. };
  1202. },
  1203. /**
  1204. * Convert stage to Image pixel coordinates
  1205. * @param {*} x
  1206. * @param {*} y
  1207. * @param {*} stage coordinates source stage
  1208. * @param {*} imageObj the original image object (with a width and height property)
  1209. * @returns image pixel coordinates
  1210. */
  1211. stageToImagePixelCoordinates: function(x, y, stage, imageObj) {
  1212. var unit = this.stageToUnitCoordinates(x, y, stage);
  1213. var ipixel = this.unitToImagePixelCoordinates(unit.x, unit.y, imageObj);
  1214. return ipixel;
  1215. },
  1216. /**
  1217. * Convert image pixel to coordinates in "real" (or scaled) units
  1218. * @param {*} x
  1219. * @param {*} y
  1220. * @param {*} pxSizeX the width of a pixel in "real" units
  1221. * @param {*} pxSizeY the height of a pixel in "real" units
  1222. * @returns "real" coordinates
  1223. */
  1224. imagePixelToRealCoordinates: function(x, y, pxSizeX, pxSizeY = null) {
  1225. if (pxSizeY == null) { pxSizeY = pxSizeX; }
  1226. return {
  1227. x: x * pxSizeX,
  1228. y: y * pxSizeY,
  1229. };
  1230. },
  1231. /**
  1232. * Formats the values given to the appropriate display unit (nm or μm).
  1233. * @param {*} value_in_nm a value in nm.
  1234. * @param {*} value2_in_nm (optional) a value in nm.
  1235. * @returns an object containing the adjusted values and selected unit.
  1236. */
  1237. formatUnitNm: function(value_in_nm, value2_in_nm = 0){
  1238. /* eslint-disable no-magic-numbers */
  1239. var out = {
  1240. value: value_in_nm,
  1241. value2: value2_in_nm,
  1242. unit: "nm",
  1243. };
  1244. if (Math.abs(out.value) > 1000 || Math.abs(out.value2) > 1000) {
  1245. out.value /= 1000;
  1246. out.value2 /= 1000;
  1247. out.unit = "μm";
  1248. }
  1249. /* eslint-enable no-magic-numbers */
  1250. return out;
  1251. },
  1252. /**
  1253. * Generate a filename with a timestamp and the given prefix and counter.
  1254. * @param {string} prefix the filename prefix
  1255. * @param {number} counter a counter that has been incremented elsewhere
  1256. * @param {string} [fileExt="png"] the file extension
  1257. * @returns the filename.
  1258. */
  1259. GetSuggestedFileName: function(prefix, counter, fileExt = "png"){
  1260. const ISODateEnd = 10;
  1261. var datestamp = new Date().toISOString().slice(0, ISODateEnd).replaceAll('-','.');
  1262. var sCounter = String(counter).padStart(3,'0');
  1263. var filename = prefix+"-"+datestamp+"-"+sCounter+"."+fileExt;
  1264. return filename;
  1265. },
  1266. /** Used internally, for @see {@link Utils.ComputeProbeValue_gs} */
  1267. _COMPUTE_GS_CANVAS: null,
  1268. /**
  1269. * Gets the average pixel value (grayscale intensity) with the given image and one probe.
  1270. * @param {*} image the image to get pixel data from
  1271. * @param {*} probe the sampling shape/spot.
  1272. * @param {number} superScale factor to scale up ("blow-up") the image for the sampling.
  1273. * @returns the computed grayscale color
  1274. */
  1275. ComputeProbeValue_gs: function(image, probe, superScale=1) {
  1276. // var iw = image.naturalWidth, ih = image.naturalHeight;
  1277. // get ellipse info
  1278. var ellipseInfo = probe;
  1279. if (typeof probe.getStage == 'function') { // if Konva Ellipse
  1280. ellipseInfo = {
  1281. centerX: probe.x(),
  1282. centerY: probe.y(),
  1283. rotationRad: this.toRadians(probe.rotation()),
  1284. radiusX: probe.radiusX(),
  1285. radiusY: probe.radiusY()
  1286. };
  1287. }
  1288. // optimization is to reduce search area to max bounds possible of the ellipse
  1289. var maxRadius = Math.max(ellipseInfo.radiusX, ellipseInfo.radiusY);
  1290. var maxDiameter = 2 * maxRadius;
  1291. var cvSize = maxDiameter * superScale;
  1292. // create an offscreen canvas, if not already done
  1293. if (this._COMPUTE_GS_CANVAS == null) {
  1294. this._COMPUTE_GS_CANVAS = new OffscreenCanvas(cvSize, cvSize);
  1295. }
  1296. var cv = this._COMPUTE_GS_CANVAS;
  1297. // var cv = document.createElement('canvas');
  1298. // if (G_DEBUG) {
  1299. // document.body.appendChild(cv);
  1300. // }
  1301. cv.width = cvSize;
  1302. cv.height = cvSize;
  1303. if (cv.width == 0 || cv.height == 0)
  1304. return 0;
  1305. var ctx = cv.getContext('2d');
  1306. ctx.imageSmoothingEnabled = false;
  1307. // since we are reusing the offscreen canvas, we should clear it each time
  1308. // to prevent getting artifacts from previous draws
  1309. ctx.clearRect(0, 0, cv.width, cv.height);
  1310. // draw the image
  1311. // ctx.drawImage(image, 0, 0);
  1312. // optimization, to draw only the necessary area of the image
  1313. ctx.drawImage(image,
  1314. ellipseInfo.centerX - maxRadius,
  1315. ellipseInfo.centerY - maxRadius,
  1316. maxDiameter,
  1317. maxDiameter,
  1318. 0,
  1319. 0,
  1320. cv.width,
  1321. cv.height);
  1322. // then, set the composite operation
  1323. ctx.globalCompositeOperation = 'destination-in'; //TODO: Is this right? or 'source-in'?
  1324. // then draw the pixel selection shape
  1325. ctx.beginPath();
  1326. ctx.ellipse(
  1327. maxDiameter / 2 * superScale,
  1328. maxDiameter / 2 * superScale,
  1329. ellipseInfo.radiusX * superScale,
  1330. ellipseInfo.radiusY * superScale,
  1331. ellipseInfo.rotationRad,
  1332. 0, 2 * Math.PI);
  1333. ctx.fillStyle = 'white';
  1334. ctx.fill();
  1335. // grab the pixel data from the pixel selection area
  1336. var pxData = ctx.getImageData(0,0,cv.width,cv.height);
  1337. // hack to directly use Konva's built-in filters code
  1338. if (typeof Konva != 'undefined') {
  1339. var brightnessFunc = Konva.Filters.Brighten.bind({
  1340. brightness: () => this.getBrightnessInput()});
  1341. var contrastFunc = Konva.Filters.Contrast.bind({
  1342. contrast: () => this.getContrastInput()});
  1343. // apply it directly to out image data before we sample it.
  1344. brightnessFunc(pxData);
  1345. contrastFunc(pxData);
  1346. }
  1347. // compute the average pixel (excluding 0-0-0-0 rgba pixels)
  1348. var pxColor = this.get_avg_pixel_gs(pxData);
  1349. // delete the canvas
  1350. //document.body.removeChild(cv);
  1351. ctx = null;
  1352. cv = null;
  1353. return pxColor;
  1354. },
  1355. /**
  1356. * Applies Brightness/Contrast (B/C) values to a given Konva stage or drawable.
  1357. * @param {*} drawable The Konva stage or drawable / drawElement.
  1358. * @param {*} brightness The brightness value, from -1 to 1.
  1359. * @param {*} contrast The contrast value, mainly from -100 to 100.
  1360. */
  1361. applyBrightnessContrast: function(drawable, brightness=0, contrast=0) {
  1362. // cache step is need for filter effects to be visible.
  1363. // https://konvajs.org/docs/performance/Shape_Caching.html
  1364. drawable.cache();
  1365. // Filters: https://konvajs.org/api/Konva.Filters.html
  1366. // Brightness => https://konvajs.org/docs/filters/Brighten.html
  1367. // Contrast => https://konvajs.org/docs/filters/Contrast.html
  1368. var currentFilters = drawable.filters();
  1369. // null check, default to empty array if n/a.
  1370. currentFilters = currentFilters != null ? currentFilters : [];
  1371. // Add filter if not already included...
  1372. var currentFiltersByName = currentFilters.map(x => x.name);
  1373. var filtersToSet = currentFilters;
  1374. var added = 0;
  1375. ['Brighten', 'Contrast'].forEach(filterName => {
  1376. if (!currentFiltersByName.includes(filterName)) {
  1377. filtersToSet.push(Konva.Filters[filterName]);
  1378. added++;
  1379. }
  1380. });
  1381. drawable.filters(filtersToSet);
  1382. if (G_DEBUG) {
  1383. console.log("filters added:", added);
  1384. }
  1385. // apply B/C filter values
  1386. drawable.brightness(brightness);
  1387. drawable.contrast(contrast);
  1388. },
  1389. /**
  1390. * Sets the image Smoothing option for a given stage.
  1391. * Currently, this only affects the "base" layer.
  1392. * For now it doesn't make sense to remove smoothing from overlay layers
  1393. * which are currently used for annotations and such.
  1394. * @param {*} stage The stage
  1395. * @param {*} enabled True for enabled, false for disabled
  1396. */
  1397. setStageImageSmoothing: function(stage, enabled=true){
  1398. var layers = stage.getLayers();
  1399. // if we need / want this for all layers, we could loop over all layers
  1400. if (layers.length > 0) {
  1401. var baseLayer = layers[0];
  1402. baseLayer.imageSmoothingEnabled(enabled);
  1403. // 2024.02.05: imageSmoothingQuality is not yet fully supported...
  1404. // baseLayer.imageSmoothingQuality = 'low';
  1405. }
  1406. },
  1407. /**
  1408. * Find and gets the first "image" type from the first layer of the given Konva stage
  1409. * @param {*} stage the stage to search through
  1410. * @returns the first image object
  1411. */
  1412. getFirstImageFromStage: function(stage){
  1413. var image = stage.getLayers()[0].getChildren(function(x){
  1414. return x.getClassName() == 'Image';
  1415. })[0];
  1416. return image;
  1417. },
  1418. /**
  1419. * Creates a new Konva.Rect object based on the position and size of a given konva object.
  1420. * @param {*} kObject A konva object that has a size and postion, such as a stage, image, or rect.
  1421. * @returns a rectable object with x, y, width, and height functions.
  1422. */
  1423. getRectFromKonvaObject: function(kObject){
  1424. return new Konva.Rect({
  1425. x: kObject.x(),
  1426. y: kObject.y(),
  1427. width: kObject.width(),
  1428. height: kObject.height(),
  1429. });
  1430. },
  1431. /**
  1432. * Finds (non-recusrive) the first layer with a matching on a given stage, null if n/a.
  1433. * @param {*} stage The stage or element with layers.
  1434. * @param {*} layerName The name of the layer.
  1435. */
  1436. getLayerByName: function(stage, layerName){
  1437. var layers = stage.getLayers();
  1438. for (let i = 0; i < layers.length; i++) {
  1439. const layer = layers[i];
  1440. const name = layer.name();
  1441. if (name == layerName) {
  1442. return layer;
  1443. }
  1444. }
  1445. return null;
  1446. },
  1447. /**
  1448. * get the image without the row/draw indicator
  1449. * @param {*} stage the stage to search through
  1450. * @returns the image object
  1451. */
  1452. getVirtualSEM_KonvaImage: function(stage){
  1453. // should be the only "Image" child on the first layer...
  1454. return this.getFirstImageFromStage(stage);
  1455. },
  1456. /**
  1457. * get the imageData (pixels) from a given konva object/image
  1458. * @param {*} konvaObject the shape/image/object to get image data from
  1459. * @param {number} pixelRatio the pixel ratio to scale (larger means ~higher resolution)
  1460. * @param {boolean} imageSmoothing Set to true to use image smoothing
  1461. * @returns The image data
  1462. */
  1463. getKonvaImageData: function(konvaObject, pixelRatio=2, imageSmoothing=true) {
  1464. // TODO: maybe we get higher DPI / density images?
  1465. var cnv = konvaObject.toCanvas({"pixelRatio": pixelRatio, "imageSmoothingEnabled": imageSmoothing});
  1466. var ctx = cnv.getContext('2d');
  1467. var data = ctx.getImageData(0, 0, cnv.width, cnv.height);
  1468. return data;
  1469. },
  1470. /** Displays a message/dialog box with information about this application. */
  1471. ShowAboutMessage: function(){
  1472. /* eslint-disable max-len */
  1473. const id = 'dialog-about';
  1474. var about = $('#'+id);
  1475. if (about.length) {
  1476. about.dialog('open');
  1477. } else {
  1478. var elem = $("<div/>")
  1479. .attr({
  1480. 'id': id,
  1481. 'title': "About " + G_APP_NAME
  1482. })
  1483. .css({'display':'none'})
  1484. .addClass('jui')
  1485. // fix jquery-ui auto-focus bug: https://stackoverflow.com/a/14748517/883015
  1486. .html(`
  1487. <div>
  1488. <input type="hidden" autofocus="autofocus" />
  1489. <div style="float: left;margin: 0 4px;"><img src="src/img/icon128.png" width="48"></div>
  1490. <p><b>` +G_APP_NAME+ `</b> was created as an easy-to-use tool to understand the effects of
  1491. the spot size to pixel size ratio on image clarity and resolution
  1492. in the SEM image formation / rasterization process.</p>
  1493. <p><b>Application Development</b></p>
  1494. <ul>
  1495. <li>Main developer: Joachim de Fourestier</li>
  1496. <li>Original concept: Michael W. Phaneuf</li>
  1497. </ul>
  1498. <details open>
  1499. <summary><b>Source Code and Documentation</b></summary>
  1500. <ul>
  1501. <li>Source code: <a href="https://github.com/joedf/ImgBeamer">https://github.com/joedf/ImgBeamer</a></li>
  1502. <li>Code documentation: <a href="https://joedf.github.io/ImgBeamer/jsdocs">https://joedf.github.io/ImgBeamer/jsdocs</a></li>
  1503. <li>Application design: <a href="https://github.com/joedf/CAS741_w23">https://github.com/joedf/CAS741_w23</a></li>
  1504. <li>Quick start guide: <a href="https://joedf.github.io/ImgBeamer/misc/ImgBeamer_QS_guide.pdf">ImgBeamer_QS_guide.pdf</a></li>
  1505. </ul>
  1506. </details>
  1507. <details>
  1508. <summary><b>Image Contributions</b></p></summary>
  1509. <ul>
  1510. <li>Bavley Guerguis for the APT needle image <q>APT_needle.png</q></li>
  1511. <li>Joachim de Fourestier for the <q>El Laco tephra (EL-JM-P4)</q> images
  1512. <q>tephra_448nm.png</q>, <q>tephra_200nm.png</q>,
  1513. and the virtual <q>grains</q> images.
  1514. </li>
  1515. </ul>
  1516. <p>All images belong to their respective owners and are used here with permission.</p>
  1517. </details>
  1518. <details>
  1519. <summary><b>Open-Source Libraries</b></p></summary>
  1520. <ul>
  1521. <li><a href="https://konvajs.org">Konva.js</a> - HTML5 2d canvas js library</li>
  1522. <li><a href="https://jquery.com/">jQuery</a>
  1523. and <a href="https://jqueryui.com">jQuery-ui</a> - HTML DOM manipulation and UI elements</li>
  1524. <li><a href="https://github.com/dataarts/dat.gui">dat.gui</a> - Lightweight GUI for changing variables</li>
  1525. <li><a href="https://github.com/photopea/UTIF.js">UTIF.js</a> - Fast and advanced TIFF decoder</li>
  1526. <li><a href="https://github.com/darosh/image-ms-ssim-js">image-ms-ssim.js</a> - Image multi-scale structural similarity (MS-SSIM)</li>
  1527. </ul>
  1528. </details>
  1529. </div>
  1530. `).appendTo('body');
  1531. elem.dialog({
  1532. modal: true,
  1533. width: 540,
  1534. buttons: {
  1535. Ok: function() {
  1536. $( this ).dialog( "close" );
  1537. }
  1538. }
  1539. });
  1540. }
  1541. /* eslint-enable max-len */
  1542. }
  1543. };

↑ Top