PolygonRegion.jsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import Konva from "konva";
  2. import { memo, useContext, useEffect, useMemo } from "react";
  3. import { Group, Line } from "react-konva";
  4. import { destroy, detach, getRoot, isAlive, types } from "mobx-state-tree";
  5. import Constants from "../core/Constants";
  6. import NormalizationMixin from "../mixins/Normalization";
  7. import RegionsMixin from "../mixins/Regions";
  8. import Registry from "../core/Registry";
  9. import { ImageModel } from "../tags/object/Image";
  10. import { LabelOnPolygon } from "../components/ImageView/LabelOnRegion";
  11. import { PolygonPoint, PolygonPointView } from "./PolygonPoint";
  12. import { green } from "@ant-design/colors";
  13. import { guidGenerator } from "../core/Helpers";
  14. import { AreaMixin } from "../mixins/AreaMixin";
  15. import { useRegionStyles } from "../hooks/useRegionColor";
  16. import { AliveRegion } from "./AliveRegion";
  17. import { KonvaRegionMixin } from "../mixins/KonvaRegion";
  18. import { observer } from "mobx-react";
  19. import { createDragBoundFunc } from "../utils/image";
  20. import { ImageViewContext } from "../components/ImageView/ImageViewContext";
  21. import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from "../components/ImageView/Image";
  22. const Model = types
  23. .model({
  24. id: types.optional(types.identifier, guidGenerator),
  25. pid: types.optional(types.string, guidGenerator),
  26. type: "polygonregion",
  27. object: types.late(() => types.reference(ImageModel)),
  28. points: types.array(types.union(PolygonPoint, types.array(types.number)), []),
  29. closed: true,
  30. })
  31. .volatile(() => ({
  32. mouseOverStartPoint: false,
  33. selectedPoint: null,
  34. hideable: true,
  35. _supportsTransform: true,
  36. useTransformer: true,
  37. preferTransformer: false,
  38. supportsRotate: false,
  39. supportsScale: true,
  40. }))
  41. .views((self) => ({
  42. get store() {
  43. return getRoot(self);
  44. },
  45. get bboxCoords() {
  46. if (!self.points?.length || !isAlive(self)) return {};
  47. const bbox = self.points.reduce(
  48. (bboxCoords, point) => ({
  49. left: Math.min(bboxCoords.left, point.x),
  50. top: Math.min(bboxCoords.top, point.y),
  51. right: Math.max(bboxCoords.right, point.x),
  52. bottom: Math.max(bboxCoords.bottom, point.y),
  53. }),
  54. {
  55. left: self.points[0].x,
  56. top: self.points[0].y,
  57. right: self.points[0].x,
  58. bottom: self.points[0].y,
  59. },
  60. );
  61. return bbox;
  62. },
  63. get flattenedPoints() {
  64. return getFlattenedPoints(this.points);
  65. },
  66. }))
  67. .actions((self) => {
  68. return {
  69. afterCreate() {
  70. if (!self.points.length) return;
  71. if (!self.points[0].id) {
  72. self.points = self.points.map(([x, y], index) => ({
  73. id: guidGenerator(),
  74. x,
  75. y,
  76. size: self.pointSize,
  77. style: self.pointStyle,
  78. index,
  79. }));
  80. }
  81. self.checkSizes();
  82. },
  83. /**
  84. * @todo excess method; better to handle click only on start point
  85. * Handler for mouse on start point of Polygon
  86. * @param {boolean} val
  87. */
  88. setMouseOverStartPoint(value) {
  89. self.mouseOverStartPoint = value;
  90. },
  91. // @todo not used
  92. setSelectedPoint(point) {
  93. if (self.selectedPoint) {
  94. self.selectedPoint.selected = false;
  95. }
  96. point.selected = true;
  97. self.selectedPoint = point;
  98. },
  99. handleMouseMove({ e, flattenedPoints }) {
  100. const { offsetX, offsetY } = e.evt;
  101. const [cursorX, cursorY] = self.parent.fixZoomedCoords([offsetX, offsetY]);
  102. const [x, y] = getAnchorPoint({ flattenedPoints, cursorX, cursorY });
  103. const group = e.currentTarget;
  104. const layer = e.currentTarget.getLayer();
  105. const zoom = self.parent.zoomScale;
  106. moveHoverAnchor({ point: [x, y], group, layer, zoom });
  107. },
  108. handleMouseLeave({ e }) {
  109. removeHoverAnchor({ layer: e.currentTarget.getLayer() });
  110. },
  111. handleLineClick({ e, flattenedPoints, insertIdx }) {
  112. if (!self.closed || !self.selected) return;
  113. e.cancelBubble = true;
  114. removeHoverAnchor({ layer: e.currentTarget.getLayer() });
  115. const { offsetX, offsetY } = e.evt;
  116. const [cursorX, cursorY] = self.parent.fixZoomedCoords([offsetX, offsetY]);
  117. const point = getAnchorPoint({ flattenedPoints, cursorX, cursorY });
  118. self.insertPoint(insertIdx, point[0], point[1]);
  119. },
  120. deletePoint(point) {
  121. const willNotEliminateClosedShape = self.points.length <= 3 && point.parent.closed;
  122. const isLastPoint = self.points.length === 1;
  123. const isSelected = self.selectedPoint === point;
  124. if (willNotEliminateClosedShape || isLastPoint) return;
  125. if (isSelected) self.selectedPoint = null;
  126. destroy(point);
  127. },
  128. addPoint(x, y) {
  129. if (self.closed) return;
  130. const point = self.control?.getSnappedPoint({ x, y });
  131. self._addPoint(point.x, point.y);
  132. },
  133. setPoints(points) {
  134. self.points.forEach((p, idx) => {
  135. p.x = points[idx * 2];
  136. p.y = points[idx * 2 + 1];
  137. });
  138. },
  139. insertPoint(insertIdx, x, y) {
  140. const pointCoords = self.control?.getSnappedPoint({
  141. x: self.parent.canvasToInternalX(x),
  142. y: self.parent.canvasToInternalY(y),
  143. });
  144. const isMatchWithPrevPoint =
  145. self.points[insertIdx - 1] && self.parent.isSamePixel(pointCoords, self.points[insertIdx - 1]);
  146. const isMatchWithNextPoint =
  147. self.points[insertIdx] && self.parent.isSamePixel(pointCoords, self.points[insertIdx]);
  148. if (isMatchWithPrevPoint || isMatchWithNextPoint) {
  149. return;
  150. }
  151. const p = {
  152. id: guidGenerator(),
  153. x: pointCoords.x,
  154. y: pointCoords.y,
  155. size: self.pointSize,
  156. style: self.pointStyle,
  157. index: self.points.length,
  158. };
  159. self.points.splice(insertIdx, 0, p);
  160. return self.points[insertIdx];
  161. },
  162. _addPoint(x, y) {
  163. const firstPoint = self.points[0];
  164. // This is mostly for "snap to pixel" mode,
  165. // 'cause there is also an ability to close polygon by clicking on the first point precisely
  166. if (self.parent.isSamePixel(firstPoint, { x, y })) {
  167. self.closePoly();
  168. return;
  169. }
  170. self.points.push({
  171. id: guidGenerator(),
  172. x,
  173. y,
  174. size: self.pointSize,
  175. style: self.pointStyle,
  176. index: self.points.length,
  177. });
  178. },
  179. closePoly() {
  180. if (self.closed || self.points.length < 3) return;
  181. self.closed = true;
  182. },
  183. canClose(x, y) {
  184. if (self.points.length < 2) return false;
  185. const p1 = self.points[0];
  186. const p2 = { x, y };
  187. const r = 50;
  188. const dist_points = (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
  189. if (dist_points < r) {
  190. return true;
  191. }
  192. return false;
  193. },
  194. destroyRegion() {
  195. detach(self.points);
  196. destroy(self.points);
  197. },
  198. afterUnselectRegion() {
  199. if (self.selectedPoint) {
  200. self.selectedPoint.selected = false;
  201. }
  202. // self.points.forEach(p => p.computeOffset());
  203. },
  204. setScale(x, y) {
  205. self.scaleX = x;
  206. self.scaleY = y;
  207. },
  208. updateImageSize() {},
  209. /**
  210. * @example
  211. * {
  212. * "original_width": 1920,
  213. * "original_height": 1280,
  214. * "image_rotation": 0,
  215. * "value": {
  216. * "points": [[2, 2], [3.5, 8.1], [3.5, 12.6]],
  217. * "polygonlabels": ["Car"]
  218. * }
  219. * }
  220. * @typedef {Object} PolygonRegionResult
  221. * @property {number} original_width width of the original image (px)
  222. * @property {number} original_height height of the original image (px)
  223. * @property {number} image_rotation rotation degree of the image (deg)
  224. * @property {Object} value
  225. * @property {number[][]} value.points list of (x, y) coordinates of the polygon by percentage of the image size (0-100)
  226. */
  227. /**
  228. * @return {PolygonRegionResult}
  229. */
  230. serialize() {
  231. const value = {
  232. points: self.points.map((p) => [p.x, p.y]),
  233. closed: self.closed,
  234. };
  235. return self.parent.createSerializedResult(self, value);
  236. },
  237. };
  238. });
  239. const PolygonRegionModel = types.compose(
  240. "PolygonRegionModel",
  241. RegionsMixin,
  242. AreaMixin,
  243. NormalizationMixin,
  244. KonvaRegionMixin,
  245. Model,
  246. );
  247. /**
  248. * Get coordinates of anchor point
  249. * @param {array} flattenedPoints
  250. * @param {number} cursorX coordinates of cursor X
  251. * @param {number} cursorY coordinates of cursor Y
  252. */
  253. function getAnchorPoint({ flattenedPoints, cursorX, cursorY }) {
  254. const [point1X, point1Y, point2X, point2Y] = flattenedPoints;
  255. const y =
  256. ((point2X - point1X) * (point2X * point1Y - point1X * point2Y) +
  257. (point2X - point1X) * (point2Y - point1Y) * cursorX +
  258. (point2Y - point1Y) * (point2Y - point1Y) * cursorY) /
  259. ((point2Y - point1Y) * (point2Y - point1Y) + (point2X - point1X) * (point2X - point1X));
  260. const x =
  261. cursorX -
  262. ((point2Y - point1Y) *
  263. (point2X * point1Y - point1X * point2Y + cursorX * (point2Y - point1Y) - cursorY * (point2X - point1X))) /
  264. ((point2Y - point1Y) * (point2Y - point1Y) + (point2X - point1X) * (point2X - point1X));
  265. return [x, y];
  266. }
  267. function getFlattenedPoints(points) {
  268. const p = points.map((p) => [p.canvasX, p.canvasY]);
  269. return p.reduce((flattenedPoints, point) => flattenedPoints.concat(point), []);
  270. }
  271. function getHoverAnchor({ layer }) {
  272. return layer.findOne(".hoverAnchor");
  273. }
  274. /**
  275. * Create new anchor for current polygon
  276. */
  277. function createHoverAnchor({ point, group, layer, zoom }) {
  278. const hoverAnchor = new Konva.Circle({
  279. name: "hoverAnchor",
  280. x: point[0],
  281. y: point[1],
  282. stroke: green.primary,
  283. fill: green[0],
  284. scaleX: 1 / (zoom || 1),
  285. scaleY: 1 / (zoom || 1),
  286. strokeWidth: 2,
  287. radius: 5,
  288. });
  289. group.add(hoverAnchor);
  290. layer.draw();
  291. return hoverAnchor;
  292. }
  293. function moveHoverAnchor({ point, group, layer, zoom }) {
  294. const hoverAnchor = getHoverAnchor({ layer }) || createHoverAnchor({ point, group, layer, zoom });
  295. hoverAnchor.to({ x: point[0], y: point[1], duration: 0 });
  296. }
  297. function removeHoverAnchor({ layer }) {
  298. const hoverAnchor = getHoverAnchor({ layer });
  299. if (!hoverAnchor) return;
  300. hoverAnchor.destroy();
  301. layer.draw();
  302. }
  303. const Poly = memo(
  304. observer(({ item, colors, dragProps, draggable }) => {
  305. const { flattenedPoints } = item;
  306. const name = "poly";
  307. return (
  308. <Group key={name} name={name}>
  309. <Line
  310. name="_transformable"
  311. lineJoin="round"
  312. lineCap="square"
  313. stroke={colors.strokeColor}
  314. strokeWidth={colors.strokeWidth}
  315. strokeScaleEnabled={false}
  316. perfectDrawEnabled={false}
  317. shadowForStrokeEnabled={false}
  318. points={flattenedPoints}
  319. fill={colors.fillColor}
  320. closed={true}
  321. {...dragProps}
  322. onTransformEnd={(e) => {
  323. if (e.target !== e.currentTarget) return;
  324. const t = e.target;
  325. const d = [t.getAttr("x", 0), t.getAttr("y", 0)];
  326. const scale = [t.getAttr("scaleX", 1), t.getAttr("scaleY", 1)];
  327. const points = t.getAttr("points");
  328. item.setPoints(
  329. points.reduce((result, coord, idx) => {
  330. const isXCoord = idx % 2 === 0;
  331. if (isXCoord) {
  332. const point = item.control?.getSnappedPoint({
  333. x: item.parent.canvasToInternalX(coord * scale[0] + d[0]),
  334. y: item.parent.canvasToInternalY(points[idx + 1] * scale[1] + d[1]),
  335. });
  336. result.push(point.x, point.y);
  337. }
  338. return result;
  339. }, []),
  340. );
  341. t.setAttr("x", 0);
  342. t.setAttr("y", 0);
  343. t.setAttr("scaleX", 1);
  344. t.setAttr("scaleY", 1);
  345. }}
  346. draggable={draggable}
  347. />
  348. </Group>
  349. );
  350. }),
  351. );
  352. /**
  353. * Line between 2 points
  354. */
  355. const Edge = observer(({ name, item, idx, p1, p2, closed, regionStyles }) => {
  356. const insertIdx = idx + 1; // idx1 + 1 or idx2
  357. const flattenedPoints = [p1.canvasX, p1.canvasY, p2.canvasX, p2.canvasY];
  358. const lineProps = closed
  359. ? {
  360. stroke: "transparent",
  361. strokeWidth: regionStyles.strokeWidth,
  362. strokeScaleEnabled: false,
  363. }
  364. : {
  365. stroke: regionStyles.strokeColor,
  366. strokeWidth: regionStyles.strokeWidth,
  367. strokeScaleEnabled: false,
  368. };
  369. return (
  370. <Group
  371. key={name}
  372. name={name}
  373. onClick={(e) => item.handleLineClick({ e, flattenedPoints, insertIdx })}
  374. onMouseMove={(e) => {
  375. if (!item.closed || !item.selected || item.isReadOnly()) return;
  376. item.handleMouseMove({ e, flattenedPoints });
  377. }}
  378. onMouseLeave={(e) => item.handleMouseLeave({ e })}
  379. >
  380. <Line
  381. lineJoin="round"
  382. opacity={1}
  383. points={flattenedPoints}
  384. hitStrokeWidth={20}
  385. strokeScaleEnabled={false}
  386. perfectDrawEnabled={false}
  387. shadowForStrokeEnabled={false}
  388. {...lineProps}
  389. />
  390. </Group>
  391. );
  392. });
  393. const Edges = memo(
  394. observer(({ item, regionStyles }) => {
  395. const { points, closed } = item;
  396. const name = "borders";
  397. if (item.closed && (item.parent.useTransformer || !item.selected)) {
  398. return null;
  399. }
  400. return (
  401. <Group key={name} name={name}>
  402. {points.map((p, idx) => {
  403. const idx1 = idx;
  404. const idx2 = idx === points.length - 1 ? 0 : idx + 1;
  405. if (!closed && idx2 === 0) {
  406. return null;
  407. }
  408. return (
  409. <Edge
  410. key={`border_${idx1}_${idx2}`}
  411. name={`border_${idx1}_${idx2}`}
  412. item={item}
  413. idx={idx1}
  414. p1={points[idx]}
  415. p2={points[idx2]}
  416. closed={closed}
  417. regionStyles={regionStyles}
  418. />
  419. );
  420. })}
  421. </Group>
  422. );
  423. }),
  424. );
  425. const HtxPolygonView = ({ item, setShapeRef }) => {
  426. const { store } = item;
  427. const { suggestion } = useContext(ImageViewContext) ?? {};
  428. const regionStyles = useRegionStyles(item, {
  429. useStrokeAsFill: true,
  430. });
  431. function renderCircle({ points, idx }) {
  432. const name = `anchor_${points.length}_${idx}`;
  433. const point = points[idx];
  434. if (!item.closed || (item.closed && item.selected)) {
  435. return <PolygonPointView item={point} name={name} key={name} />;
  436. }
  437. }
  438. function renderCircles(points) {
  439. const name = "anchors";
  440. if (item.closed && (item.parent.useTransformer || !item.selected)) {
  441. return null;
  442. }
  443. return (
  444. <Group key={name} name={name}>
  445. {points.map((p, idx) => renderCircle({ points, idx }))}
  446. </Group>
  447. );
  448. }
  449. const dragProps = useMemo(() => {
  450. let isDragging = false;
  451. return {
  452. onDragStart: (e) => {
  453. if (e.target !== e.currentTarget) return;
  454. if (item.parent.getSkipInteractions()) {
  455. e.currentTarget.stopDrag(e.evt);
  456. return;
  457. }
  458. isDragging = true;
  459. item.annotation.setDragMode(true);
  460. item.annotation.history.freeze(item.id);
  461. },
  462. dragBoundFunc: createDragBoundFunc(item, { x: -item.bboxCoords.left, y: -item.bboxCoords.top }),
  463. onDragEnd: (e) => {
  464. if (!isDragging) return;
  465. const t = e.target;
  466. if (e.target === e.currentTarget) {
  467. item.annotation.setDragMode(false);
  468. const point = item.control?.getSnappedPoint({
  469. x: item.parent?.canvasToInternalX(t.getAttr("x")),
  470. y: item.parent?.canvasToInternalY(t.getAttr("y")),
  471. });
  472. point.x = item.parent?.internalToCanvasX(point.x);
  473. point.y = item.parent?.internalToCanvasY(point.y);
  474. item.points.forEach((p) => p.movePoint(point.x, point.y));
  475. item.annotation.history.unfreeze(item.id);
  476. }
  477. t.setAttr("x", 0);
  478. t.setAttr("y", 0);
  479. isDragging = false;
  480. },
  481. };
  482. }, [item.bboxCoords.left, item.bboxCoords.top]);
  483. useEffect(() => {
  484. if (!item.closed) item.control.tools.Polygon.resumeUnfinishedRegion(item);
  485. }, [item.closed]);
  486. if (!item.parent) return null;
  487. if (!item.inViewPort) return null;
  488. const stage = item.parent?.stageRef;
  489. return (
  490. <Group
  491. key={item.id ? item.id : guidGenerator(5)}
  492. name={item.id}
  493. ref={(el) => setShapeRef(el)}
  494. onMouseOver={() => {
  495. if (store.annotationStore.selected.isLinkingMode) {
  496. item.setHighlight(true);
  497. }
  498. item.updateCursor(true);
  499. }}
  500. onMouseOut={() => {
  501. if (store.annotationStore.selected.isLinkingMode) {
  502. item.setHighlight(false);
  503. }
  504. item.updateCursor();
  505. }}
  506. onClick={(e) => {
  507. // create regions over another regions with Cmd/Ctrl pressed
  508. if (item.parent.getSkipInteractions()) return;
  509. if (item.isDrawing) return;
  510. e.cancelBubble = true;
  511. if (!item.closed) return;
  512. if (store.annotationStore.selected.isLinkingMode) {
  513. stage.container().style.cursor = Constants.DEFAULT_CURSOR;
  514. }
  515. item.setHighlight(false);
  516. item.onClickRegion(e);
  517. }}
  518. {...dragProps}
  519. draggable={!item.isReadOnly() && (!item.inSelection || item.parent?.selectedRegions?.length === 1)}
  520. listening={!suggestion}
  521. >
  522. <LabelOnPolygon item={item} color={regionStyles.strokeColor} />
  523. {item.mouseOverStartPoint}
  524. {item.points && item.closed ? (
  525. <Poly
  526. item={item}
  527. colors={regionStyles}
  528. dragProps={dragProps}
  529. draggable={!item.isReadOnly() && item.inSelection && item.parent?.selectedRegions?.length > 1}
  530. />
  531. ) : null}
  532. {item.points && !item.isReadOnly() ? <Edges item={item} regionStyles={regionStyles} /> : null}
  533. {item.points && !item.isReadOnly() ? renderCircles(item.points) : null}
  534. </Group>
  535. );
  536. };
  537. const HtxPolygon = AliveRegion(HtxPolygonView);
  538. Registry.addTag("polygonregion", PolygonRegionModel, HtxPolygon);
  539. Registry.addRegionType(PolygonRegionModel, "image", (value) => !!value.points);
  540. export { PolygonRegionModel, HtxPolygon };