import Konva from "konva"; import { memo, useContext, useEffect, useMemo } from "react"; import { Group, Line } from "react-konva"; import { destroy, detach, getRoot, isAlive, types } from "mobx-state-tree"; import Constants from "../core/Constants"; import NormalizationMixin from "../mixins/Normalization"; import RegionsMixin from "../mixins/Regions"; import Registry from "../core/Registry"; import { ImageModel } from "../tags/object/Image"; import { LabelOnPolygon } from "../components/ImageView/LabelOnRegion"; import { PolygonPoint, PolygonPointView } from "./PolygonPoint"; import { green } from "@ant-design/colors"; import { guidGenerator } from "../core/Helpers"; import { AreaMixin } from "../mixins/AreaMixin"; import { useRegionStyles } from "../hooks/useRegionColor"; import { AliveRegion } from "./AliveRegion"; import { KonvaRegionMixin } from "../mixins/KonvaRegion"; import { observer } from "mobx-react"; import { createDragBoundFunc } from "../utils/image"; import { ImageViewContext } from "../components/ImageView/ImageViewContext"; import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from "../components/ImageView/Image"; const Model = types .model({ id: types.optional(types.identifier, guidGenerator), pid: types.optional(types.string, guidGenerator), type: "polygonregion", object: types.late(() => types.reference(ImageModel)), points: types.array(types.union(PolygonPoint, types.array(types.number)), []), closed: true, }) .volatile(() => ({ mouseOverStartPoint: false, selectedPoint: null, hideable: true, _supportsTransform: true, useTransformer: true, preferTransformer: false, supportsRotate: false, supportsScale: true, })) .views((self) => ({ get store() { return getRoot(self); }, get bboxCoords() { if (!self.points?.length || !isAlive(self)) return {}; const bbox = self.points.reduce( (bboxCoords, point) => ({ left: Math.min(bboxCoords.left, point.x), top: Math.min(bboxCoords.top, point.y), right: Math.max(bboxCoords.right, point.x), bottom: Math.max(bboxCoords.bottom, point.y), }), { left: self.points[0].x, top: self.points[0].y, right: self.points[0].x, bottom: self.points[0].y, }, ); return bbox; }, get flattenedPoints() { return getFlattenedPoints(this.points); }, })) .actions((self) => { return { afterCreate() { if (!self.points.length) return; if (!self.points[0].id) { self.points = self.points.map(([x, y], index) => ({ id: guidGenerator(), x, y, size: self.pointSize, style: self.pointStyle, index, })); } self.checkSizes(); }, /** * @todo excess method; better to handle click only on start point * Handler for mouse on start point of Polygon * @param {boolean} val */ setMouseOverStartPoint(value) { self.mouseOverStartPoint = value; }, // @todo not used setSelectedPoint(point) { if (self.selectedPoint) { self.selectedPoint.selected = false; } point.selected = true; self.selectedPoint = point; }, handleMouseMove({ e, flattenedPoints }) { const { offsetX, offsetY } = e.evt; const [cursorX, cursorY] = self.parent.fixZoomedCoords([offsetX, offsetY]); const [x, y] = getAnchorPoint({ flattenedPoints, cursorX, cursorY }); const group = e.currentTarget; const layer = e.currentTarget.getLayer(); const zoom = self.parent.zoomScale; moveHoverAnchor({ point: [x, y], group, layer, zoom }); }, handleMouseLeave({ e }) { removeHoverAnchor({ layer: e.currentTarget.getLayer() }); }, handleLineClick({ e, flattenedPoints, insertIdx }) { if (!self.closed || !self.selected) return; e.cancelBubble = true; removeHoverAnchor({ layer: e.currentTarget.getLayer() }); const { offsetX, offsetY } = e.evt; const [cursorX, cursorY] = self.parent.fixZoomedCoords([offsetX, offsetY]); const point = getAnchorPoint({ flattenedPoints, cursorX, cursorY }); self.insertPoint(insertIdx, point[0], point[1]); }, deletePoint(point) { const willNotEliminateClosedShape = self.points.length <= 3 && point.parent.closed; const isLastPoint = self.points.length === 1; const isSelected = self.selectedPoint === point; if (willNotEliminateClosedShape || isLastPoint) return; if (isSelected) self.selectedPoint = null; destroy(point); }, addPoint(x, y) { if (self.closed) return; const point = self.control?.getSnappedPoint({ x, y }); self._addPoint(point.x, point.y); }, setPoints(points) { self.points.forEach((p, idx) => { p.x = points[idx * 2]; p.y = points[idx * 2 + 1]; }); }, insertPoint(insertIdx, x, y) { const pointCoords = self.control?.getSnappedPoint({ x: self.parent.canvasToInternalX(x), y: self.parent.canvasToInternalY(y), }); const isMatchWithPrevPoint = self.points[insertIdx - 1] && self.parent.isSamePixel(pointCoords, self.points[insertIdx - 1]); const isMatchWithNextPoint = self.points[insertIdx] && self.parent.isSamePixel(pointCoords, self.points[insertIdx]); if (isMatchWithPrevPoint || isMatchWithNextPoint) { return; } const p = { id: guidGenerator(), x: pointCoords.x, y: pointCoords.y, size: self.pointSize, style: self.pointStyle, index: self.points.length, }; self.points.splice(insertIdx, 0, p); return self.points[insertIdx]; }, _addPoint(x, y) { const firstPoint = self.points[0]; // This is mostly for "snap to pixel" mode, // 'cause there is also an ability to close polygon by clicking on the first point precisely if (self.parent.isSamePixel(firstPoint, { x, y })) { self.closePoly(); return; } self.points.push({ id: guidGenerator(), x, y, size: self.pointSize, style: self.pointStyle, index: self.points.length, }); }, closePoly() { if (self.closed || self.points.length < 3) return; self.closed = true; }, canClose(x, y) { if (self.points.length < 2) return false; const p1 = self.points[0]; const p2 = { x, y }; const r = 50; const dist_points = (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2; if (dist_points < r) { return true; } return false; }, destroyRegion() { detach(self.points); destroy(self.points); }, afterUnselectRegion() { if (self.selectedPoint) { self.selectedPoint.selected = false; } // self.points.forEach(p => p.computeOffset()); }, setScale(x, y) { self.scaleX = x; self.scaleY = y; }, updateImageSize() {}, /** * @example * { * "original_width": 1920, * "original_height": 1280, * "image_rotation": 0, * "value": { * "points": [[2, 2], [3.5, 8.1], [3.5, 12.6]], * "polygonlabels": ["Car"] * } * } * @typedef {Object} PolygonRegionResult * @property {number} original_width width of the original image (px) * @property {number} original_height height of the original image (px) * @property {number} image_rotation rotation degree of the image (deg) * @property {Object} value * @property {number[][]} value.points list of (x, y) coordinates of the polygon by percentage of the image size (0-100) */ /** * @return {PolygonRegionResult} */ serialize() { const value = { points: self.points.map((p) => [p.x, p.y]), closed: self.closed, }; return self.parent.createSerializedResult(self, value); }, }; }); const PolygonRegionModel = types.compose( "PolygonRegionModel", RegionsMixin, AreaMixin, NormalizationMixin, KonvaRegionMixin, Model, ); /** * Get coordinates of anchor point * @param {array} flattenedPoints * @param {number} cursorX coordinates of cursor X * @param {number} cursorY coordinates of cursor Y */ function getAnchorPoint({ flattenedPoints, cursorX, cursorY }) { const [point1X, point1Y, point2X, point2Y] = flattenedPoints; const y = ((point2X - point1X) * (point2X * point1Y - point1X * point2Y) + (point2X - point1X) * (point2Y - point1Y) * cursorX + (point2Y - point1Y) * (point2Y - point1Y) * cursorY) / ((point2Y - point1Y) * (point2Y - point1Y) + (point2X - point1X) * (point2X - point1X)); const x = cursorX - ((point2Y - point1Y) * (point2X * point1Y - point1X * point2Y + cursorX * (point2Y - point1Y) - cursorY * (point2X - point1X))) / ((point2Y - point1Y) * (point2Y - point1Y) + (point2X - point1X) * (point2X - point1X)); return [x, y]; } function getFlattenedPoints(points) { const p = points.map((p) => [p.canvasX, p.canvasY]); return p.reduce((flattenedPoints, point) => flattenedPoints.concat(point), []); } function getHoverAnchor({ layer }) { return layer.findOne(".hoverAnchor"); } /** * Create new anchor for current polygon */ function createHoverAnchor({ point, group, layer, zoom }) { const hoverAnchor = new Konva.Circle({ name: "hoverAnchor", x: point[0], y: point[1], stroke: green.primary, fill: green[0], scaleX: 1 / (zoom || 1), scaleY: 1 / (zoom || 1), strokeWidth: 2, radius: 5, }); group.add(hoverAnchor); layer.draw(); return hoverAnchor; } function moveHoverAnchor({ point, group, layer, zoom }) { const hoverAnchor = getHoverAnchor({ layer }) || createHoverAnchor({ point, group, layer, zoom }); hoverAnchor.to({ x: point[0], y: point[1], duration: 0 }); } function removeHoverAnchor({ layer }) { const hoverAnchor = getHoverAnchor({ layer }); if (!hoverAnchor) return; hoverAnchor.destroy(); layer.draw(); } const Poly = memo( observer(({ item, colors, dragProps, draggable }) => { const { flattenedPoints } = item; const name = "poly"; return ( { if (e.target !== e.currentTarget) return; const t = e.target; const d = [t.getAttr("x", 0), t.getAttr("y", 0)]; const scale = [t.getAttr("scaleX", 1), t.getAttr("scaleY", 1)]; const points = t.getAttr("points"); item.setPoints( points.reduce((result, coord, idx) => { const isXCoord = idx % 2 === 0; if (isXCoord) { const point = item.control?.getSnappedPoint({ x: item.parent.canvasToInternalX(coord * scale[0] + d[0]), y: item.parent.canvasToInternalY(points[idx + 1] * scale[1] + d[1]), }); result.push(point.x, point.y); } return result; }, []), ); t.setAttr("x", 0); t.setAttr("y", 0); t.setAttr("scaleX", 1); t.setAttr("scaleY", 1); }} draggable={draggable} /> ); }), ); /** * Line between 2 points */ const Edge = observer(({ name, item, idx, p1, p2, closed, regionStyles }) => { const insertIdx = idx + 1; // idx1 + 1 or idx2 const flattenedPoints = [p1.canvasX, p1.canvasY, p2.canvasX, p2.canvasY]; const lineProps = closed ? { stroke: "transparent", strokeWidth: regionStyles.strokeWidth, strokeScaleEnabled: false, } : { stroke: regionStyles.strokeColor, strokeWidth: regionStyles.strokeWidth, strokeScaleEnabled: false, }; return ( item.handleLineClick({ e, flattenedPoints, insertIdx })} onMouseMove={(e) => { if (!item.closed || !item.selected || item.isReadOnly()) return; item.handleMouseMove({ e, flattenedPoints }); }} onMouseLeave={(e) => item.handleMouseLeave({ e })} > ); }); const Edges = memo( observer(({ item, regionStyles }) => { const { points, closed } = item; const name = "borders"; if (item.closed && (item.parent.useTransformer || !item.selected)) { return null; } return ( {points.map((p, idx) => { const idx1 = idx; const idx2 = idx === points.length - 1 ? 0 : idx + 1; if (!closed && idx2 === 0) { return null; } return ( ); })} ); }), ); const HtxPolygonView = ({ item, setShapeRef }) => { const { store } = item; const { suggestion } = useContext(ImageViewContext) ?? {}; const regionStyles = useRegionStyles(item, { useStrokeAsFill: true, }); function renderCircle({ points, idx }) { const name = `anchor_${points.length}_${idx}`; const point = points[idx]; if (!item.closed || (item.closed && item.selected)) { return ; } } function renderCircles(points) { const name = "anchors"; if (item.closed && (item.parent.useTransformer || !item.selected)) { return null; } return ( {points.map((p, idx) => renderCircle({ points, idx }))} ); } const dragProps = useMemo(() => { let isDragging = false; return { onDragStart: (e) => { if (e.target !== e.currentTarget) return; if (item.parent.getSkipInteractions()) { e.currentTarget.stopDrag(e.evt); return; } isDragging = true; item.annotation.setDragMode(true); item.annotation.history.freeze(item.id); }, dragBoundFunc: createDragBoundFunc(item, { x: -item.bboxCoords.left, y: -item.bboxCoords.top }), onDragEnd: (e) => { if (!isDragging) return; const t = e.target; if (e.target === e.currentTarget) { item.annotation.setDragMode(false); const point = item.control?.getSnappedPoint({ x: item.parent?.canvasToInternalX(t.getAttr("x")), y: item.parent?.canvasToInternalY(t.getAttr("y")), }); point.x = item.parent?.internalToCanvasX(point.x); point.y = item.parent?.internalToCanvasY(point.y); item.points.forEach((p) => p.movePoint(point.x, point.y)); item.annotation.history.unfreeze(item.id); } t.setAttr("x", 0); t.setAttr("y", 0); isDragging = false; }, }; }, [item.bboxCoords.left, item.bboxCoords.top]); useEffect(() => { if (!item.closed) item.control.tools.Polygon.resumeUnfinishedRegion(item); }, [item.closed]); if (!item.parent) return null; if (!item.inViewPort) return null; const stage = item.parent?.stageRef; return ( setShapeRef(el)} onMouseOver={() => { if (store.annotationStore.selected.isLinkingMode) { item.setHighlight(true); } item.updateCursor(true); }} onMouseOut={() => { if (store.annotationStore.selected.isLinkingMode) { item.setHighlight(false); } item.updateCursor(); }} onClick={(e) => { // create regions over another regions with Cmd/Ctrl pressed if (item.parent.getSkipInteractions()) return; if (item.isDrawing) return; e.cancelBubble = true; if (!item.closed) return; if (store.annotationStore.selected.isLinkingMode) { stage.container().style.cursor = Constants.DEFAULT_CURSOR; } item.setHighlight(false); item.onClickRegion(e); }} {...dragProps} draggable={!item.isReadOnly() && (!item.inSelection || item.parent?.selectedRegions?.length === 1)} listening={!suggestion} > {item.mouseOverStartPoint} {item.points && item.closed ? ( 1} /> ) : null} {item.points && !item.isReadOnly() ? : null} {item.points && !item.isReadOnly() ? renderCircles(item.points) : null} ); }; const HtxPolygon = AliveRegion(HtxPolygonView); Registry.addTag("polygonregion", PolygonRegionModel, HtxPolygon); Registry.addRegionType(PolygonRegionModel, "image", (value) => !!value.points); export { PolygonRegionModel, HtxPolygon };