/* globals
* Utils,
* G_BOX_SIZE, G_DEBUG, G_AUTO_PREVIEW_LIMIT,
* G_VSEM_PAUSED, G_MATH_TOFIXED,
* G_SHOW_SUBREGION_OVERLAY,
* G_update_ImgMetrics
*/
/* exported
* drawSpotProfileEdit, drawSubregionImage, drawSpotContent, drawSpotSignal,
* drawProbeLayout, drawProbeLayoutSampling, drawResampled, drawGroundtruthImage,
* drawVirtualSEM
*/
// used by "Resulting Image" box / drawVirtualSEM()
// to reduce artifacts from drawing pixel-by-pixel in canvas
var G_DRAW_WITH_OVERLAP = true;
// overlap amount in pixels to all edges (top, left, right, bottom)
var G_DRAW_OVERLAP_PIXELS = 1;
// Optionally draw with overlap when above a certain pixel (cell) count
// set to 0 to essentially ignore this threshold value...
// eslint-disable-next-line no-magic-numbers
var G_DRAW_OVERLAP_THRESHOLD = 10 * 10; // rows * cols
// Optionally, to draw normally (w/o overlap) after a number of passes
var G_DRAW_OVERLAP_PASSES = 1;
// The minimum average pixel/signal value for an image to be considered "non-blank"
var G_MIN_AVG_SIGNAL_VALUE = 2;
// the pixel size of the spot used for the subregion render view, updated elsewhere
var G_BEAMRADIUS_SUBREGION_PX = {x:1,y:1};
const KEYCODE_R = 82;
const KEYCODE_ESC = 27;
const G_ZOOM_FACTOR_PER_TICK = 1.2;
var G_VirtualSEM_animationFrameRequestId = null;
var G_SubResampled_animationFrameRequestId = null;
/**
* Draws an node-editable ellipse shape on the given drawing stage.
* @param {*} stage the stage to draw on.
* @param {Function} updateCallback optional callback when a change occurs in spotSize
* @returns the spot/beam (Ellipse) object
*/
function drawSpotProfileEdit(stage, updateCallback = null) {
var layer = stage.getLayers()[0];
layer.destroyChildren(); // avoid memory leaks
// default beam shape values
var defaultRadius = {
x: 70,
y: 70
};
// create our shape
var beam = new Konva.Ellipse({
x: stage.width() / 2,
y: stage.height() / 2,
radius: defaultRadius,
fill: 'white',
strokeWidth: 0,
});
layer.add(beam);
layer.draw();
// make it editable
var tr = new Konva.Transformer({
nodes: [beam],
centeredScaling: true,
// style the transformer:
// https://konvajs.org/docs/select_and_transform/Transformer_Styling.html
anchorSize: 11,
anchorCornerRadius: 3,
borderDash: [3, 3],
// eslint-disable-next-line no-magic-numbers
rotationSnaps: [0, 45, 90, 135, 180],
// resize limits
// https://konvajs.org/docs/select_and_transform/Resize_Limits.html
boundBoxFunc: function (oldBoundBox, newBoundBox) {
// if the new bounding box is too large or small
// small than the stage size, but more than 1 px.
// then, we return the old bounding box
if ( newBoundBox.width > stage.width() || newBoundBox.width < 1
|| newBoundBox.height > stage.height() || newBoundBox.height < 1) {
return oldBoundBox;
}
return newBoundBox;
}
});
layer.listening(true);
layer.add(tr);
// make it (de)selectable
// based on https://konvajs.org/docs/select_and_transform/Basic_demo.html
stage.off('click tap'); // prevent "eventHandler doubling" from subsequent calls
stage.on('click tap', function (e) {
// if click on empty area - remove all selections
if (e.target === stage) {
tr.nodes([]);
return;
}
const isSelected = tr.nodes().indexOf(e.target) >= 0;
if (!isSelected) {
// was not already selected, so now we add it to the transformer
// select just the one
tr.nodes([e.target]);
}
});
// keyboard events
// based on https://konvajs.org/docs/events/Keyboard_Events.html
var container = stage.container();
// make it focusable
container.tabIndex = 2;
// Avoiding using addEventListener, since we only ever want one handler at time.
// This way, we easily replace it when a new image is loaded.
container.onkeydown = function(e) {
// don't handle meta-key'd events for now...
const metaPressed = e.shiftKey || e.ctrlKey || e.metaKey;
if (metaPressed)
return;
switch (e.keyCode) {
case KEYCODE_R: // 'r' key, reset beam shape
beam.rotation(0);
beam.scale({x:1, y:1});
// update other beams based on this one
// https://konvajs.org/docs/events/Fire_Events.html
beam.fire('transform');
break;
case KEYCODE_ESC: // 'esc' key, deselect all
tr.nodes([]);
break;
default: break;
}
e.preventDefault();
};
// Replacing userScaledImage / userImage
// invisible shape to get scale X/Y as a control for spot size/mag
// by mouse scroll or values being set on a konva object to call .ScaleX/Y()
var rectW = stage.width();
var magRect = new Konva.Rect({
width: rectW,
height: rectW,
// fill: 'red',
fillEnabled: false,
// strokeWidth: 0,
strokeWidth: 1,
stroke: 'lime',
// strokeEnabled: false,
strokeScaleEnabled: false,
listening: false,
visible: false,
perfectDrawEnabled: false,
});
// "pre-zoom" a bit, and start with center position
// zoom/scale so that the spot size starts at 100%
var _tempCellWidth = magRect.width() / Utils.getColsInput();
var initialSpotScale = beam.width() / _tempCellWidth;
Utils.scaleOnCenter(stage, magRect, 1, initialSpotScale);
// optional event callback
var doUpdate = function(){
if (typeof updateCallback == 'function')
return updateCallback();
};
stage.off('wheel').on('wheel', function(e){
e.evt.preventDefault(); // stop default scrolling
var scaleBy = G_ZOOM_FACTOR_PER_TICK;
// Do half rate scaling, if shift is pressed
if (e.evt.shiftKey) {
scaleBy = 1 +((scaleBy-1) / 2);
}
// how to scale? Zoom in? Or zoom out?
let direction = e.evt.deltaY > 0 ? -1 : 1;
var oldScale = magRect.scaleX();
var newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
// limit the max zoom from scrolling, to prevent blank pixel data
// because of too small of a spot size...
const tolerance = -0.1;
if (G_BEAMRADIUS_SUBREGION_PX.x + tolerance < 1
|| G_BEAMRADIUS_SUBREGION_PX.y + tolerance < 1) {
newScale = Math.min(oldScale, newScale);
}
Utils.centeredScale(magRect, newScale);
doUpdate();
});
layer.add(magRect);
return {beam: beam, spotSize: magRect};
}
/**
* Draws the subregion image display.
* @param {*} stage The stage to draw it on.
* @param {*} oImg The ground truth image.
* @param {Number} size (to be removed) The max size (width or height) of the image to draw.
* @param {Function} updateCallback
* @returns a reference to the subregion image object that can be panned and zoomed by the user.
*
* @todo remove 'size' ... confusing and not useful.
*/
function drawSubregionImage(stage, oImg, size, updateCallback = null) {
var max = size;
var imageSize = { w: oImg.naturalWidth, h: oImg.naturalHeight, };
if (G_DEBUG)
console.log("img natural size:", oImg.naturalWidth, oImg.naturalHeight);
// get image ratios to "fit" in canvas
var fillMode = Utils.getImageFillMode();
var fitSize = Utils.fitImageProportions(oImg.naturalWidth, oImg.naturalHeight, max, fillMode);
var fitScale = {
x: fitSize.w / oImg.naturalWidth,
y: fitSize.h / oImg.naturalHeight,
};
// force the image to be square by compressing it to the smallest dimension (w or h),
// if we have the 'squish' fill mode.
if (fillMode == 'squish') {
let maxScale = Math.max(fitScale.x, fitScale.y);
fitScale = { x: maxScale, y: maxScale };
let minDim = Math.min(oImg.naturalWidth, oImg.naturalHeight);
imageSize = { w: minDim, h: minDim };
}
// TODO: this should be in a helper likely,
// since part of it is very similar to drawGroundtruthImage()
var kImage = new Konva.Image({
image: oImg,
width: imageSize.w,
height: imageSize.h,
scale: {
x: fitScale.x,
y: fitScale.y,
},
draggable: true,
});
var layer = stage.getLayers()[0];
layer.destroyChildren(); // avoid memory leaks
var constrainBounds = function(){
var scaleX = kImage.scaleX(), scaleY = kImage.scaleY();
var x = kImage.x(), y = kImage.y();
var w = kImage.width() * scaleX, h = kImage.height() * scaleY;
var sx = stage.x(), sw = stage.width();
var sy = stage.y(), sh = stage.height();
if (x > sx) { kImage.x(sx); }
if (x < (sx - w + sw) ) { kImage.x(sx - w + sw); }
if (y > sy) { kImage.y(sy); }
if (y < (sy - h + sh) ) { kImage.y(sy - h + sh); }
stage.draw();
};
// optional event callback
var doUpdate = function(){
if (typeof updateCallback == 'function')
return updateCallback();
};
// Enable drag and interaction events
layer.listening(true);
kImage.on('mouseup', function() { doUpdate(); });
kImage.on('dragmove', function() {
// set bounds on object, by overriding position here
constrainBounds();
doUpdate();
});
kImage.on('wheel', Utils.MakeZoomHandler(stage, kImage, function(){
// bounds check for zooming out
constrainBounds();
// callback here, e.g. doUpdate();
doUpdate();
}, G_ZOOM_FACTOR_PER_TICK, fitScale.x));
layer.add(kImage);
stage.draw();
// keyboard events
// TODO: similar or duplicate from drawSpotProfileEdit() or
// "Spot Profile" keyboard event code
var container = stage.container();
// make it focusable
container.tabIndex = 1;
// Avoiding using addEventListener, since we only ever want one handler at time.
// This way, we easily replace it when a new image is loaded.
container.onkeydown = function(e) {
// don't handle meta-key'd events for now...
const metaPressed = e.shiftKey || e.ctrlKey || e.metaKey;
if (metaPressed)
return;
switch (e.keyCode) {
case KEYCODE_R: // 'r' key, reset scale & position
kImage.setAttrs({
scaleX: fitScale.x,
scaleY: fitScale.y,
x:0, y:0
});
doUpdate();
break;
default: break;
}
e.preventDefault();
};
return kImage;
}
/**
* Draws the spot content on the given drawing stage.
* The given image is draggable (pan) and zoomable (scroll).
* @param {*} stage the drawing stage.
* @param {*} sImage the subregion image (will be cloned for the image object displayed).
* @param {*} sBeam the beam/spot shape (used by reference).
* @param {function} updateCallback a function to call when a change occurs such as pan-and-zoom.
* @returns a reference to the image object being scaled by the user => "userScaledImage".
*/
function drawSpotContent(stage, sImage, sBeam, updateCallback = null) {
var layer = stage.getLayers()[0];
layer.destroyChildren(); // avoid memory leaks
layer.listening(true);
// Give yellow box border to indicate interactive
$(stage.getContainer()).css('border-color','yellow');
var image = sImage.clone();
image.draggable(true);
image.globalCompositeOperation('source-in');
layer.add(sBeam);
layer.add(image);
// "pre-zoom" a bit, and start with center position
// zoom/scale so that the spot size starts at 100%
var _tempCellSize = Utils.computeCellSize(sImage);
var initialSpotScale = sBeam.width() / _tempCellSize.w;
// get image proportions once scaled and fitted in the stages
var max = G_BOX_SIZE, oImg = sImage.image(), fillMode = Utils.getImageFillMode();
var fitSize = Utils.fitImageProportions(oImg.naturalWidth, oImg.naturalHeight, max, fillMode);
var minScaleX = fitSize.w / oImg.naturalWidth;
// center the image copy based on the calculated center and initial scales
Utils.scaleOnCenter(stage, image, minScaleX, initialSpotScale);
layer.draw();
var doUpdate = function(){
if (typeof updateCallback == 'function')
return updateCallback();
};
// Events
image.on('mouseup', function() { doUpdate(); });
image.on('dragmove', function() { stage.draw(); });
image.on('wheel', Utils.MakeZoomHandler(stage, image, function(){
doUpdate();
}, G_ZOOM_FACTOR_PER_TICK, 0, function(oldScale,newScale){
// limit the max zoom from scrolling, to prevent blank pixel data
// because of too small of a spot size...
const tolerance = -0.1;
if (G_BEAMRADIUS_SUBREGION_PX.x + tolerance < 1
|| G_BEAMRADIUS_SUBREGION_PX.y + tolerance < 1) {
return Math.min(oldScale, newScale);
}
return newScale;
}));
return image;
}
/**
* Draws the Spot Signal - previews the averaged signal for a given spot.
* @param {*} sourceStage the source stage to sample from.
* @param {*} destStage the stage to draw on.
* @param {*} sBeam the beam to sample (or "stencil") to with.
* @returns an update function to call when a redraw is needed.
*/
function drawSpotSignal(sourceStage, destStage, sBeam) {
var sourceLayer = sourceStage.getLayers()[0];
var destLayer = destStage.getLayers()[0];
destLayer.destroyChildren(); // avoid memory leaks
var beam = sBeam; //.clone();
var doUpdateAvgSpot = function(){
var pCtx = sourceLayer.getContext();
var allPx = pCtx.getImageData(0, 0, pCtx.canvas.width, pCtx.canvas.height);
// var avgPx = Utils.get_avg_pixel_rgba(allPx);
var avgPx = Utils.get_avg_pixel_gs(allPx); avgPx = [avgPx,avgPx,avgPx,1];
var avgSpot = null;
if (destLayer.getChildren().length <= 0){
avgSpot = beam;
destLayer.add(avgSpot);
} else {
avgSpot = destLayer.getChildren()[0];
}
var avgColor = "rgba("+ avgPx.join(',') +")";
avgSpot.stroke(avgColor);
avgSpot.fill(avgColor);
destStage.getContainer().setAttribute('note', avgColor);
destLayer.draw();
};
// run once immediately
doUpdateAvgSpot();
return doUpdateAvgSpot;
}
/**
* Draws the probe layout.
* @param {*} drawStage The stage to draw on.
* @param {*} baseImage The subregion image to draw with (cloned).
* @param {*} spotScale an object to query for the spot scale X/Y.
* @param {*} beam the beam/spot shape to draw with (cloned and scaled).
* @returns an update function to call when a redraw is needed.
*/
function drawProbeLayout(drawStage, baseImage, spotScale, beam) {
// draws probe layout
var layers = drawStage.getLayers();
var baseLayer = layers[0];
baseLayer.destroyChildren(); // avoid memory leaks
// The subregion area is based on what is "visible" in the subregion view.
var baseGridRect = Utils.getRectFromKonvaObject(baseImage.getStage());
var imageCopy = baseImage.clone();
baseLayer.add(imageCopy);
baseLayer.draw();
// setup "last" values to help optimize for draw performance
// no need to redraw the grid lines and spot outlines if the
// no. rows, columns, spot radii, or rotation didn't change...
var _last = {
rows: -1,
cols: -1,
radiusX: -1,
radiusY: -1,
rotation: 0,
beamOpacity: Utils.getSpotLayoutOpacityInput(),
};
var updateProbeLayout = function(){
// get the over-layer, create if not already added
var gridLayer = null;
var gridDrawn = false;
if (layers.length < 2) {
gridLayer = new Konva.Layer();
drawStage.add(gridLayer);
} else {
gridLayer = layers[1];
gridDrawn = true; // assume we drew it already
}
// get probe layer, make a new if not already there
var probesLayer = null;
if (layers.length < 3) {
probesLayer = new Konva.Layer();
drawStage.add(probesLayer);
} else {
probesLayer = layers[2];
}
///////////////////////////////
// Do drawing work ...
// update image based on user subregion
imageCopy.x(baseImage.x());
imageCopy.y(baseImage.y());
imageCopy.scaleX(baseImage.scaleX());
imageCopy.scaleY(baseImage.scaleY());
var tRows = Utils.getRowsInput();
var tCols = Utils.getColsInput();
var radiusX = (beam.width() / spotScale.scaleX()) / 2; //(cell.width/2) * .8
var radiusY = (beam.height() / spotScale.scaleY()) / 2; //(cell.height/2) * .8
var beamRotation = beam.rotation();
// preview spot display opacity
var beamOpacity = Utils.getSpotLayoutOpacityInput();
// only redraw grid lines and spot outlines if they would change
if (_last.rows != tRows || _last.cols != tCols
|| _last.radiusX != radiusX || _last.radiusY != radiusY
|| _last.rotation != beamRotation
|| _last.beamOpacity != beamOpacity){
// record for next change detect
_last.rows = tRows, _last.cols = tCols;
_last.radiusX = radiusX, _last.radiusY = radiusY,
_last.rotation = beamRotation;
_last.beamOpacity = beamOpacity;
// comment to draw grid only once
gridDrawn = false; gridLayer.destroyChildren();
// draw grid, based on rect
if (!gridDrawn)
Utils.drawGrid(gridLayer, baseGridRect, tRows, tCols);
// clear the probe layer
probesLayer.destroyChildren();
var probe = new Konva.Ellipse({
radius : {
x : radiusX,
y : radiusY,
},
rotation: beamRotation,
fill: 'rgba(255,0,0,'+beamOpacity+')',
strokeWidth: 1,
stroke: 'red'
});
Utils.repeatDrawOnGrid(probesLayer, baseGridRect, probe, tRows, tCols);
}
};
// run once immediately
updateProbeLayout();
return updateProbeLayout;
}
/**
* Draws the spot layout sampled image content. The image stenciled by the spot shape over a grid.
* @param {*} drawStage The stage to draw on
* @param {*} originalImage The image to "stencil" / "clip" or sample.
* @param {*} spotScale an object to query for the spot scale X/Y.
* @param {*} sBeam the spot/beam shape to use (cloned and scaled)
* @returns an update function to call when a redraw is needed.
*/
function drawProbeLayoutSampling(drawStage, originalImage, spotScale, sBeam) {
var imageCopy = originalImage.clone();
var beam = sBeam; //.clone();
var drawLayer = drawStage.getLayers()[0];
drawLayer.destroyChildren(); // avoid memory leaks
// The subregion area is based on what is "visible" in the subregion view.
var baseGridRect = Utils.getRectFromKonvaObject(originalImage.getStage());
// setup "last" values to help optimize for draw performance
// similar reason as for drawProbeLayout()
var _last = {
rows: -1,
cols: -1,
radiusX: -1,
radiusY: -1,
rotation: 0,
};
var updateProbeLayoutSampling = function(){
var rows = Utils.getRowsInput();
var cols = Utils.getColsInput();
var radiusX = (beam.width() / spotScale.scaleX()) / 2;
var radiusY = (beam.height() / spotScale.scaleY()) / 2;
var beamRotation = beam.rotation();
// only redraw as necessary: if the spots would change...
if (_last.rows != rows || _last.cols != cols
|| _last.radiusX != radiusX || _last.radiusY != radiusY
|| _last.rotation != beamRotation){
// record for next change detect
_last.rows = rows, _last.cols = cols;
_last.radiusX = radiusX, _last.radiusY = radiusY,
_last.rotation = beamRotation;
// clear the layer before we draw
drawLayer.destroyChildren();
var probe = new Konva.Ellipse({
radius : {
x : radiusX,
y : radiusY,
},
rotation: beamRotation,
fill: 'white',
listening: false,
});
//Utils.computeResampledPreview(drawStage, imageCopy, probe, rows, cols, baseGridRect);
Utils.repeatDrawOnGrid(drawLayer, baseGridRect, probe, rows, cols);
imageCopy.globalCompositeOperation('source-in');
drawLayer.add(imageCopy);
// Not needed
// drawStage.draw();
}
else
{
// otherwise only the image needs to updated by size & position
imageCopy.x(originalImage.x());
imageCopy.y(originalImage.y());
imageCopy.scaleX(originalImage.scaleX());
imageCopy.scaleY(originalImage.scaleY());
}
};
// run once immediately
updateProbeLayoutSampling();
return updateProbeLayoutSampling;
}
/**
* Draws the sampled subregion.
* @param {*} sourceStage The source stage to sample from
* @param {*} destStage The stage to draw on
* @param {*} originalImage the subregion image to sample or 'stencil' over.
* @param {*} spotScale an object to query for the spot scale X/Y.
* @param {*} sBeam the beam to sample with (cloned and scaled)
* @returns an update function to call when a redraw is needed
*/
function drawResampled(sourceStage, destStage, originalImage, spotScale, sBeam) {
var baseImage = originalImage; //.clone();
var beam = sBeam; //.clone();
// The subregion area is based on what is "visible" in the subregion view.
var baseGridRect = Utils.getRectFromKonvaObject(baseImage.getStage());
var rows = 0, cols = 0;
var probe = null;
var changeCount = 0, lastChange = 0;
var updateConfigValues = function(){
rows = Utils.getRowsInput();
cols = Utils.getColsInput();
probe = new Konva.Ellipse({
radius : {
x : (beam.width() / spotScale.scaleX()) / 2,
y : (beam.height() / spotScale.scaleY()) / 2
},
rotation: beam.rotation(),
fill: 'white',
listening: false,
});
// update it globally, so we can limit zoom in Spot Content, based on this
G_BEAMRADIUS_SUBREGION_PX = {x:probe.radiusX()*2, y:probe.radiusY()*2};
// manage layers to allow for multilayer draw as needed for row-by-row drawing
// guarantee at least one layer
var layers = destStage.getLayers();
if (layers.length < 1) { destStage.add(new Konva.Layer({ listening: false })); }
changeCount++;
};
var currentRow = 0;
function doUpdate(){
var layers = destStage.getLayers();
if (rows*cols > G_AUTO_PREVIEW_LIMIT) {
// do row-by-row draw
// otherwise, the app gets unresponsive since a frame may take too long to draw.
if (!G_VSEM_PAUSED) {
var row = currentRow++;
if (currentRow >= rows) {
currentRow = 0;
var layer = new Konva.Layer({ listening: false });
destStage.add(layer);
}
if (layers.length > 2) { layers[0].destroy(); }
Utils.computeResampledSlow(sourceStage, destStage, baseImage, probe, rows, cols, baseGridRect
,row,row+1,0,-1,false,true);
}
} else {
// do frame-by-frame draw
if (changeCount > lastChange) {
// reset change counts instead of recording it,
// a trick to avoid an integer rollover, yet still functional.
lastChange = changeCount = 0;
// ensure we only use one layer
// prevents any left over partially draw layers on top coming from the row-by-row draw
if (layers.length > 1) {
destStage.destroyChildren();
destStage.add(new Konva.Layer({ listening: false }));
}
// Utils.computeResampledFast(sourceStage, destStage, baseImage, probe, rows, cols);
// Utils.computeResampledSlow(sourceStage, destStage, baseImage, probe, rows, cols, baseGridRect);
Utils.computeResampledSlow(sourceStage, destStage, baseImage, probe, rows, cols, baseGridRect
,0,-1,0,-1,true,false);
}
}
// not needed
// destStage.draw()
G_SubResampled_animationFrameRequestId = requestAnimationFrame(doUpdate);
}
// run once immediately
updateConfigValues();
cancelAnimationFrame(G_SubResampled_animationFrameRequestId);
G_SubResampled_animationFrameRequestId = requestAnimationFrame(doUpdate);
return updateConfigValues;
}
/**
* Draws the ground truth image and the subregion bounds overlay.
* @param {*} stage the stage to draw on.
* @param {*} imageObj the original/full-size image to draw
* @param {*} subregionImage the subregion image (to get the bounds from)
* @param {number} maxSize (to be removed) the maximum size (width or height) of the stage to fit the image?
* @param {Function} updateCallback called when a change is made to the subregion
* @returns an object with an update function to call for needed redraws and the subregion bounds.
* @todo remove maxSize if possible?
* @todo do we really need to return the subregionRect as well?
*/
function drawGroundtruthImage(stage, imageObj, subregionImage, maxSize=G_BOX_SIZE, updateCallback = null){
var fillMode = Utils.getImageFillMode();
var fit = Utils.fitImageProportions(imageObj.naturalWidth, imageObj.naturalHeight, maxSize, fillMode);
var layer = stage.getLayers()[0];
layer.destroyChildren(); // avoid memory leaks
// TODO: this shouldnt be need or it at least duplicate with
// part of drawSubregionImage()
var image = new Konva.Image({
x: (maxSize - fit.w)/2,
y: (maxSize - fit.h)/2,
image: imageObj,
width: fit.w,
height: fit.h,
listening: false,
});
var rect = new Konva.Rect({
x: image.x(),
y: image.y(),
width: image.width(),
height: image.height(),
fill: "rgba(0,255,255,0.4)",
stroke: "#00FFFF",
strokeWidth: 1,
// listening: false,
draggable: true,
strokeScaleEnabled: false,
});
var imagePixelScaling = Utils.imagePixelScaling(image, imageObj);
if (fillMode == 'squish') {
var maxScale = Math.max(imagePixelScaling.x, imagePixelScaling.y);
imagePixelScaling = { x: maxScale, y: maxScale };
}
// Draggable nav-rect
// https://github.com/joedf/ImgBeamer/issues/41
rect.on('dragmove', function(){
constrainRect();
applyChangesFromNavRect();
});
var constrainRect = function(){
var rw = rect.width();
var rh = rect.height();
var ss = stage.size();
// top left corner limit
if (rect.x() < 0) { rect.x(0); }
if (rect.y() < 0) { rect.y(0); }
// bottom right limit
if (rect.x() > ss.width - rw) { rect.x(ss.width - rw); }
if (rect.y() > ss.height - rh) { rect.y(ss.height - rh); }
};
stage.off('wheel'); // prevent "eventHandler doubling" from subsequent calls
stage.on('wheel', function(e) {
// code is based on Utils.MakeZoomHandler()
e.evt.preventDefault(); // stop default scrolling
const scaleFactor = G_ZOOM_FACTOR_PER_TICK;
var scaleBy = scaleFactor;
// Do half rate scaling, if shift is pressed
if (e.evt.shiftKey) {
scaleBy = 1 +((scaleBy-1) / 2);
}
// calculate scale with direction
let direction = e.evt.deltaY > 0 ? -1 : 1;
var scale = direction > 0 ? 1.0 / scaleBy : 1.0 * scaleBy;
// calculate new size
var rs = rect.size();
var newWidth = rs.width * scale;
var newHeigth = rs.height * scale;
// constrain size
var limitW = Math.min(Math.max(newWidth, 1), stage.width());
var limitH = Math.min(Math.max(newHeigth, 1), stage.height());
// get rect center point delta
var dx = rect.width() - limitW;
var dy = rect.height() - limitH;
// apply new size
rect.size({ width: limitW, height: limitH });
// center rect based on new size
rect.position({
x: rect.x() + dx/2,
y: rect.y() + dy/2
});
constrainRect();
applyChangesFromNavRect();
});
var applyChangesFromNavRect = function(){
// update the subregion view to the new position and zoom based on changes
// to the nav-rect by the user
var si = subregionImage;
si.scale({
x: (stage.width() / rect.width()) * imagePixelScaling.x,
y: (stage.height() / rect.height()) * imagePixelScaling.y,
});
si.position({
x: ((image.x() - rect.x()) * si.scaleX()) / imagePixelScaling.x,
y: ((image.y() - rect.y()) * si.scaleY()) / imagePixelScaling.y,
});
// this propagates the changes to the subregion to the rest of the app
if (typeof updateCallback == 'function')
return updateCallback();
};
// Grab cursor for nav-rectangle overlay
// https://konvajs.org/docs/styling/Mouse_Cursor.html
layer.listening(true);
rect.on('mouseenter', function () {
stage.container().style.cursor = 'grab';
}).on('mouseleave', function () {
stage.container().style.cursor = 'default';
});
layer.add(image);
layer.add(rect);
var update = function(){
// calc location rect from subregionImage
// and update bounds drawn rectangle
var si = subregionImage;
rect.position({
x: ((image.x() - si.x()) / si.scaleX()) * imagePixelScaling.x,
y: ((image.y() - si.y()) / si.scaleY()) * imagePixelScaling.y,
});
rect.size({
width: (stage.width() / si.scaleX()) * imagePixelScaling.x,
height: (stage.height() / si.scaleY()) * imagePixelScaling.y,
});
// subregion overlay visibility
rect.visible(G_SHOW_SUBREGION_OVERLAY);
stage.draw();
// center of the rect coords
var center = {
x: rect.x() + rect.width()/2,
y: rect.y() + rect.height()/2,
};
// transform to unit square coords
var unitCoords = Utils.stageToUnitCoordinates(center.x, center.y, stage);
// scale to original image pixel size
var pxImgCoords = Utils.unitToImagePixelCoordinates(unitCoords.x, unitCoords.y, imageObj);
var pxSizeNm = Utils.getPixelSizeNmInput();
// scale as real physical units
var middle = Utils.imagePixelToRealCoordinates(pxImgCoords.x, pxImgCoords.y, pxSizeNm);
// get as optimal displayUnit
var fmtMiddle = Utils.formatUnitNm(middle.x, middle.y);
var sizeFOV = {
w: (rect.width() / stage.width()) * imageObj.naturalWidth * pxSizeNm,
};
var fmtSizeFOV = Utils.formatUnitNm(sizeFOV.w);
// display coords & FOV size
Utils.updateExtraInfo(stage, '('
+ fmtMiddle.value.toFixed(G_MATH_TOFIXED.SHORT) + ', '
+ fmtMiddle.value2.toFixed(G_MATH_TOFIXED.SHORT) + ')'
+ ' ' + fmtMiddle.unit
+ '<br>FOV width: ' + fmtSizeFOV.value.toFixed(G_MATH_TOFIXED.SHORT)
+ ' ' + fmtSizeFOV.unit
);
};
update();
stage.draw();
return {
updateFunc: update,
subregionRect: rect
};
}
/**
* Draws the resulting image continously row-by-row.
* @param {*} stage the stage to draw on
* @param {*} beam the beam to sample with
* @param {*} subregionRect the bounds on the subregion
* @param {*} subregionRectStage the stage for the gorund truth
* @param {*} originalImageObj the ground truth image
* @param {*} spotScale an object to query for the spot scale X/Y.
* @returns an update function to call when the spot profile or the cell/pixel size changes.
* @todo do we really need both subregionRect and subregionRectStage as
* separate parameters? maybe the info needed can be obtained with less
* or more cleanly?
* @todo rename confusing subregionRectStage to groundTruthStage?
* @todo can we get rid userScaleImage / userImage throughout the source if possible, cleaner?
*/
function drawVirtualSEM(stage, beam, subregionRect, subregionRectStage, originalImageObj, spotScale){
var rows = 0, cols = 0;
var cellW = 0, cellH = 0;
var currentRow = 0;
const indicatorWidth = 20;
const indicatorHeight = 3;
var pixelCount = 0;
var currentDrawPass = 0;
// use the canvas API directly in a konva stage
// https://konvajs.org/docs/sandbox/Free_Drawing.html
var layer = stage.getLayers()[0];
layer.destroyChildren(); // avoid memory leaks
var canvas = document.createElement('canvas');
canvas.width = stage.width();
canvas.height = stage.height();
// the canvas is added to the layer as a "Konva.Image" element
var image = new Konva.Image({
image: canvas,
x: 0,
y: 0,
});
layer.add(image);
// draw an indicator to show which row was last drawn
var indicator = new Konva.Rect({
x: stage.width() - indicatorWidth, y: 0,
width: indicatorWidth,
height: indicatorHeight,
fill: 'red',
});
layer.add(indicator);
var context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
var beamRadius = {x : 0, y: 0};
var superScale = 1;
// original image size
var iw = originalImageObj.naturalWidth, ih = originalImageObj.naturalHeight;
// get scale factor for full image size
var irw = (iw / stage.width()), irh = (ih / stage.height());
var updateConfigValues = function(){
var ratioX = subregionRectStage.width() / subregionRect.width();
var ratioY = subregionRectStage.height() / subregionRect.height();
// multiply by the ratio, since we should have more cells on the full image
rows = Math.round(Utils.getRowsInput() * ratioY);
cols = Math.round(Utils.getColsInput() * ratioX);
// the total number of "pixels" (cells) that will drawn
pixelCount = rows * cols;
// save last value, to detect significant change
var lastCellW = cellW, lastCellH = cellH;
cellW = stage.width() / cols;
cellH = stage.height() / rows;
var significantChange = (cellW != lastCellW) && (cellH != lastCellH);
// get beam size based on user-scaled image
beamRadius = {
// divide by the ratio, since the spot should be smaller when mapped onto
// the full image which is scaled down to the same stage size...
x : (beam.width() / spotScale.scaleX()) / 2 / ratioX,
y : (beam.height() / spotScale.scaleY()) / 2 / ratioY
};
// check if we need to scale up the image for sampling...
// if the avg spot radius is less than 1, scale up at 1 / x (inversely proportional)
var radiusAvg = (beamRadius.x+beamRadius.y)/2;
superScale = (radiusAvg < 1) ? 1 / radiusAvg : 1;
// we can clear the screen here, if we want to avoid lines from previous configs...
if (significantChange) { // if it affects the drawing
context.clearRect(0, 0, canvas.width, canvas.height);
currentRow = 0; // restart drawing from the top
currentDrawPass = 0;
}
// display image size / pixel counts and the pixel size
var fullImgWidthNm = iw * Utils.getPixelSizeNmInput();
var fmtPxSize = Utils.formatUnitNm(
fullImgWidthNm / cols,
fullImgWidthNm / rows,
);
Utils.updateExtraInfo(stage, cols + ' x ' + rows
+ ' px<br>' + fmtPxSize.value.toFixed(G_MATH_TOFIXED.SHORT)
+ " x " + fmtPxSize.value2.toFixed(G_MATH_TOFIXED.SHORT)
+ " " + fmtPxSize.unit + "/px");
};
updateConfigValues();
// var colors = ['blue', 'yellow', 'red', 'green', 'cyan', 'pink'];
var colors = ['#DDDDDD','#EEEEEE','#CCCCCC','#999999','#666666','#333333','#B6B6B6','#1A1A1A'];
var color = colors[Utils.getRandomInt(colors.length)];
var doUpdate = function(){
if (!G_VSEM_PAUSED) {
// track time to draw the row
var timeRowStart = Date.now();
var row = currentRow++;
var ctx = context;
if (currentRow >= rows) {
currentRow = 0;
currentDrawPass += 1;
}
var rowIntensitySum = 0;
// interate over X
for (let i = 0; i < cols; i++) {
const cellX = i * cellW;
const cellY = row * cellH;
// TODO: check these values and the Utils.ComputeProbeValue_gs again
// since the final image seems to differ...
// map/transform values to full resolution image coordinates
const scaledProbe = {
centerX: (cellX + cellW/2) * irw,
centerY: (cellY + cellH/2) * irh,
rotationRad: Utils.toRadians(beam.rotation()),
radiusX: beamRadius.x * irw,
radiusY: beamRadius.y * irh,
};
// compute the pixel value, for the given spot/probe profile
var gsValue = Utils.ComputeProbeValue_gs(originalImageObj, scaledProbe, superScale);
color = 'rgba('+[gsValue,gsValue,gsValue].join(',')+',1)';
ctx.fillStyle = color;
rowIntensitySum += gsValue;
// optionally, draw with overlap to reduce visual artifacts
if ((currentDrawPass < G_DRAW_OVERLAP_PASSES)
&& (G_DRAW_WITH_OVERLAP && pixelCount >= G_DRAW_OVERLAP_THRESHOLD)) {
ctx.fillRect(
cellX -G_DRAW_OVERLAP_PIXELS,
cellY -G_DRAW_OVERLAP_PIXELS,
cellW +G_DRAW_OVERLAP_PIXELS,
cellH +G_DRAW_OVERLAP_PIXELS
);
} else {
ctx.fillRect(cellX, cellY, cellW, cellH);
}
}
// if the last drawn was essentially completely black
// assume the spot size was too small or no signal
// for 1-2 overlapped-draw passes...
var rowIntensityAvg = rowIntensitySum / cols;
if (rowIntensityAvg <= G_MIN_AVG_SIGNAL_VALUE) { // out of 255
currentDrawPass = -1;
}
// move/update the indicator
indicator.y((row+1) * cellH - indicator.height());
layer.batchDraw();
// use this for debugging, less heavy, draw random color rows
// color = colors[Utils.getRandomInt(colors.length)];
// updateConfigValues();
var timeDrawTotal = Date.now() - timeRowStart;
stage.getContainer().setAttribute('note', timeDrawTotal + " ms/Row");
// update the image metric automatically a few times
// so the score updates as parts of the images changes
if (typeof G_update_ImgMetrics == "function") {
// update it every quarter of the rows drawn
let imgMetricUpdateTick = (currentRow % Math.floor(rows / 4) == 0) && (currentRow > 1);
// or update if the draw-rate is fast (less than 50 ms/row)
const rowTime = 50; //ms
// and not an SSIM-based algorithm, since they are comparatively slow...
var isSSIM = (Utils.getImageMetricAlgorithm()).indexOf('SSIM') >= 0;
if ( (timeDrawTotal < rowTime && !isSSIM) || imgMetricUpdateTick) {
G_update_ImgMetrics();
}
}
}
// see comment on using this instead of setInterval below
G_VirtualSEM_animationFrameRequestId = requestAnimationFrame(doUpdate);
};
// a warning is logged with slow setTimeout or requestAnimationFrame callbacks
// for each frame taking longer than ~60+ ms... resulting in hundreds/thousands,
// possibly slowing down the browser over time...
// ... read next comment block first, then come back to this one ...
// This cancel is needed, otherwise subsequent calls will multiple rogue update functions
// (going out of scope) running forever, but never allow itself to be garbage collected
// because the execution never ends... A similar case would likely happen with timers
// as well, e.g. self-calling setTimeout or setInterval...
cancelAnimationFrame(G_VirtualSEM_animationFrameRequestId);
// ... but we use requestAnimationFrame to let the browser determine what the
// fastest possible ideal speed is.
G_VirtualSEM_animationFrameRequestId = requestAnimationFrame(doUpdate);
return updateConfigValues;
}