diff --git a/modules/annotations/annotations.js b/modules/annotations/annotations.js index 704ab0b..d1d7789 100644 --- a/modules/annotations/annotations.js +++ b/modules/annotations/annotations.js @@ -1356,6 +1356,7 @@ window.OSDAnnotations = class extends XOpatModuleSingleton { OSDAnnotations.registerAnnotationFactory(OSDAnnotations.Ellipse, false); OSDAnnotations.registerAnnotationFactory(OSDAnnotations.Ruler, false); OSDAnnotations.registerAnnotationFactory(OSDAnnotations.Polygon, false); + OSDAnnotations.registerAnnotationFactory(OSDAnnotations.Multipolygon, false); /** * Polygon factory, the only factory required within the module @@ -2229,8 +2230,9 @@ OSDAnnotations.StateFreeFormTool = class extends OSDAnnotations.AnnotationState if (!o.sessionID) return false; let factory = o._factory(); if (!factory.isEditable()) return false; - const result = factory.isImplicit() ? - factory.toPointArray(o, OSDAnnotations.AnnotationObjectFactory.withObjectPoint) : o.points; + const result = factory.isImplicit() + ? factory.toPointArray(o, OSDAnnotations.AnnotationObjectFactory.withObjectPoint) + : o.points; if (!result) return false; return {object: o, asPolygon: result}; } @@ -2250,19 +2252,47 @@ OSDAnnotations.StateFreeFormTool = class extends OSDAnnotations.AnnotationState height: ffTool.radius * 2 + offset }, getObjectAsCandidateForIntersectionTest); + const polygonUtils = OSDAnnotations.PolygonUtilities; const active = this.context.canvas.getActiveObject(); - let max = 0, - result = candidates; // by default return the whole list if intersections are <= 0 - for (let i = 0; i < candidates.length; i++) { - let candidate = candidates[i]; - const intersection = OSDAnnotations.checkPolygonIntersect(brushPolygon, candidate.asPolygon); - if (intersection.length) { - if (active) { // prefer first encountered object if it is also the selection - return candidate; + let max = 0, result = candidates; // by default return the whole list if intersections are <= 0 + + for (let candidate of candidates) { + let outerPolygon; + let holes = null; + let notFullyInHoles = false; + let isMultipolygon = candidate.object.factoryID === "multipolygon"; + + if (isMultipolygon) { + outerPolygon = candidate.asPolygon[0]; + holes = candidate.asPolygon.slice(1); + } else { + outerPolygon = candidate.asPolygon; + } + + const intersection = OSDAnnotations.checkPolygonIntersect(brushPolygon, outerPolygon); + if (!intersection.length) continue; + + if (holes) { + notFullyInHoles = holes.every(hole => { + + const bboxBrush = polygonUtils.getBoundingBox(brushPolygon); + const bboxHole = polygonUtils.getBoundingBox(hole); + + if (polygonUtils.intersectAABB(bboxBrush, bboxHole)) { + const preciseIntersection = OSDAnnotations.checkPolygonIntersect(brushPolygon, hole); + return !(JSON.stringify(preciseIntersection) === JSON.stringify(brushPolygon)); + } + return true; + }); + } + + if (!isMultipolygon || notFullyInHoles) { + if (active) { // prefer first encounhtered object if it is also the selection + return candidate.object; } if (intersection.length > max) { max = intersection.length; - result = candidate; + result = candidate.object; } } } @@ -2323,6 +2353,7 @@ OSDAnnotations.StateFreeFormToolAdd = class extends OSDAnnotations.StateFreeForm } let created = false; const ffTool = this.context.freeFormTool; + ffTool.zoom = this.context.canvas.getZoom(); ffTool.recomputeRadius(); const newPolygonPoints = ffTool.getCircleShape(point); let targetIntersection = this.fftFindTarget(point, ffTool, newPolygonPoints, 0); @@ -2390,6 +2421,7 @@ OSDAnnotations.StateFreeFormToolRemove = class extends OSDAnnotations.StateFreeF } const ffTool = this.context.freeFormTool; + ffTool.zoom = this.context.canvas.getZoom(); ffTool.recomputeRadius(); const newPolygonPoints = ffTool.getCircleShape(point); let candidates = this.fftFindTarget(point, ffTool, newPolygonPoints, 50); @@ -2573,6 +2605,7 @@ OSDAnnotations.StateCorrectionTool = class extends OSDAnnotations.StateFreeFormT const ffTool = this.context.freeFormTool, newPolygonPoints = ffTool.getCircleShape(point); + ffTool.zoom = this.context.canvas.getZoom(); let candidates = this.fftFindTarget(point, ffTool, newPolygonPoints, 50); if (this.fftFoundIntersection(candidates)) { diff --git a/modules/annotations/freeFormTool.js b/modules/annotations/freeFormTool.js index 7ff593f..e1a5d8a 100644 --- a/modules/annotations/freeFormTool.js +++ b/modules/annotations/freeFormTool.js @@ -13,6 +13,7 @@ OSDAnnotations.FreeFormTool = class { this.radius = 20; this.mousePos = null; this.SQRT3DIV2 = 0.866025403784; + this.zoom = null; this._context = context; this._update = null; this._created = false; @@ -21,6 +22,14 @@ OSDAnnotations.FreeFormTool = class { USER_INTERFACE.addHtml(`
`, this._context.id); this._node = document.getElementById("annotation-cursor"); + + this._offscreenCanvas = document.createElement('canvas'); + this._offscreenCanvas.width = this._context.overlay._containerWidth; + this._offscreenCanvas.height = this._context.overlay._containerHeight; + this._ctx2d = this._offscreenCanvas.getContext('2d', { willReadFrequently: true }); + + this.MagicWand = OSDAnnotations.makeMagicWand(); + this.ref = VIEWER.scalebar.getReferencedTiledImage(); } /** @@ -37,8 +46,11 @@ OSDAnnotations.FreeFormTool = class { let objectFactory = this._context.getAnnotationObjectFactory(object.factoryID); this._created = created; + this._ctx2d.clearRect(0, 0, this._ctx2d.canvas.width, this._ctx2d.canvas.height); + this._ctx2d.fillStyle = 'white'; + if (objectFactory !== undefined) { - if (objectFactory.factoryID !== "polygon") { //object can be used immedietaly + if (objectFactory.factoryID !== "polygon" && objectFactory.factoryID !== "multipolygon") { //object can be used immedietaly let points = Array.isArray(created) ? points : ( objectFactory.supportsBrush() ? objectFactory.toPointArray(object, @@ -52,7 +64,8 @@ OSDAnnotations.FreeFormTool = class { return; } } else { - let newPolygon = created ? object : this._context.polygonFactory.copy(object, object.points); + const factory = objectFactory.factoryID === "polygon" ? this._context.polygonFactory : this._context.objectFactories.multipolygon; + let newPolygon = created ? object : factory.copy(object, null); this._setupPolygon(newPolygon, object); } @@ -197,12 +210,15 @@ OSDAnnotations.FreeFormTool = class { } try { - this._updatePerformed = this._update(point) || this._updatePerformed; + const cursorPolygon = this.getCircleShape(point); + const polygon = this.polygon.factoryID === "multipolygon" ? this.polygon.points[0] : this.polygon.points; - if (this.polygon) { - this.polygon._setPositionDimensions({}); + const intersect = OSDAnnotations.checkPolygonIntersect(cursorPolygon, polygon); + if (intersect.length !== 0) { + this._updatePerformed = this._update(point) || this._updatePerformed; this._context.canvas.renderAll(); } + } catch (e) { console.warn("FreeFormTool: something went wrong, ignoring...", e); } @@ -251,49 +267,39 @@ OSDAnnotations.FreeFormTool = class { return null; } - //TODO sometimes the greinerHormann cycling, vertices are NaN values, do some measurement and kill after it takes too long (2+s ?) - _union (nextMousePos) { - if (!this.polygon || this._toDistancePointsAsObjects(this.mousePos, nextMousePos) < this.radius / 3) return false; - - let radPoints = this.getCircleShape(nextMousePos); - //console.log(radPoints); - let polyPoints = this.polygon.get("points"); - //avoid 'Leaflet issue' - expecting a polygon that is not 'closed' on points (first != last) - if (this._toDistancePointsAsObjects(polyPoints[0], polyPoints[polyPoints.length - 1]) < this.radius) polyPoints.pop(); - this.mousePos = nextMousePos; + _drawPolygon(polygon) { + this._ctx2d.moveTo(polygon[0].x, polygon[0].y); - let calcSize = OSDAnnotations.PolygonUtilities.approximatePolygonArea; + for (let i = 1; i < polygon.length; i++) { + this._ctx2d.lineTo(polygon[i].x, polygon[i].y); + } + this._ctx2d.lineTo(polygon[0].x, polygon[0].y); + this._ctx2d.closePath(); + } - //compute union - try { - var union = greinerHormann.union(polyPoints, radPoints); - } catch (e) { - console.warn("Unable to unify polygon with tool.", this.polygon, radPoints, e); - return false; + _rasterizePolygons(originalPoints, isPolygon, needsConversion=true) { + const convertPoints = (points) => + points.map(point => this.ref.imageToWindowCoordinates(new OpenSeadragon.Point(point.x, point.y))); + + if (needsConversion) { + originalPoints = isPolygon + ? convertPoints(originalPoints) + : originalPoints.map(convertPoints); } + + const points = originalPoints; + const firstPolygon = isPolygon ? points : points[0]; - if (union) { - if (typeof union[0][0] === 'number') { // single linear ring - return false; - } + this._ctx2d.beginPath(); + this._drawPolygon(firstPolygon); - if (union.length > 1) union = this._unify(union); - - let maxIdx = 0,maxScore = 0; - for (let j = 0; j < union.length; j++) { - let measure = calcSize(union[j]); - if (measure.diffX < this.radius || measure.diffY < this.radius) continue; - let area = measure.diffX * measure.diffY; - let score = 2*area + union[j].length; - if (score > maxScore) { - maxScore = score; - maxIdx = j; - } + if (!isPolygon) { + for (let i = 1; i < points.length; i++) { + this._drawPolygon(points[i]); } - this.polygon.set({points: this.simplifier(union[maxIdx])}); - return true; } - return false; + + this._ctx2d.fill("evenodd"); } //initialize object so that it is ready to be modified @@ -307,6 +313,9 @@ OSDAnnotations.FreeFormTool = class { this._context.addHelperAnnotation(polyObject); } + const isPolygon = polyObject.factoryID === "polygon"; + this._rasterizePolygons(polyObject.points, isPolygon); + polyObject.moveCursor = 'crosshair'; } @@ -317,82 +326,172 @@ OSDAnnotations.FreeFormTool = class { this._setupPolygon(polygon, object); } - //try to merge polygon list into one polygons using 'greinerHormann.union' repeated call and simplyfiing the polygon - _unify(unions) { - let i = 0, len = unions.length ** 2 + 10, primary = [], secondary = []; + _changeFactory(factory, contourPoints) { + let newObject = factory.copy(this.polygon, contourPoints); + newObject.factoryID = factory.factoryID; - unions.forEach(u => { - primary.push(this.simplifier(u)); - }); - while (i < len) { - if (primary.length < 2) break; + if (!this._created) { + this._context.replaceAnnotation(this.polygon, this.initial, true); + this.polygon = newObject; + this._context.replaceAnnotation(this.initial, this.polygon, true); + } else { + this._context.deleteHelperAnnotation(this.polygon); + this.polygon = newObject; + this._context.addHelperAnnotation(this.polygon); + } + } - i++; - let j = 0; - for (; j < primary.length - 1; j += 2) { - let ress = greinerHormann.union(primary[j], primary[j + 1]); + _getValidContours(contours) { + const polygonUtils = OSDAnnotations.PolygonUtilities; + let innerContours = []; + let falseOuterContours = []; + let maxArea = 0; + let outerContour = null; - if (typeof ress[0][0] === 'number') { - ress = [ress]; - } - secondary = ress.concat(secondary); //reverse order for different union call in the next loop + for (let i = 0; i < contours.length; i++) { + const size = polygonUtils.approximatePolygonArea(contours[i].points); + const area = size.diffX * size.diffY; + + if (contours[i].inner) { + //if (area < this.zoom ) continue; // deleting too small holes + innerContours.push(contours[i]); + + } else if (area > maxArea) { + if (outerContour) falseOuterContours.push(outerContour); + maxArea = area; + outerContour = contours[i]; + + } else { + falseOuterContours.push(contours[i]); } - if (j === primary.length - 1) secondary.push(primary[j]); - primary = secondary; - secondary = []; } - return primary; + + if (!outerContour) return innerContours; + + // deleting inner contours (holes) which are found inside deleted outer contours + if (falseOuterContours.length !== 0) { + innerContours = innerContours.filter(inner => { + return falseOuterContours.some(outer => { + + const innerBbox = polygonUtils.getBoundingBox(inner.points); + const outerBbox = polygonUtils.getBoundingBox(outer.points); + + if (polygonUtils.intersectAABB(innerBbox, outerBbox)) { + const intersections = OSDAnnotations.checkPolygonIntersect(inner.points, outer.points); + return intersections.length === 0 || JSON.stringify(intersections) === JSON.stringify(outer.points); + } + return true; + }); + }); + + this._ctx2d.fillStyle = 'white'; + this._ctx2d.clearRect(0, 0, this._ctx2d.canvas.width, this._ctx2d.canvas.height); + + const newContours = [outerContour, ...innerContours].map(contour => contour.points); + this._rasterizePolygons(newContours, false, false); + } + + return [outerContour, ...innerContours]; } - _subtract (nextMousePos) { + _processContours(nextMousePos, fillColor) { if (!this.polygon || this._toDistancePointsAsObjects(this.mousePos, nextMousePos) < this.radius / 3) return false; - let radPoints = this.getCircleShape(nextMousePos); - let polyPoints = this.polygon.get("points"); this.mousePos = nextMousePos; + this._ctx2d.fillStyle = fillColor; + let contours = this._getContours(); + + if (contours.length >= 1 && contours[0].inner) return false; + + if (contours.length === 0) return this.finish(true); // deletion in subtract mode + + if (contours.length === 1) { // polygon + if (this.polygon.factoryID !== "multipolygon") { + this.polygon.set({ points: contours[0].points }); + } else { + this._changeFactory(this._context.polygonFactory, contours[0].points); + } - let calcSize = OSDAnnotations.PolygonUtilities.approximatePolygonArea; + this.polygon._setPositionDimensions({}); + return true; + } - try { - var difference = greinerHormann.diff(polyPoints, radPoints); - } catch (e) { - console.warn("Unable to diff polygon with tool.", this.polygon, radPoints, e); - return false; + // multipolygon + let contourPoints = contours.map(contour => contour.points); + + if (this.polygon.factoryID === "multipolygon") { + this.polygon = this._context.objectFactories.multipolygon.setPoints(this.polygon, contourPoints); + } else { + this._changeFactory(this._context.objectFactories.multipolygon, contourPoints); } + + return true; + } - if (difference) { - let polygon; - if (typeof difference[0][0] === 'number') { // single linear ring - polygon = this.simplifier(difference); - } else { - if (difference.length > 1) difference = this._unify(difference); - - let maxIdx = 0, maxArea = 0, maxScore = 0; - for (let j = 0; j < difference.length; j++) { - let measure = calcSize(difference[j]); - if (measure.diffX < this.radius || measure.diffY < this.radius) continue; - let area = measure.diffX * measure.diffY; - let score = 2*area + difference[j].length; - if (score > maxScore) { - maxArea = area; - maxScore = score; - maxIdx = j; - } - } + _union (nextMousePos) { + return this._processContours(nextMousePos, 'white'); + } - if (maxArea < this.radius * this.radius / 2) { //largest area ceased to exist: finish - delete this.initial.moveCursor; - delete this.polygon.moveCursor; - this.finish(true); - return true; + _subtract (nextMousePos) { + return this._processContours(nextMousePos, 'black'); + } + + _getContours() { + this._rasterizePolygons(this.getCircleShape(this.mousePos), true); + + const imageData = this._ctx2d.getImageData(0, 0, this._ctx2d.canvas.width, this._ctx2d.canvas.height); + const mask = this._getBinaryMask(imageData.data, imageData.width, imageData.height); + if (!mask.bounds) return []; + + let contours = this.MagicWand.traceContours(mask); + contours = this._getValidContours(contours); + contours = this.MagicWand.simplifyContours(contours, 0, 30); + + const imageContours = contours.map(contour => ({ + ...contour, + points: contour.points.map(point => this.ref.windowToImageCoordinates(new OpenSeadragon.Point(point.x, point.y))) + })); + + return imageContours; + } + + _getBinaryMask(data, width, height) { + let mask = new Uint8ClampedArray(width * height); + let maxX = -1, minX = width, maxY = -1, minY = height, bounds; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + + if (r === 255) { + mask[y * width + x] = 1; + + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; } + } + } - polygon = this.simplifier(difference[maxIdx]); + if (maxX === -1 || maxY === -1) { + bounds = null; + } else { + bounds = { + minX: minX, + minY: minY, + maxX: maxX, + maxY: maxY } - this.polygon.set({points: polygon}); - return true; } - return false; + + return { + data: mask, + width: width, + height: height, + bounds: bounds, + } } _toDistancePointsAsObjects(pointA, pointB) { diff --git a/modules/annotations/objectGenericFactories.js b/modules/annotations/objectGenericFactories.js index e895f7f..d4831b1 100644 --- a/modules/annotations/objectGenericFactories.js +++ b/modules/annotations/objectGenericFactories.js @@ -1579,3 +1579,106 @@ OSDAnnotations.Group = class extends OSDAnnotations.AnnotationObjectFactory { return "Complex Annotation"; } }; + +OSDAnnotations.Multipolygon = class extends OSDAnnotations.AnnotationObjectFactory { + + constructor(context, autoCreationStrategy, presetManager) { + super(context, autoCreationStrategy, presetManager, "multipolygon", "path"); + this._polygonFactory = new OSDAnnotations.Polygon(context, autoCreationStrategy, presetManager); + } + + getIcon() { + return "view_timeline"; + } + + fabricStructure() { + return "path"; + } + + getDescription(ofObject) { + return `Multipolygon [${Math.round(ofObject.left)}, ${Math.round(ofObject.top)}]`; + } + + title() { + return "Multipolygon"; + } + + exportsGeometry() { + return ["path"]; + } + + exports() { + return ["points"]; + } + + isImplicit() { + return false; + } + + create(parameters, options) { + const path = this._createPathFromPoints(parameters); + let multipolygon = new fabric.Path(path); + + this.configure(multipolygon, options); + multipolygon.points = parameters; + return multipolygon; + } + + configure(object, options) { + super.configure(object, options); + object.fillRule = "evenodd"; + } + + _createPathFromPoints(multiPoints) { + if (multiPoints.length === 0) return; + let pathString = ''; + + for (let i = 0; i < multiPoints.length; i++) { + const points = multiPoints[i]; + + if (i !== 0) pathString += ' '; + pathString += `M ${points[0].x} ${points[0].y}`; + + points.forEach(point => { + pathString += ` L ${point.x} ${point.y}`; + }); + + pathString += ' z'; + if (i !== multiPoints.length) pathString += ' '; + } + + return pathString; + } + + copy(ofObject, parameters=undefined) { + const props = this.copyProperties(ofObject); + delete props.left; + delete props.top; + delete props.width; + delete props.height; + delete props.points; + + if (!parameters) parameters = ofObject.points; + props.points = parameters; + + return new fabric.Path(this._createPathFromPoints(parameters), props); + } + + setPoints(object, points) { + object.points = points; + const newPathString = this._createPathFromPoints(points); + + object._setPath(fabric.util.parsePath(newPathString)); + object.setCoords(); + return object; + } + + getArea(theObject) { + let area = this._polygonFactory.getArea({points: theObject.points[0]}); + + for (let i = 1; i < theObject.points.length; i++) { + area -= this._polygonFactory.getArea({points: theObject.points[i]}); + } + return area; + } +}; diff --git a/modules/annotations/objects.js b/modules/annotations/objects.js index 07a8fe7..47f9030 100644 --- a/modules/annotations/objects.js +++ b/modules/annotations/objects.js @@ -586,16 +586,32 @@ OSDAnnotations.PolygonUtilities = { }, approximatePolygonArea: function (points) { - if (points.length < 3) return { diffX: 0, diffY: 0 }; + if (!points || points.length < 3) return { diffX: 0, diffY: 0 }; + const bbox = this.getBoundingBox(points); + + return { diffX: bbox.width, diffY: bbox.height }; + }, + + getBoundingBox: function (points) { + if (!points || points.length === 0) return null; + let maxX = points[0].x, minX = points[0].x, maxY = points[0].y, minY = points[0].y; - for (let i = 1; i < points.length; i++) { - maxX = Math.max(maxX, points[i].x); - maxY = Math.max(maxY, points[i].y); - minX = Math.min(minX, points[i].x); - minY = Math.min(minY, points[i].y); + + for (let i = 0; i < points.length; i++) { + const point = points[i]; + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); } - return { diffX: maxX - minX, diffY: maxY - minY }; - }, + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + }; + }, /** * https://gist.github.com/cwleonard/e124d63238bda7a3cbfa