diff --git a/src/components/Object3DView/LabelOnRegion.js b/src/components/Object3DView/LabelOnRegion.js new file mode 100644 index 0000000000..376d7c689e --- /dev/null +++ b/src/components/Object3DView/LabelOnRegion.js @@ -0,0 +1,332 @@ +import React, { Fragment, useCallback, useContext, useMemo, useState } from 'react'; +import { Group, Label, Path, Rect, Tag, Text } from 'react-konva'; +import { observer } from 'mobx-react'; +import { getRoot } from 'mobx-state-tree'; + +import Utils from '../../utils'; +import Constants from '../../core/Constants'; +import { Object3DViewContext } from './Object3DViewContext'; + +const NON_ADJACENT_CORNER_RADIUS = 4; +const ADJACENT_CORNER_RADIUS = [4, 4, 0, 0]; +const TAG_PATH = 'M13.47,2.52c-0.27-0.27-0.71-0.27-1.59-0.27h-0.64c-1.51,0-2.26,0-2.95,0.29C7.61,2.82,7.07,3.35,6,4.43L3.65,6.78c-0.93,0.93-1.4,1.4-1.4,1.97c0,0.58,0.46,1.04,1.39,1.97l1.63,1.63c0.93,0.93,1.39,1.39,1.97,1.39s1.04-0.46,1.97-1.39L11.57,10c1.07-1.07,1.61-1.61,1.89-2.29c0.28-0.68,0.28-1.44,0.28-2.96V4.11C13.74,3.23,13.74,2.8,13.47,2.52z M10.5,6.9c-0.77,0-1.4-0.63-1.4-1.4s0.63-1.39,1.4-1.39s1.39,0.63,1.39,1.4S11.27,6.9,10.5,6.9z'; +const OCR_PATH = 'M13,1v2H6C4.11,3,3.17,3,2.59,3.59C2,4.17,2,5.11,2,7v2c0,1.89,0,2.83,0.59,3.41C3.17,13,4.11,13,6,13h7v2h1V1H13z M6,9.5C5.17,9.5,4.5,8.83,4.5,8S5.17,6.5,6,6.5S7.5,7.17,7.5,8S6.83,9.5,6,9.5z M11,9.5c-0.83,0-1.5-0.67-1.5-1.5s0.67-1.5,1.5-1.5s1.5,0.67,1.5,1.5S11.83,9.5,11,9.5z'; + +const LabelOnBbox = ({ + x, + y, + text, + score, + showLabels, + showScore = showLabels, + rotation = 0, + zoomScale = 1, + color, + maxWidth, + onClickLabel, + onMouseEnterLabel, + onMouseLeaveLabel, + adjacent = false, + isTexting = false, +}) => { + const fontSize = 13; + const height = 20; + const ss = showScore && score; + const scale = 1 / zoomScale; + const [textEl, setTextEl] = useState(); + const paddingLeft = 20; + const paddingRight = 5; + const scoreSpace = ss ? 34 : 0; + const horizontalPaddings = paddingLeft + paddingRight; + const textMaxWidth = Math.max(0, maxWidth * zoomScale - horizontalPaddings - scoreSpace); + const isSticking = !!textMaxWidth; + const { suggestion } = useContext(Object3DViewContext) ?? {}; + + const width = useMemo(() => { + if (!showLabels || !textEl || !maxWidth) return null; + const currentTextWidth = (text ? textEl.measureSize(text).width : 0); + + if (currentTextWidth > textMaxWidth) { + return textMaxWidth; + } else { + return null; + } + }, [textEl, text, maxWidth, scale]); + + const tagSceneFunc = useCallback((context, shape) => { + const cornerRadius = adjacent && isSticking ? ADJACENT_CORNER_RADIUS : NON_ADJACENT_CORNER_RADIUS; + const width = maxWidth ? Math.min(shape.width() + horizontalPaddings, isSticking ? maxWidth * zoomScale : paddingLeft) : shape.width() + horizontalPaddings; + const height = shape.height(); + + context.beginPath(); + if (!cornerRadius) { + context.rect(0, 0, width, height); + } + else { + let topLeft = 0; + let topRight = 0; + let bottomLeft = 0; + let bottomRight = 0; + + if (typeof cornerRadius === 'number') { + topLeft = topRight = bottomLeft = bottomRight = Math.min(cornerRadius, width / 2, height / 2); + } + else { + topLeft = Math.min(cornerRadius[0], width / 2, height / 2); + topRight = Math.min(cornerRadius[1], width / 2, height / 2); + bottomRight = Math.min(cornerRadius[2], width / 2, height / 2); + bottomLeft = Math.min(cornerRadius[3], width / 2, height / 2); + } + context.moveTo(topLeft, 0); + context.lineTo(width - topRight, 0); + context.arc(width - topRight, topRight, topRight, (Math.PI * 3) / 2, 0, false); + context.lineTo(width, height - bottomRight); + context.arc(width - bottomRight, height - bottomRight, bottomRight, 0, Math.PI / 2, false); + context.lineTo(bottomLeft, height); + context.arc(bottomLeft, height - bottomLeft, bottomLeft, Math.PI / 2, Math.PI, false); + context.lineTo(0, topLeft); + context.arc(topLeft, topLeft, topLeft, Math.PI, (Math.PI * 3) / 2, false); + } + context.closePath(); + context.fillStrokeShape(shape); + }, [adjacent, isSticking, maxWidth]); + + return ( + + {ss && ( + + + )} + {showLabels && ( + <> + + + + )} + + ); +}; + +const LabelOnEllipse = observer(({ item, color, strokewidth }) => { + const isLabeling = !!item.labeling; + const isTexting = !!item.texting; + const labelText = item.getLabelText(','); + const obj = item.parent; + + if (!isLabeling && !isTexting) return null; + const zoomScale = item.parent.zoomScale || 1; + + return ( + + ); +}); + +const LabelOnRect = observer(({ item, color, strokewidth }) => { + const isLabeling = !!item.labeling; + const isTexting = !!item.texting; + const labelText = item.getLabelText(','); + const obj = item.parent; + + if (!isLabeling && !isTexting) return null; + const zoomScale = item.parent.zoomScale || 1; + + return ( + + ); +}); + +const LabelOnPolygon = observer(({ item, color }) => { + const isLabeling = !!item.labeling; + const isTexting = !!item.texting; + const labelText = item.getLabelText(','); + + if (!isLabeling && !isTexting) return null; + + const bbox = item.bboxCoordsCanvas; + + if (!bbox) return null; + + const settings = getRoot(item).settings; + + return ( + + {settings && (settings.showLabels || settings.showScore) && ( + + )} + + + ); +}); + +const LabelOnMask = observer(({ item, color }) => { + const settings = getRoot(item).settings; + + if (settings && !settings.showLabels && !settings.showScore) return null; + + const isLabeling = !!item.labeling; + const isTexting = !!item.texting; + const labelText = item.getLabelText(','); + + if (!isLabeling && !isTexting) return null; + + const bbox = item.bboxCoordsCanvas; + + if (!bbox) return null; + return ( + + + + + ); +}); + +const LabelOnKP = observer(({ item, color }) => { + const isLabeling = !!item.labeling; + const isTexting = !!item.texting; + const labelText = item.getLabelText(','); + + if (!isLabeling && !isTexting) return null; + + return ( + + ); +}); + +const LabelOnVideoBbox = observer(({ reg, box, color, scale, strokeWidth, adjacent = false }) => { + const isLabeling = !!reg.labeling; + const isTexting = !!reg.texting; + const labelText = reg.getLabelText(','); + + if (!isLabeling && !isTexting) return null; + + return ( + + ); +}); + +export { LabelOnBbox, LabelOnPolygon, LabelOnRect, LabelOnEllipse, LabelOnKP, LabelOnMask, LabelOnVideoBbox }; diff --git a/src/components/Object3DView/Object3D.js b/src/components/Object3DView/Object3D.js new file mode 100644 index 0000000000..cc657c4c0a --- /dev/null +++ b/src/components/Object3DView/Object3D.js @@ -0,0 +1,126 @@ +import { observer } from 'mobx-react'; +import { forwardRef, useCallback, useMemo } from 'react'; +import { Block, Elem } from '../../utils/bem'; +import { FF_LSDV_4711, isFF } from '../../utils/feature-flags'; +import messages from '../../utils/messages'; +import { ErrorMessage } from '../ErrorMessage/ErrorMessage'; +import './Object3D.styl'; + +/** + * Coordinates in relative mode belong to a data domain consisting of percentages in the range from 0 to 100 + */ +export const RELATIVE_STAGE_WIDTH = 100; + +/** + * Coordinates in relative mode belong to a data domain consisting of percentages in the range from 0 to 100 + */ +export const RELATIVE_STAGE_HEIGHT = 100; + +/** + * Mode of snapping to pixel + */ +export const SNAP_TO_PIXEL_MODE = { + EDGE: 'edge', + CENTER: 'center', +}; + +export const Object3D = observer(forwardRef(({ + imageEntity, + imageTransform, + updateObject3DSize, + usedValue, + size, +}, ref) => { + const imageSize = useMemo(() => { + return { + width: size.width === 1 ? '100%' : size.width, + height: size.height === 1 ? 'auto' : size.height, + }; + }, [size]); + + const onLoad = useCallback((event) => { + updateObject3DSize(event); + imageEntity.setObject3DLoaded(true); + }, [updateObject3DSize, imageEntity]); + + return ( + + + {imageEntity.downloaded ? ( + + ) : null} + + ); +})); + +const Object3DProgress = observer(({ + downloading, + progress, + error, + src, + usedValue, +}) => { + return downloading ? ( + + Downloading image + + + ) : error ? ( + + ) : null; +}); + +const imgDefaultProps = {}; + +if (isFF(FF_LSDV_4711)) imgDefaultProps.crossOrigin = 'anonymous'; + +const Object3DRenderer = observer(forwardRef(({ + src, + onLoad, + imageTransform, + isLoaded, +}, ref) => { + const imageStyles = useMemo(() => { + const style = imageTransform ?? {}; + + return { ...style, visibility: isLoaded ? 'visible' : 'hidden' }; + }, [imageTransform, isLoaded]); + + return ( + image + ); +})); + +const Object3DLoadingError = ({ src, value }) => { + const error = useMemo(() => { + return messages.ERR_LOADING_HTTP({ + url: src, + error: '', + attr: value, + }); + }, [src]); + + return ( + + ); +}; diff --git a/src/components/Object3DView/Object3D.styl b/src/components/Object3DView/Object3D.styl new file mode 100644 index 0000000000..ffc45384ea --- /dev/null +++ b/src/components/Object3DView/Object3D.styl @@ -0,0 +1,19 @@ +.object3d + top 0 + position absolute + overflow hidden + +.object3d-progress + padding 14px 16px + display flex + justify-content center + background #fff + border-radius 4px + box-shadow 0 0 0 0.5px rgba(0,0,0,0.2) + margin 16px 0.5px + flex-direction column + color #777 + font-weight bold + + &__bar + width 200px diff --git a/src/components/Object3DView/Object3DView.js b/src/components/Object3DView/Object3DView.js new file mode 100644 index 0000000000..cd4c1c05cb --- /dev/null +++ b/src/components/Object3DView/Object3DView.js @@ -0,0 +1,197 @@ +import React from 'react'; +// import React, { +// Component, +// forwardRef, +// useMemo, +// useRef, +// useState +// } from 'react'; +import { observer } from 'mobx-react'; +// import * as THREE from 'three'; +// import { Color } from 'three'; +// import { Canvas } from '@react-three/fiber'; +// import { Center, Environment, Gltf, MapControls, OrbitControls, PivotControls, RandomizedLight, View } from '@react-three/drei'; +// import { AccumulativeShadows, OrthographicCamera, PerspectiveCamera } from '@react-three/drei'; +// import { Button, Menu } from '@mantine/core'; +// import * as ICONS from '@tabler/icons'; +// import useRefs from 'react-use-refs'; +// import create from 'zustand'; + + +// const matrix = new THREE.Matrix4(); +// const positions = { Top: [0, 10, 0], Bottom: [0, -10, 0], Left: [-10, 0, 0], Right: [10, 0, 0], Back: [0, 0, -10], Front: [0, 0, 10] }; +// const useStore = create((set) => ({ +// projection: 'Perspective', +// top: 'Back', +// middle: 'Top', +// bottom: 'Right', +// setPanelView: (which, view) => set({ [which]: view }), +// setProjection: (projection) => set({ projection }), +// })); + +const Object3DView = () => { + return ( + + ); + // // stored position of canvas before creating region + + // const [view1, view2, view3, view4] = useRefs(); + + // return ( + //
+ // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // + // {/** Tracking div's, regular HTML and made responsive with CSS media-queries ... */} + // + // + // + // + //
+ // ); + // }; + + // function CameraSwitcher() { + // const projection = useStore((state) => state.projection); + + // // Would need to remember the old coordinates to be more useful ... + // return projection === 'Perspective' ? ( + // + // ) : ( + // + // ); + // } + + // function PanelCamera({ which }) { + // const view = useStore((state) => state[which]); + + // return ; + // } + + // const MainPanel = forwardRef((props, fref) => { + // const projection = useStore((state) => state.projection); + // const setProjection = useStore((state) => state.setProjection); + + // return ( + //
+ // + // + // + // + // setProjection(e.target.innerText)}> + // }>Perspective + // }>Orthographic + // + // + //
+ // ); + // }); + + // const SidePanel = forwardRef(({ which }, fref) => { + // const value = useStore((state) => state[which]); + // const setPanelView = useStore((state) => state.setPanelView); + + // return ( + //
+ // + // + // + // + // setPanelView(which, e.target.innerText)}> + // }>Top + // }>Bottom + // }>Left + // }>Right + // }>Front + // }>Back + // + // + //
+ // ); + // }); + + + // function Scene({ background = 'white', children, ...props }) { + // return ( + // <> + // + // + // + // {/* */} + // (self.matrix = matrix)} + // {...props}> + //
+ // + + // {/* */} + //
+ // {children} + //
+ // + // ); + // } + + // function Box({ text, ...props }) { + // const ref = useRef(); + // const black = useMemo(() => new Color('black'), []); + // const lime = useMemo(() => new Color('lime'), []); + // const [hovered, setHovered] = useState(false); + // const transform = useRef(); + + // return ( + // <> + // setHovered(true)} + // onPointerOut={() => setHovered(false)} + // {...props} + // ref={ref} + // > + // + // + // {props.children} + // + // setHovered(true)} + // onPointerOut={() => setHovered(false)} + // > + // + // + // {props.children} + // + // + // // + // ); +}; + +export default observer(Object3DView); diff --git a/src/components/Object3DView/Object3DView.module.scss b/src/components/Object3DView/Object3DView.module.scss new file mode 100644 index 0000000000..24e3105be2 --- /dev/null +++ b/src/components/Object3DView/Object3DView.module.scss @@ -0,0 +1,159 @@ +.block { + display: flex; + flex-flow: column; + align-items: center; + border: 1px solid rgba(34, 36, 38, 0.15); + border-radius: 0.28571429rem; + width: fit-content; + padding: 0.5em; +} + +.block:empty { + display: none; +} + +.divider { + margin: 12px 0; +} + +.button { + margin: 0.3rem 0; +} + +.wrapperComponent { + flex: 1; + position: relative; + display: flex; + align-items: flex-start; + justify-content: space-between; + align-self: stretch; +} + +.wrapper { + max-width: 100%; + width: 100%; +} + +.loading { + z-index: 10; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: auto; + height: auto; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + background: rgba(125,125,125,.15); + font-size: 24px; +} + +.image-element { + position: absolute; +} + +.image_position { + position: absolute; + &__top { + top: 0; + } + &__middle { + top: 50%; + transform: translateY(-50%); + &.image_position__center { + transform: translate(-50%, -50%); + } + } + &__bottom { + bottom: 0; + } + &__left { + left: 0; + } + &__center { + left: 50%; + transform: translateX(-50%); + } + &__right { + right: 0; + } +} + +.container { + overflow: hidden; + position: relative; + width: 100%; + height: 100%; + + // padding hack to fill the empty space with given aspect ratio + // see Object3DView.render() + .frame { + position: absolute; + overflow: hidden; + height: 0; + } + .frame_height { + width: 100%; + } + .filler { + max-width: 100%; + position: relative; + overflow: hidden; + } + .overlay { + position: absolute; + top: 0; + pointer-events: none; + z-index: 100; + } + + img { + position: absolute; + top: 0; + } +} + +.withGallery { + margin-bottom: 80px; +} + +.withPagination { + padding-top: 30px; // pagination height 24px + 6px offset +} + +.gallery { + position: absolute; + bottom: -80px; + display: flex; + overflow-x: auto; + width: 100%; + padding-bottom: 8px; // for scroll + + img { + cursor: pointer; + margin-right: 4px; + border: 4px solid transparent; + max-width: 120px; + height: 60px; + object-fit: cover; + + &:hover { + border-color: #1890ff66; + } + + &.active { + border-color: #1890ff; + } + } +} + +.pagination { + position: absolute; + top: 0; + display: flex; + width: 100%; + padding-right: 40px; // for toolbar +} diff --git a/src/components/Object3DView/Object3DViewContext.ts b/src/components/Object3DView/Object3DViewContext.ts new file mode 100644 index 0000000000..9256b46b38 --- /dev/null +++ b/src/components/Object3DView/Object3DViewContext.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +export const Object3DViewContext = createContext<{suggestion: boolean}>({ suggestion: false }); + +export const Object3DViewProvider = Object3DViewContext.Provider; diff --git a/src/mixins/CubeDrawingTool.js b/src/mixins/CubeDrawingTool.js new file mode 100644 index 0000000000..b8f2ce0478 --- /dev/null +++ b/src/mixins/CubeDrawingTool.js @@ -0,0 +1,605 @@ +import { types } from 'mobx-state-tree'; + +import Utils from '../utils'; +import throttle from 'lodash.throttle'; +import { MIN_SIZE } from '../tools/Base'; +import { FF_DEV_3666, FF_DEV_3793, isFF } from '../utils/feature-flags'; +import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../components/Object3DView/Object3D'; + +const DrawingTool = types + .model('DrawingTool', { + default: true, + mode: types.optional(types.enumeration(['drawing', 'viewing']), 'viewing'), + unselectRegionOnToolChange: true, + }) + .volatile(() => { + return { + currentArea: null, + }; + }) + .views(self => { + return { + createRegionOptions(opts) { + return { + ...opts, + coordstype: 'px', + }; + }, + get tagTypes() { + console.error('Drawing tool model needs to implement tagTypes getter in views'); + return {}; + }, + isIncorrectControl() { + return self.tagTypes.stateTypes === self.control.type && !self.control.isSelected; + }, + isIncorrectLabel() { + return !self.obj.checkLabels(); + }, + get isDrawing() { + return self.mode === 'drawing'; + }, + get getActiveShape() { + return self.currentArea; + }, + getCurrentArea() { + return self.currentArea; + }, + current() { + return self.currentArea; + }, + canStart() { + return !self.isDrawing && !self.annotation.isReadOnly(); + }, + get defaultDimensions() { + console.warn('Drawing tool model needs to implement defaultDimentions getter in views'); + return {}; + }, + get MIN_SIZE() { + if (isFF(FF_DEV_3793)) { + return { + X: MIN_SIZE.X / self.obj.stageScale / self.obj.stageWidth * RELATIVE_STAGE_WIDTH, + Y: MIN_SIZE.Y / self.obj.stageScale / self.obj.stageHeight * RELATIVE_STAGE_HEIGHT, + }; + } + + return { + X: MIN_SIZE.X / self.obj.stageScale, + Y: MIN_SIZE.Y / self.obj.stageScale, + }; + }, + }; + }) + .actions(self => { + let lastClick = { + ts: 0, + x: 0, + y: 0, + }; + + return { + event(name, ev, [x, y, canvasX, canvasY]) { + // filter right clicks and middle clicks and shift pressed + if (ev.button > 0 || ev.shiftKey) return; + let fn = name + 'Ev'; + + if (typeof self[fn] !== 'undefined') self[fn].call(self, ev, [x, y], [canvasX, canvasY]); + + // Emulating of dblclick event, 'cause redrawing will crush the the original one + if (name === 'click') { + const ts = ev.timeStamp; + + if (ts - lastClick.ts < 300 && self.comparePointsWithThreshold(lastClick, { x, y })) { + fn = 'dbl' + fn; + if (typeof self[fn] !== 'undefined') self[fn].call(self, ev, [x, y], [canvasX, canvasY]); + } + lastClick = { ts, x, y }; + } + }, + + comparePointsWithThreshold(p1, p2, threshold = { x: self.MIN_SIZE.X, y: self.MIN_SIZE.Y }) { + if (!p1 || !p2) return; + if (typeof threshold === 'number') threshold = { x: threshold, y: threshold }; + return Math.abs(p1.x - p2.x) < threshold.x && Math.abs(p1.y - p2.y) < threshold.y; + }, + }; + }) + .actions(self => { + return { + createDrawingRegion(opts) { + const control = self.control; + const resultValue = control.getResultValue(); + + self.currentArea = self.obj.createDrawingRegion(opts, resultValue, control, false); + self.currentArea.setDrawing(true); + + self.applyActiveStates(self.currentArea); + self.annotation.setIsDrawing(true); + return self.currentArea; + }, + resumeUnfinishedRegion(existingUnclosedPolygon) { + self.currentArea = existingUnclosedPolygon; + self.currentArea.setDrawing(true); + self.annotation.regionStore.selection._updateResultsFromRegions([self.currentArea]); + self.mode = 'drawing'; + self.annotation.setIsDrawing(true); + self.annotation.regionStore.selection.drawingSelect(self.currentArea); + self.listenForClose?.(); + }, + commitDrawingRegion() { + const { currentArea, control, obj } = self; + + if (!currentArea) return; + const source = currentArea.toJSON(); + const value = Object.keys(currentArea.serialize().value).reduce((value, key) => { + value[key] = source[key]; + return value; + }, { coordstype: 'px', dynamic: self.dynamic }); + + const [main, ...rest] = currentArea.results; + const newArea = self.annotation.createResult(value, main.value.toJSON(), control, obj); + + //when user is using two different labels tag to draw a region, the other labels will be added to the region + rest.forEach(r => newArea.addResult(r.toJSON())); + + currentArea.setDrawing(false); + self.deleteRegion(); + newArea.notifyDrawingFinished(); + return newArea; + }, + createRegion(opts, skipAfterCreate = false) { + const control = self.control; + const resultValue = control.getResultValue(); + + self.currentArea = self.annotation.createResult(opts, resultValue, control, self.obj, skipAfterCreate); + self.applyActiveStates(self.currentArea); + return self.currentArea; + }, + deleteRegion() { + self.currentArea = null; + self.obj.deleteDrawingRegion(); + }, + applyActiveStates(area) { + const activeStates = self.obj.activeStates(); + + activeStates.forEach(state => { + area.setValue(state); + }); + }, + + beforeCommitDrawing() { + return true; + }, + + canStartDrawing() { + return !self.isIncorrectControl() + && (!isFF(FF_DEV_3666) || !self.isIncorrectLabel()) + && self.canStart() + && !self.annotation.isDrawing; + }, + + startDrawing(x, y) { + self.annotation.history.freeze(); + self.mode = 'drawing'; + self.currentArea = self.createDrawingRegion(self.createRegionOptions({ x, y })); + }, + finishDrawing() { + if (!self.beforeCommitDrawing()) { + self.deleteRegion(); + if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true); + self._resetState(); + } else { + self._finishDrawing(); + } + }, + _finishDrawing() { + self.commitDrawingRegion(); + self._resetState(); + }, + _resetState() { + self.annotation.setIsDrawing(false); + self.annotation.history.unfreeze(); + self.mode = 'viewing'; + }, + }; + }); + +const TwoPointsDrawingTool = DrawingTool.named('TwoPointsDrawingTool') + .views(self => ({ + get defaultDimensions() { + return { + width: self.MIN_SIZE.X, + height: self.MIN_SIZE.Y, + }; + }, + })) + .actions(self => { + const DEFAULT_MODE = 0; + const DRAG_MODE = 1; + const TWO_CLICKS_MODE = 2; + let currentMode = DEFAULT_MODE; + let modeAfterMouseMove = DEFAULT_MODE; + let startPoint = null; + let endPoint = { x: 0, y: 0 }; + const Super = { + finishDrawing: self.finishDrawing, + }; + + return { + updateDraw: throttle(function(x, y) { + if (currentMode === DEFAULT_MODE) return; + self.draw(x, y); + }, 48), // 3 frames, optimized enough and not laggy yet + + draw(x, y) { + const shape = self.getCurrentArea(); + + if (!shape) return; + const isEllipse = shape.type.includes('ellipse'); + const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth; + const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight; + + let { x1, y1, x2, y2 } = isEllipse ? { + x1: shape.startX, + y1: shape.startY, + x2: x, + y2: y, + } : Utils.Object3D.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y }); + + x1 = Math.max(0, x1); + y1 = Math.max(0, y1); + x2 = Math.min(maxStageWidth, x2); + y2 = Math.min(maxStageHeight, y2); + + let [distX, distY] = [x2 - x1, y2 - y1].map(Math.abs); + + if (isEllipse) { + distX = Math.min(distX, Math.min(x1, maxStageWidth - x1)); + distY = Math.min(distY, Math.min(y1, maxStageHeight - y1)); + } + + shape.setPositionInternal(x1, y1, distX, distY, shape.rotation); + }, + + finishDrawing(x, y) { + startPoint = null; + Super.finishDrawing(x, y); + currentMode = DEFAULT_MODE; + modeAfterMouseMove = DEFAULT_MODE; + }, + + mousedownEv(_, [x, y]) { + if (!self.canStartDrawing()) return; + startPoint = { x, y }; + if (currentMode === DEFAULT_MODE) { + modeAfterMouseMove = DRAG_MODE; + } + }, + + mousemoveEv(_, [x, y]) { + if (currentMode === DEFAULT_MODE && startPoint) { + if (!self.comparePointsWithThreshold(startPoint, { x, y })) { + currentMode = modeAfterMouseMove; + if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) { + self.startDrawing(startPoint.x, startPoint.y); + if (!self.isDrawing) { + currentMode = DEFAULT_MODE; + return; + } + } + } + } + if (!self.isDrawing) return; + if ([DRAG_MODE, TWO_CLICKS_MODE].includes(currentMode)) { + self.updateDraw(x, y); + } + }, + + mouseupEv(_, [x, y]) { + if (currentMode !== DRAG_MODE) return; + endPoint = { x, y }; + if (!self.isDrawing) return; + self.draw(x, y); + self.finishDrawing(x, y); + }, + + clickEv(_, [x, y]) { + if (!self.canStartDrawing()) return; + // @todo: here is a potential problem with endPoint + // it may be incorrect due to it may be not set at this moment + if (startPoint && endPoint && !self.comparePointsWithThreshold(startPoint, endPoint)) return; + if (currentMode === DEFAULT_MODE) { + modeAfterMouseMove = TWO_CLICKS_MODE; + } else if (self.isDrawing && currentMode === TWO_CLICKS_MODE) { + self.draw(x, y); + self.finishDrawing(x, y); + currentMode = DEFAULT_MODE; + } + }, + + dblclickEv(_, [x, y]) { + if (!self.canStartDrawing()) return; + + let dX = self.defaultDimensions.width; + let dY = self.defaultDimensions.height; + + if (isFF(FF_DEV_3793)) { + dX = self.obj.canvasToInternalX(dX); + dY = self.obj.canvasToInternalY(dY); + } + + if (currentMode === DEFAULT_MODE) { + self.startDrawing(x, y); + if (!self.isDrawing) return; + x += dX; + y += dY; + self.draw(x, y); + self.finishDrawing(x, y); + } + }, + }; + }); + +const MultipleClicksDrawingTool = DrawingTool.named('MultipleClicksMixin') + .views(() => ({ + canStart() { + return !this.current(); + }, + })) + .actions(self => { + let startPoint = { x: 0, y: 0 }; + let pointsCount = 0; + let lastPoint = { x: -1, y: -1 }; + let lastEvent = 0; + const MOUSE_DOWN_EVENT = 1; + const MOUSE_UP_EVENT = 2; + const CLICK_EVENT = 3; + let lastClickTs = 0; + const Super = { + canStartDrawing: self.canStartDrawing, + }; + + return { + canStartDrawing() { + return Super.canStartDrawing() && !self.annotation.regionStore.hasSelection; + }, + nextPoint(x, y) { + const area = self.getCurrentArea(); + const object = self.obj; + + if (area && object && object.multiObject3D && area.item_index !== object.currentObject3D) return; + + self.getCurrentArea().addPoint(x, y); + pointsCount++; + }, + listenForClose() { + console.error('MultipleClicksMixin model needs to implement listenForClose method in actions'); + }, + closeCurrent() { + console.error('MultipleClicksMixin model needs to implement closeCurrent method in actions'); + }, + finishDrawing() { + if (!self.isDrawing) return; + + self.annotation.regionStore.selection.drawingUnselect(); + + pointsCount = 0; + self.closeCurrent(); + setTimeout(() => { + self._finishDrawing(); + }); + }, + cleanupUncloseableShape() { + self.deleteRegion(); + if (self.control.type === self.tagTypes.stateTypes) self.annotation.unselectAll(true); + self._resetState(); + }, + mousedownEv(ev, [x, y]) { + lastPoint = { x, y }; + lastEvent = MOUSE_DOWN_EVENT; + }, + mouseupEv(ev, [x, y]) { + if (lastEvent === MOUSE_DOWN_EVENT && self.comparePointsWithThreshold(lastPoint, { x, y })) { + self._clickEv(ev, [x, y]); + lastEvent = MOUSE_UP_EVENT; + } + lastPoint = { x: -1, y: -1 }; + }, + clickEv(ev, [x, y]) { + if (lastEvent !== MOUSE_UP_EVENT) { + self._clickEv(ev, [x, y]); + } + lastEvent = CLICK_EVENT; + lastPoint = { x: -1, y: -1 }; + }, + _clickEv(ev, [x, y]) { + if (self.current()) { + if ( + pointsCount === 1 && + self.comparePointsWithThreshold(startPoint, { x, y }) && + ev.timeStamp - lastClickTs < 350 + ) { + // dblclick + self.drawDefault(); + } else { + if (self.comparePointsWithThreshold(startPoint, { x, y })) { + if (pointsCount > 2) { + self.finishDrawing(); + } + } else { + self.nextPoint(x, y); + } + } + } else { + if (!self.canStartDrawing()) return; + startPoint = { x, y }; + pointsCount = 1; + lastClickTs = ev.timeStamp; + self.startDrawing(x, y); + self.listenForClose(); + } + }, + + drawDefault() { + const { x, y } = startPoint; + let dX = self.defaultDimensions.length; + let dY = self.defaultDimensions.length; + + if (isFF(FF_DEV_3793)) { + dX = self.obj.canvasToInternalX(dX); + dY = self.obj.canvasToInternalY(dY); + } + + self.nextPoint(x + dX, y); + self.nextPoint( + x + dX / 2, + y + Math.sin(Math.PI / 3) * dY, + ); + self.finishDrawing(); + }, + }; + }); + +const ThreePointsDrawingTool = DrawingTool.named('ThreePointsDrawingTool') + .views((self) => ({ + canStart() { + return !this.current(); + }, + get defaultDimensions() { + return { + width: self.MIN_SIZE.X, + height: self.MIN_SIZE.Y, + }; + }, + })) + .actions(self => { + let points = []; + let lastEvent = 0; + const DEFAULT_MODE = 0; + const MOUSE_DOWN_EVENT = 1; + const MOUSE_UP_EVENT = 2; + const CLICK_EVENT = 3; + const DRAG_MODE = 4; + const DBL_CLICK_EVENT = 5; + let currentMode = DEFAULT_MODE; + let startPoint = null; + const Super = { + finishDrawing: self.finishDrawing, + }; + + return { + canStartDrawing() { + return !self.isIncorrectControl(); + }, + updateDraw: (x, y) => { + if (currentMode === DEFAULT_MODE) + self.getCurrentArea()?.draw(x, y, points); + else if (currentMode === DRAG_MODE) + self.draw(x, y); + }, + + nextPoint(x, y) { + points.push({ x, y }); + self.getCurrentArea().draw(x, y, points); + }, + draw(x, y) { + const shape = self.getCurrentArea(); + + if (!shape) return; + const maxStageWidth = isFF(FF_DEV_3793) ? RELATIVE_STAGE_WIDTH : self.obj.stageWidth; + const maxStageHeight = isFF(FF_DEV_3793) ? RELATIVE_STAGE_HEIGHT : self.obj.stageHeight; + + let { x1, y1, x2, y2 } = Utils.Object3D.reverseCoordinates({ x: shape.startX, y: shape.startY }, { x, y }); + + x1 = Math.max(0, x1); + y1 = Math.max(0, y1); + x2 = Math.min(maxStageWidth, x2); + y2 = Math.min(maxStageHeight, y2); + + shape.setPositionInternal(x1, y1, x2 - x1, y2 - y1, shape.rotation); + }, + + finishDrawing(x, y) { + if (self.isDrawing) { + points = []; + startPoint = null; + currentMode = DEFAULT_MODE; + Super.finishDrawing(x, y); + setTimeout(() => { + self._finishDrawing(); + }); + } else return; + }, + + mousemoveEv(_, [x, y]) { + if (self.isDrawing) { + if (lastEvent === MOUSE_DOWN_EVENT) { + currentMode = DRAG_MODE; + } + + if (currentMode === DRAG_MODE && startPoint) { + self.startDrawing(startPoint.x, startPoint.y); + self.updateDraw(x, y); + } else if (currentMode === DEFAULT_MODE) { + self.updateDraw(x, y); + } + } + }, + mousedownEv(ev, [x, y]) { + if (!self.canStartDrawing() || self.annotation.isDrawing) return; + lastEvent = MOUSE_DOWN_EVENT; + startPoint = { x, y }; + self.mode = 'drawing'; + }, + mouseupEv(ev, [x, y]) { + if (!self.canStartDrawing()) return; + if (self.isDrawing) { + if (currentMode === DRAG_MODE) { + self.draw(x, y); + self.finishDrawing(x, y); + } + lastEvent = MOUSE_UP_EVENT; + } + }, + clickEv(ev, [x, y]) { + if (!self.canStartDrawing()) return; + if (currentMode === DEFAULT_MODE) { + self._clickEv(ev, [x, y]); + } + lastEvent = CLICK_EVENT; + }, + _clickEv(ev, [x, y]) { + if (points.length >= 2) { + self.finishDrawing(x, y); + } else if (points.length === 0) { + points = [{ x, y }]; + self.startDrawing(x, y); + } else { + self.nextPoint(x, y); + } + }, + + dblclickEv(_, [x, y]) { + lastEvent = DBL_CLICK_EVENT; + if (!self.canStartDrawing()) return; + + let dX = self.defaultDimensions.width; + let dY = self.defaultDimensions.height; + + if (isFF(FF_DEV_3793)) { + dX = self.obj.canvasToInternalX(dX); + dY = self.obj.canvasToInternalY(dY); + } + + if (currentMode === DEFAULT_MODE) { + self.startDrawing(x, y); + if (!self.isDrawing) return; + x += dX; + y += dY; + self.draw(x, y); + self.finishDrawing(x, y); + } + }, + }; + }); + +export { DrawingTool, TwoPointsDrawingTool, MultipleClicksDrawingTool }; diff --git a/src/regions/CubeRegion.js b/src/regions/CubeRegion.js new file mode 100644 index 0000000000..fa97ff3239 --- /dev/null +++ b/src/regions/CubeRegion.js @@ -0,0 +1,421 @@ +import { getRoot, isAlive, types } from 'mobx-state-tree'; +import React, { useContext } from 'react'; +import { Rect } from 'react-konva'; +import { Object3DViewContext } from '../components/Object3DView/Object3DViewContext'; +import { LabelOnRect } from '../components/Object3DView/LabelOnRegion'; +import Constants from '../core/Constants'; +import { guidGenerator } from '../core/Helpers'; +import Registry from '../core/Registry'; +import { useRegionStyles } from '../hooks/useRegionColor'; +import { AreaMixin } from '../mixins/AreaMixin'; +import { KonvaRegionMixin } from '../mixins/KonvaRegion'; +import NormalizationMixin from '../mixins/Normalization'; +import RegionsMixin from '../mixins/Regions'; +import { Object3DModel } from '../tags/object/Object3D'; +import { rotateBboxCoords } from '../utils/bboxCoords'; +import { FF_DEV_3793, isFF } from '../utils/feature-flags'; +import { createDragBoundFunc } from '../utils/image'; +import { AliveRegion } from './AliveRegion'; +import { EditableRegion } from './EditableRegion'; +import { RegionWrapper } from './RegionWrapper'; +import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../components/Object3DView/Object3D'; + +/** + * Rectangle object for Bounding Box + * + */ +const Model = types + .model({ + id: types.optional(types.identifier, guidGenerator), + pid: types.optional(types.string, guidGenerator), + type: 'cuberegion', + object: types.late(() => types.reference(Object3DModel)), + + x: types.number, + y: types.number, + + width: types.number, + height: types.number, + + rotation: 0, + rotationAtCreation: 0, + }) + .volatile(() => ({ + startX: 0, + startY: 0, + + // @todo not used + scaleX: 1, + scaleY: 1, + + opacity: 1, + + fill: true, + fillColor: '#ff8800', // Constants.FILL_COLOR, + fillOpacity: 0.2, + + strokeColor: Constants.STROKE_COLOR, + strokeWidth: Constants.STROKE_WIDTH, + + _supportsTransform: true, + // depends on region and object tag; they both should correctly handle the `hidden` flag + hideable: true, + + editableFields: [ + { property: 'x', label: 'X' }, + { property: 'y', label: 'Y' }, + { property: 'width', label: 'W' }, + { property: 'height', label: 'H' }, + { property: 'rotation', label: 'icon:angle' }, + ], + })) + .volatile(() => { + return { + useTransformer: true, + preferTransformer: true, + supportsRotate: true, + supportsScale: true, + }; + }) + .views(self => ({ + get store() { + return getRoot(self); + }, + get parent() { + return isAlive(self) ? self.object : null; + }, + get bboxCoords() { + const bboxCoords = { + left: self.x, + top: self.y, + right: self.x + self.width, + bottom: self.y + self.height, + }; + + if (self.rotation === 0 || !self.parent) return bboxCoords; + + return rotateBboxCoords(bboxCoords, self.rotation, { x: self.x, y: self.y }, self.parent.whRatio); + }, + get canvasX() { + return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasX(self.x) : self.x; + }, + get canvasY() { + return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasY(self.y) : self.y; + }, + get canvasWidth() { + return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasX(self.width) : self.width; + }, + get canvasHeight() { + return isFF(FF_DEV_3793) ? self.parent?.internalToCanvasY(self.height) : self.height; + }, + })) + .actions(self => ({ + afterCreate() { + self.startX = self.x; + self.startY = self.y; + }, + + getDistanceBetweenPoints(pointA, pointB) { + const { x: xA, y: yA } = pointA; + const { x: xB, y: yB } = pointB; + const distanceX = xA - xB; + const distanceY = yA - yB; + + return Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2)); + }, + + getHeightOnPerpendicular(pointA, pointB, cursor) { + const dX = pointB.x - pointA.x; + const dY = pointB.y - pointA.y; + const s2 = Math.abs(dY * cursor.x - dX * cursor.y + pointB.x * pointA.y - pointB.y * pointA.x); + const ab = Math.sqrt(dY * dY + dX * dX); + + return s2 / ab; + }, + + isAboveTheLine(a, b, c) { + return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 0; + }, + + draw(x, y, points) { + const oldHeight = self.height; + const canvasX = self.parent.internalToCanvasX(x); + const canvasY = self.parent.internalToCanvasY(y); + + if (points.length === 1) { + const canvasWidth = self.getDistanceBetweenPoints({ x: canvasX, y: canvasY }, { + x: self.canvasX, + y: self.canvasY, + }); + + self.width = self.parent.canvasToInternalX(canvasWidth); + self.rotation = self.rotationAtCreation = Math.atan2(canvasY - self.canvasY, canvasX - self.canvasX) * (180 / Math.PI); + } else if (points.length === 2) { + const canvasPoints = points.map(({ x, y }) => ({ + x: self.parent.internalToCanvasX(x), + y: self.parent.internalToCanvasY(y), + })); + const { y: firstPointY, x: firstPointX } = points[0]; + const { y: secondPointY, x: secondPointX } = points[1]; + + if (self.isAboveTheLine(canvasPoints[0], canvasPoints[1], { x: canvasX, y: canvasY })) { + self.x = secondPointX; + self.y = secondPointY; + self.rotation = self.rotationAtCreation + 180; + } else { + self.x = firstPointX; + self.y = firstPointY; + self.rotation = self.rotationAtCreation; + } + const canvasHeight = self.getHeightOnPerpendicular(canvasPoints[0], canvasPoints[1], { + x: canvasX, + y: canvasY, + }); + + self.height = self.parent.canvasToInternalY(canvasHeight); + } + self.setPositionInternal(self.x, self.y, self.width, self.height, self.rotation); + + const areaBBoxCoords = self?.bboxCoords; + + if ( + areaBBoxCoords?.left < 0 || + areaBBoxCoords?.top < 0 || + areaBBoxCoords?.right > RELATIVE_STAGE_WIDTH || + areaBBoxCoords?.bottom > RELATIVE_STAGE_HEIGHT + ) { + self.height = oldHeight; + } + }, + + // @todo not used + coordsInside(x, y) { + // check if x and y are inside the rectangle + const rx = self.x; + const ry = self.y; + const rw = self.width * (self.scaleX || 1); + const rh = self.height * (self.scaleY || 1); + + if (x > rx && x < rx + rw && y > ry && y < ry + rh) return true; + + return false; + }, + + setPositionInternal(x, y, width, height, rotation) { + self.x = x; + self.y = y; + self.width = width; + self.height = height; + self.rotation = (rotation + 360) % 360; + }, + + /** + * Bounding Box set position on canvas + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @param {number} rotation + */ + setPosition(x, y, width, height, rotation) { + self.setPositionInternal( + self.parent.canvasToInternalX(x), + self.parent.canvasToInternalY(y), + self.parent.canvasToInternalX(width), + self.parent.canvasToInternalY(height), + rotation, + ); + }, + + setScale(x, y) { + self.scaleX = x; + self.scaleY = y; + }, + + addState(state) { + self.states.push(state); + }, + + setFill(color) { + self.fill = color; + }, + + updateObject3DSize() {}, + + /** + * @example + * { + * "original_width": 1920, + * "original_height": 1280, + * "image_rotation": 0, + * "value": { + * "x": 3.1, + * "y": 8.2, + * "width": 20, + * "height": 16, + * "rectanglelabels": ["Car"] + * } + * } + * @typedef {Object} RectRegionResult + * @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.x x coordinate of the top left corner before rotation (0-100) + * @property {number} value.y y coordinate of the top left corner before rotation (0-100) + * @property {number} value.width width of the bounding box (0-100) + * @property {number} value.height height of the bounding box (0-100) + * @property {number} value.rotation rotation degree of the bounding box (deg) + */ + + /** + * @return {RectRegionResult} + */ + serialize() { + const value = { + x: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertXToPerc(self.x) : self.x, + y: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertYToPerc(self.y) : self.y, + width: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertHDimensionToPerc(self.width) : self.width, + height: (self.parent.stageWidth > 1 && !isFF(FF_DEV_3793)) ? self.convertVDimensionToPerc(self.height) : self.height, + rotation: self.rotation, + }; + + return self.parent.createSerializedResult(self, value); + }, + })); + +const CubeRegionModel = types.compose( + 'CubeRegionModel', + RegionsMixin, + NormalizationMixin, + AreaMixin, + KonvaRegionMixin, /* react-3-fiber */ + EditableRegion, + Model, +); + +const HtxCubeView = ({ item, setShapeRef }) => { + const { store } = item; + + const { suggestion } = useContext(Object3DViewContext) ?? {}; + const regionStyles = useRegionStyles(item, { suggestion }); + const stage = item.parent?.stageRef; + + const eventHandlers = {}; + + if (!item.parent) return null; + if (!item.inViewPort) return null; + + if (!suggestion && !item.isReadOnly()) { + eventHandlers.onTransform = ({ target }) => { + // resetting the skew makes transformations weird but predictable + target.setAttr('skewX', 0); + target.setAttr('skewY', 0); + }; + eventHandlers.onTransformEnd = (e) => { + const t = e.target; + + item.setPosition( + t.getAttr('x'), + t.getAttr('y'), + t.getAttr('width') * t.getAttr('scaleX'), + t.getAttr('height') * t.getAttr('scaleY'), + t.getAttr('rotation'), + ); + + t.setAttr('scaleX', 1); + t.setAttr('scaleY', 1); + + item.notifyDrawingFinished(); + }; + + eventHandlers.onDragStart = (e) => { + if (item.parent.getSkipInteractions()) { + e.currentTarget.stopDrag(e.evt); + return; + } + item.annotation.history.freeze(item.id); + }; + + eventHandlers.onDragEnd = (e) => { + const t = e.target; + + item.setPosition( + t.getAttr('x'), + t.getAttr('y'), + t.getAttr('width'), + t.getAttr('height'), + t.getAttr('rotation'), + ); + item.setScale(t.getAttr('scaleX'), t.getAttr('scaleY')); + item.annotation.history.unfreeze(item.id); + + item.notifyDrawingFinished(); + }; + + eventHandlers.dragBoundFunc = createDragBoundFunc(item, { + x: item.x - item.bboxCoords.left, + y: item.y - item.bboxCoords.top, + }); + } + + return ( + + setShapeRef(node)} + width={item.canvasWidth} + height={item.canvasHeight} + fill={regionStyles.fillColor} + stroke={regionStyles.strokeColor} + strokeWidth={regionStyles.strokeWidth} + strokeScaleEnabled={false} + perfectDrawEnabled={false} + shadowForStrokeEnabled={false} + shadowBlur={0} + dash={suggestion ? [10, 10] : null} + scaleX={item.scaleX} + scaleY={item.scaleY} + opacity={1} + rotation={item.rotation} + draggable={!item.isReadOnly()} + name={`${item.id} _transformable`} + {...eventHandlers} + onMouseOver={() => { + if (store.annotationStore.selected.relationMode) { + item.setHighlight(true); + stage.container().style.cursor = Constants.RELATION_MODE_CURSOR; + } else { + stage.container().style.cursor = Constants.POINTER_CURSOR; + } + }} + onMouseOut={() => { + stage.container().style.cursor = Constants.DEFAULT_CURSOR; + + if (store.annotationStore.selected.relationMode) { + item.setHighlight(false); + } + }} + onClick={e => { + if (item.parent.getSkipInteractions()) return; + if (store.annotationStore.selected.relationMode) { + stage.container().style.cursor = Constants.DEFAULT_CURSOR; + } + + item.setHighlight(false); + item.onClickRegion(e); + }} + listening={!suggestion && !item.annotation?.isDrawing} + /> + + + ); +}; + +const HtxCube = AliveRegion(HtxCubeView); + +Registry.addTag('cuberegion', CubeRegionModel, HtxCube); +Registry.addRegionType(CubeRegionModel, 'object3d'); + +export { CubeRegionModel, HtxCube }; diff --git a/src/regions/Result.js b/src/regions/Result.js index d1756fa6fd..b2803c8c1d 100644 --- a/src/regions/Result.js +++ b/src/regions/Result.js @@ -57,6 +57,7 @@ const Result = types 'pairwise', 'videorectangle', 'ranker', + 'cubelabels', ]), // @todo much better to have just a value, not a hash with empty fields value: types.model({ @@ -75,6 +76,7 @@ const Result = types hypertextlabels: types.maybe(types.array(types.string)), paragraphlabels: types.maybe(types.array(types.string)), rectanglelabels: types.maybe(types.array(types.string)), + cubelabels: types.maybe(types.array(types.string)), keypointlabels: types.maybe(types.array(types.string)), polygonlabels: types.maybe(types.array(types.string)), ellipselabels: types.maybe(types.array(types.string)), diff --git a/src/tags/control/Cube.js b/src/tags/control/Cube.js new file mode 100644 index 0000000000..7f60060ca8 --- /dev/null +++ b/src/tags/control/Cube.js @@ -0,0 +1,69 @@ +import { types } from 'mobx-state-tree'; + +import Registry from '../../core/Registry'; +import ControlBase from './Base'; +import { customTypes } from '../../core/CustomTypes'; +import { AnnotationMixin } from '../../mixins/AnnotationMixin'; +import SeparatedControlMixin from '../../mixins/SeparatedControlMixin'; +import { ToolManagerMixin } from '../../mixins/ToolManagerMixin'; + +/** + * The `Cube` tag is used to add a Cube (Bounding Box) to an image without selecting a label. This can be useful when you have only one label to assign to a Cube. + * + * Use with the following data types: image. + * @example + * + * + * + * + * + * @name Cube + * @meta_title Cube Tag for Adding Cube Bounding Box to Images + * @meta_description Customize Label Studio with the Cube tag to add Cube bounding boxes to images for machine learning and data science projects. + * @param {string} name - Name of the element + * @param {string} toName - Name of the image to label + * @param {float=} [opacity=0.6] - Opacity of Cube + * @param {string=} [fillColor] - Cube fill color in hexadecimal + * @param {string=} [strokeColor=#f48a42] - Stroke color in hexadecimal + * @param {number=} [strokeWidth=1] - Width of the stroke + * @param {boolean=} [canRotate=true] - Whether to show or hide rotation control + * @param {boolean} [smart] - Show smart tool for interactive pre-annotations + * @param {boolean} [smartOnly] - Only show smart tool for interactive pre-annotations + */ +const TagAttrs = types.model({ + toname: types.maybeNull(types.string), + + opacity: types.optional(customTypes.range(), '0.2'), + fillcolor: types.optional(customTypes.color, '#f48a42'), + + strokewidth: types.optional(types.string, '1'), + strokecolor: types.optional(customTypes.color, '#f48a42'), + fillopacity: types.maybeNull(customTypes.range()), + + canrotate: types.optional(types.boolean, true), +}); + +const Model = types + .model({ + type: 'cube', + }) + .volatile(() => ({ + toolNames: ['Cube'], + })); + +const CubeModel = types.compose('CubeModel', + ControlBase, + AnnotationMixin, + SeparatedControlMixin, + TagAttrs, + Model, + ToolManagerMixin, +); + +const HtxView = () => { + return null; +}; + +Registry.addTag('cube', CubeModel, HtxView); + +export { HtxView, CubeModel }; diff --git a/src/tags/control/CubeLabels.js b/src/tags/control/CubeLabels.js new file mode 100644 index 0000000000..229f174c1c --- /dev/null +++ b/src/tags/control/CubeLabels.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { observer } from 'mobx-react'; +import { types } from 'mobx-state-tree'; + +import LabelMixin from '../../mixins/LabelMixin'; +import Registry from '../../core/Registry'; +import SelectedModelMixin from '../../mixins/SelectedModel'; +import Types from '../../core/Types'; +import { HtxLabels, LabelsModel } from './Labels/Labels'; +import { CubeModel } from './Cube'; +import { guidGenerator } from '../../core/Helpers'; +import ControlBase from './Base'; + +/** + * The `CubeLabels` tag creates labeled Cubes. Use to apply labels to bounding box semantic segmentation tasks. + * + * Use with the following data types: image. + * @example + * + * + * + * + * + * + * @name CubeLabels + * @regions RectRegion + * @meta_title Cube Label Tag to Label Cube Bounding Box in Images + * @meta_description Customize Label Studio with the CubeLabels tag and add labeled Cube bounding boxes in images for semantic segmentation and object detection machine learning and data science projects. + * @param {string} name - Name of the element + * @param {string} toName - Name of the image to label + * @param {single|multiple=} [choice=single] - Configure whether you can select one or multiple labels + * @param {number} [maxUsages] - Maximum number of times a label can be used per task + * @param {boolean} [showInline=true] - Show labels in the same visual line + * @param {float} [opacity=0.6] - Opacity of Cube + * @param {string} [fillColor] - Cube fill color in hexadecimal + * @param {string} [strokeColor] - Stroke color in hexadecimal + * @param {number} [strokeWidth=1] - Width of stroke + * @param {boolean} [canRotate=true] - Show or hide rotation control + */ + +const Validation = types.model({ + controlledTags: Types.unionTag(['Object3D']), +}); + +const ModelAttrs = types.model('CubeLabelsModel', { + pid: types.optional(types.string, guidGenerator), + type: 'cubelabels', + children: Types.unionArray(['label', 'header', 'view', 'hypertext']), +}); + +const Composition = types.compose( + ControlBase, + LabelsModel, + ModelAttrs, + CubeModel, + Validation, + LabelMixin, + SelectedModelMixin.props({ _child: 'LabelModel' }), +); + +const CubeLabelsModel = types.compose('CubeLabelsModel', Composition); + +const HtxCubeLabels = observer(({ item }) => { + return ; +}); + +Registry.addTag('cubelabels', CubeLabelsModel, HtxCubeLabels); + +export { HtxCubeLabels, CubeLabelsModel }; diff --git a/src/tags/control/Label.js b/src/tags/control/Label.js index 26e7fe72a0..1346d72dd0 100644 --- a/src/tags/control/Label.js +++ b/src/tags/control/Label.js @@ -73,6 +73,7 @@ const Model = types.model({ _value: types.optional(types.string, ''), parentTypes: Types.tagsTypes([ 'Labels', + 'CubeLabels', 'EllipseLabels', 'RectangleLabels', 'PolygonLabels', diff --git a/src/tags/control/index.js b/src/tags/control/index.js index 2405be71b6..e682995144 100644 --- a/src/tags/control/index.js +++ b/src/tags/control/index.js @@ -1,4 +1,6 @@ import { ChoicesModel } from './Choices'; +import { CubeModel } from './Cube'; +import { CubeLabelsModel } from './CubeLabels'; import { DateTimeModel } from './DateTime'; import { NumberModel } from './Number'; import { PairwiseModel } from './Pairwise'; @@ -31,6 +33,8 @@ import { RelationModel } from './Relation'; export { ChoicesModel, + CubeModel, + CubeLabelsModel, DateTimeModel, NumberModel, PairwiseModel, diff --git a/src/tags/object/Image/Image.js b/src/tags/object/Image/Image.js index 8baa558cee..37577c02e1 100644 --- a/src/tags/object/Image/Image.js +++ b/src/tags/object/Image/Image.js @@ -140,6 +140,9 @@ const IMAGE_CONSTANTS = { brushlabels: 'brushlabels', brushModel: 'BrushModel', ellipselabels: 'ellipselabels', + cubeModel: 'CubeModel', + cubeLabelsModel: 'CubeLabelsModel', + cubelabels: 'cubelabels', }; const Model = types.model({ @@ -404,7 +407,8 @@ const Model = types.model({ if ( item.type === IMAGE_CONSTANTS.rectanglelabels || item.type === IMAGE_CONSTANTS.brushlabels || - item.type === IMAGE_CONSTANTS.ellipselabels + item.type === IMAGE_CONSTANTS.ellipselabels || + item.type === IMAGE_CONSTANTS.cubelabels ) { returnedControl = item; } diff --git a/src/tags/object/Object3D/DrawingRegion.js b/src/tags/object/Object3D/DrawingRegion.js new file mode 100644 index 0000000000..a563ff7049 --- /dev/null +++ b/src/tags/object/Object3D/DrawingRegion.js @@ -0,0 +1,19 @@ +import { types } from 'mobx-state-tree'; +import Registry from '../../../core/Registry'; + +export const DrawingRegion = types.union( + { + dispatcher(sn) { + if (!sn) return types.null; + // may be a tag itself or just its name + const objectName = sn.object.name || sn.object; + // we have to use current config to detect Object tag by name + const tag = window.Htx.annotationStore.names.get(objectName); + // provide value to detect Area by data + const available = Registry.getAvailableAreas(tag.type, sn); + // union of all available Areas for this Object type + + return types.union(...available, types.null); + }, + }, +); diff --git a/src/tags/object/Object3D/Object3D.js b/src/tags/object/Object3D/Object3D.js new file mode 100644 index 0000000000..2ae7df1eb8 --- /dev/null +++ b/src/tags/object/Object3D/Object3D.js @@ -0,0 +1,1167 @@ +import { inject } from 'mobx-react'; +import { destroy, getRoot, getType, types } from 'mobx-state-tree'; + +import Object3DView from '../../../components/Object3DView/Object3DView'; +import { customTypes } from '../../../core/CustomTypes'; +import Registry from '../../../core/Registry'; +import { AnnotationMixin } from '../../../mixins/AnnotationMixin'; +import { IsReadyWithDepsMixin } from '../../../mixins/IsReadyMixin'; +import { CubeRegionModel } from '../../../regions/CubeRegion'; +import * as Tools from '../../../tools'; +import ToolsManager from '../../../tools/Manager'; +import { parseValue } from '../../../utils/data'; +import { guidGenerator } from '../../../utils/unique'; +import { clamp, isDefined } from '../../../utils/utilities'; +import ObjectBase from '../Base'; +import { DrawingRegion } from './DrawingRegion'; +import { Object3DEntityMixin } from './Object3DEntityMixin'; +import { Object3DSelection } from './Object3DSelection'; +import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH, SNAP_TO_PIXEL_MODE } from '../../../components/Object3DView/Object3D'; + +const IMAGE_PRELOAD_COUNT = 3; + +/** + * The `Object3D` tag shows an object3d on the page. Use for all object3d annotation tasks to display an object3d on the labeling interface. + * + * Use with the following data types: object3ds. + * + * When you annotate object3d regions with this tag, the annotations are saved as percentages of the original size of the object3d, from 0-100. + * + * @example + * + * + * + * + * + * + * @example + * + * + * + * + * + * + * + * @name Object3D + * @meta_title Object3D Tags for Object3Ds + * @meta_description Customize Label Studio with the Object3D tag to annotate object3ds for computer vision machine learning and data science projects. + * @param {string} name - Name of the element + * @param {string} value - Data field containing a path or URL to the object3d + * @param {string} [valueList] - References a variable that holds a list of object3d URLs + * @param {boolean} [smoothing] - Enable smoothing, by default it uses user settings + * @param {string=} [width=100%] - Object3D width + * @param {string=} [maxWidth=750px] - Maximum object3d width + * @param {boolean=} [zoom=false] - Enable zooming an object3d with the mouse wheel + * @param {boolean=} [negativeZoom=false] - Enable zooming out an object3d + * @param {float=} [zoomBy=1.1] - Scale factor + * @param {boolean=} [grid=false] - Whether to show a grid + * @param {number=} [gridSize=30] - Specify size of the grid + * @param {string=} [gridColor=#EEEEF4] - Color of the grid in hex, opacity is 0.15 + * @param {boolean} [zoomControl=false] - Show zoom controls in toolbar + * @param {boolean} [brightnessControl=false] - Show brightness control in toolbar + * @param {boolean} [contrastControl=false] - Show contrast control in toolbar + * @param {boolean} [rotateControl=false] - Show rotate control in toolbar + * @param {boolean} [crosshair=false] - Show crosshair cursor + * @param {left|center|right} [horizontalAlignment=left] - Where to align object3d horizontally. Can be one of "left", "center", or "right" + * @param {top|center|bottom} [verticalAlignment=top] - Where to align object3d vertically. Can be one of "top", "center", or "bottom" + * @param {auto|original|fit} [defaultZoom=fit] - Specify the initial zoom of the object3d within the viewport while preserving its ratio. Can be one of "auto", "original", or "fit" + * @param {none|anonymous|use-credentials} [crossOrigin=none] - Configures CORS cross domain behavior for this object3d, either "none", "anonymous", or "use-credentials", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin). + */ +const TagAttrs = types.model({ + value: types.maybeNull(types.string), + valuelist: types.maybeNull(types.string), + resize: types.maybeNull(types.number), + width: types.optional(types.string, '100%'), + height: types.maybeNull(types.string), + maxwidth: types.optional(types.string, '100%'), + maxheight: types.optional(types.string, 'calc(100vh - 194px)'), + smoothing: types.maybeNull(types.boolean), + + // rulers: types.optional(types.boolean, true), + grid: types.optional(types.boolean, false), + gridsize: types.optional(types.string, '30'), + gridcolor: types.optional(customTypes.color, '#EEEEF4'), + + zoom: types.optional(types.boolean, true), + negativezoom: types.optional(types.boolean, false), + zoomby: types.optional(types.string, '1.1'), + + showlabels: types.optional(types.boolean, false), + + zoomcontrol: types.optional(types.boolean, true), + brightnesscontrol: types.optional(types.boolean, false), + contrastcontrol: types.optional(types.boolean, false), + rotatecontrol: types.optional(types.boolean, false), + crosshair: types.optional(types.boolean, false), + selectioncontrol: types.optional(types.boolean, true), + + // this property is just to turn lazyload off to e2e tests + lazyoff: types.optional(types.boolean, false), + + horizontalalignment: types.optional(types.enumeration(['left', 'center', 'right']), 'left'), + verticalalignment: types.optional(types.enumeration(['top', 'center', 'bottom']), 'top'), + defaultzoom: types.optional(types.enumeration(['auto', 'original', 'fit']), 'fit'), + + crossorigin: types.optional(types.enumeration(['none', 'anonymous', 'use-credentials']), 'none'), +}); + +const IMAGE_CONSTANTS = { + cubeModel: 'CubeModel', + cubeLabelsModel: 'CubeLabelsModel', + cubelabels: 'cubelabels', +}; + +const Model = types.model({ + type: 'object3d', + + // tools: types.array(BaseTool), + + sizeUpdated: types.optional(types.boolean, false), + + /** + * Cursor coordinates + */ + cursorPositionX: types.optional(types.number, 0), + cursorPositionY: types.optional(types.number, 0), + + brushControl: types.optional(types.string, 'brush'), + + brushStrokeWidth: types.optional(types.number, 15), + + /** + * Mode + * brush for Object3D Segmentation + * eraser for Object3D Segmentation + */ + mode: types.optional(types.enumeration(['drawing', 'viewing']), 'viewing'), + + regions: types.array( + types.union(CubeRegionModel), + [], + ), + + drawingRegion: types.optional(DrawingRegion, null), + selectionArea: types.optional(Object3DSelection, { start: null, end: null }), +}).volatile(() => ({ + currentObject3D: undefined, + supportSuggestions: true, +})).views(self => ({ + get store() { + return getRoot(self); + }, + + get multiObject3D() { + return !!self.isMultiItem; + }, + + // an alias of currentObject3D to make an interface reusable + get currentItemIndex() { + return self.currentObject3D; + }, + + get parsedValue() { + return parseValue(self.value, self.store.task.dataObj); + }, + + get parsedValueList() { + return parseValue(self.valuelist, self.store.task.dataObj); + }, + + get currentSrc() { + return self.currentObject3DEntity.src; + }, + + get usedValue() { + return self.multiObject3D ? self.valuelist : self.value; + }, + + get object3ds() { + const value = self.parsedValue; + + if (!value) return []; + if (Array.isArray(value)) return value; + return [value]; + }, + + /** + * @return {boolean} + */ + get hasStates() { + const states = self.states(); + + return states && states.length > 0; + }, + + get selectedRegions() { + return self.regs.filter(region => region.inSelection); + }, + + get selectedRegionsBBox() { + let bboxCoords; + + self.selectedRegions.forEach((region) => { + const regionBBox = region.bboxCoords; + + if (!regionBBox) return; + + if (bboxCoords) { + bboxCoords = { + left: Math.min(regionBBox?.left, bboxCoords.left), + top: Math.min(regionBBox?.top, bboxCoords.top), + right: Math.max(regionBBox?.right, bboxCoords.right), + bottom: Math.max(regionBBox?.bottom, bboxCoords.bottom), + }; + } else { + bboxCoords = regionBBox; + } + }); + return bboxCoords; + }, + + get regionsInSelectionArea() { + return self.regs.filter(region => region.isInSelectionArea); + }, + + get selectedShape() { + return self.regs.find(r => r.selected); + }, + + get suggestions() { + return self.annotation?.regionStore.suggestions.filter(r => r.object === self) || []; + }, + + get useTransformer() { + return self.getToolsManager().findSelectedTool()?.useTransformer === true; + }, + + get stageTranslate() { + const { + stageWidth: width, + stageHeight: height, + } = self; + + return { + 0: { x: 0, y: 0 }, + 90: { x: 0, y: height }, + 180: { x: width, y: height }, + 270: { x: width, y: 0 }, + }[self.rotation]; + }, + + get stageScale() { + return self.zoomScale; + }, + + get hasTools() { + return !!self.getToolsManager().allTools()?.length; + }, + + get object3dCrossOrigin() { + const value = self.crossorigin.toLowerCase(); + + if (!value || value === 'none') { + return null; + } else { + return value; + } + }, + + get fillerHeight() { + const { naturalWidth, naturalHeight } = self; + + return self.isSideways + ? `${naturalWidth / naturalHeight * 100}%` + : `${naturalHeight / naturalWidth * 100}%`; + }, + + get zoomedPixelSize() { + const { naturalWidth, naturalHeight } = self; + + return { + x: self.stageWidth / naturalWidth, + y: self.stageHeight / naturalHeight, + }; + + }, + + isSamePixel({ x: x1, y: y1 }, { x: x2, y: y2 }) { + const zoomedPixelSizeX = self.zoomedPixelSize.x; + const zoomedPixelSizeY = self.zoomedPixelSize.y; + + return Math.abs(x1 - x2) < zoomedPixelSizeX / 2 && Math.abs(y1 - y2) < zoomedPixelSizeY / 2; + }, + + snapPointToPixel({ x,y }, snapMode = SNAP_TO_PIXEL_MODE.EDGE) { + const zoomedPixelSizeX = self.zoomedPixelSize.x; + const zoomedPixelSizeY = self.zoomedPixelSize.y; + + switch (snapMode) { + case SNAP_TO_PIXEL_MODE.EDGE: { + return { + x: Math.round(x / zoomedPixelSizeX) * zoomedPixelSizeX, + y: Math.round(y / zoomedPixelSizeY) * zoomedPixelSizeY, + }; + } + case SNAP_TO_PIXEL_MODE.CENTER: { + return { + x: Math.floor(x / zoomedPixelSizeX) * zoomedPixelSizeX + zoomedPixelSizeX / 2, + y: Math.floor(y / zoomedPixelSizeY) * zoomedPixelSizeY + zoomedPixelSizeY / 2, + }; + } + } + }, + + createSerializedResult(region, value) { + const index = region.item_index ?? 0; + const currentObject3DEntity = self.findObject3DEntity(index); + + const object3dDimension = { + original_width: currentObject3DEntity.naturalWidth, + original_height: currentObject3DEntity.naturalHeight, + object3d_rotation: currentObject3DEntity.rotation, + }; + + if (self.multiObject3D && isDefined(index)) { + object3dDimension.item_index = index; + } + + // We're using raw region result instead of calulated one when + // the object3d data is not available (object3d is not yet loaded) + // As the serialization also happens during region creation, + // we have to forsee this scenario and avoid using raw result + // as it can only be present for already created (submitter) regions + const useRawResult = !currentObject3DEntity.object3dLoaded && isDefined(region._rawResult); + + return useRawResult ? structuredClone(region._rawResult) : { + ...object3dDimension, + value, + }; + }, + + /** + * @return {object} + */ + states() { + return self.annotation.toNames.get(self.name); + }, + + activeStates() { + const states = self.states(); + + return states && states.filter(s => s.isSelected && s.type.includes('labels')); + }, + + controlButton() { + const names = self.states(); + + if (!names || names.length === 0) return; + + let returnedControl = names[0]; + + names.forEach(item => { + if ( + item.type === IMAGE_CONSTANTS.cubelabels + ) { + returnedControl = item; + } + }); + + return returnedControl; + }, + + get controlButtonType() { + const name = self.controlButton(); + + return getType(name).name; + }, + + get isSideways() { + return (self.rotation + 360) % 180 === 90; + }, + + get stageComponentSize() { + if (self.isSideways) { + return { + width: self.stageHeight, + height: self.stageWidth, + }; + } + return { + width: self.stageWidth, + height: self.stageHeight, + }; + }, + + get canvasSize() { + if (self.isSideways) { + return { + width: Math.round(self.naturalHeight * self.stageZoomX), + height: Math.round(self.naturalWidth * self.stageZoomY), + }; + } + + return { + width: Math.round(self.naturalWidth * self.stageZoomX), + height: Math.round(self.naturalHeight * self.stageZoomY), + }; + }, + + get alignmentOffset() { + const offset = { x: 0, y: 0 }; + + return offset; + }, + + get zoomBy() { + return parseFloat(self.zoomby); + }, + get isDrawing() { + return !!self.drawingRegion; + }, + + get object3dTransform() { + const imgStyle = { + // scale transform leaves gaps on object3d border, so much better to change object3d sizes + width: `${self.stageWidth * self.zoomScale}px`, + height: `${self.stageHeight * self.zoomScale}px`, + transformOrigin: 'left top', + // We should always set some transform to make the object3d rendering in the same way all the time + transform: 'translate3d(0,0,0)', + filter: `brightness(${self.brightnessGrade}%) contrast(${self.contrastGrade}%)`, + }; + const imgTransform = []; + + if (self.zoomScale !== 1) { + const { zoomingPositionX = 0, zoomingPositionY = 0 } = self; + + imgTransform.push('translate3d(' + zoomingPositionX + 'px,' + zoomingPositionY + 'px, 0)'); + } + + if (self.rotation) { + const translate = { + 90: '0, -100%', + 180: '-100%, -100%', + 270: '-100%, 0', + }; + + // there is a top left origin already set for zoom; so translate+rotate + imgTransform.push(`rotate(${self.rotation}deg)`); + imgTransform.push(`translate(${translate[self.rotation] || '0, 0'})`); + + } + + if (imgTransform?.length > 0) { + imgStyle.transform = imgTransform.join(' '); + } + return imgStyle; + }, + + get maxScale() { + return self.isSideways + ? Math.min(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth) + : Math.min(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight); + }, + + get coverScale() { + return self.isSideways + ? Math.max(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth) + : Math.max(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight); + }, + + get viewPortBBoxCoords() { + let width = self.canvasSize.width / self.zoomScale; + let height = self.canvasSize.height / self.zoomScale; + const leftOffset = -self.zoomingPositionX / self.zoomScale; + const topOffset = -self.zoomingPositionY / self.zoomScale; + const rightOffset = self.stageComponentSize.width - (leftOffset + width); + const bottomOffset = self.stageComponentSize.height - (topOffset + height); + const offsets = [leftOffset, topOffset, rightOffset, bottomOffset]; + + if (self.isSideways) { + [width, height] = [height, width]; + } + if (self.rotation) { + const rotateCount = (self.rotation / 90) % 4; + + for (let k = 0; k < rotateCount; k++) { + offsets.push(offsets.shift()); + } + } + const left = offsets[0]; + const top = offsets[1]; + + return { + left, + top, + right: left + width, + bottom: top + height, + width, + height, + }; + }, +})) + + // actions for the tools + .actions(self => { + const manager = ToolsManager.getInstance({ name: self.name }); + const env = { manager, control: self, object: self }; + + function createObject3DEntities() { + if (!self.store.task) return; + + const parsedValue = self.multiObject3D + ? self.parsedValueList + : self.parsedValue; + + if (Array.isArray(parsedValue)) { + parsedValue.forEach((src, index) => { + self.object3dEntities.push({ + id: `${self.name}#${index}`, + src, + index, + }); + }); + } else { + self.object3dEntities.push({ + id: `${self.name}#0`, + src: parsedValue, + index: 0, + }); + } + + self.setCurrentObject3D(0); + } + + function afterAttach() { + if (self.selectioncontrol) + manager.addTool('MoveTool', Tools.Selection.create({}, env)); + + if (self.zoomcontrol) + manager.addTool('ZoomPanTool', Tools.Zoom.create({}, env)); + + if (self.brightnesscontrol) + manager.addTool('BrightnessTool', Tools.Brightness.create({}, env)); + + if (self.contrastcontrol) + manager.addTool('ContrastTool', Tools.Contrast.create({}, env)); + + if (self.rotatecontrol) + manager.addTool('RotateTool', Tools.Rotate.create({}, env)); + + createObject3DEntities(); + } + + function afterResultCreated(region) { + if (!region) return; + if (region.classification) return; + if (!self.multiObject3D) return; + + region.setItemIndex?.(self.currentObject3D); + } + + function getToolsManager() { + return manager; + } + + return { + afterAttach, + getToolsManager, + afterResultCreated, + }; + }).extend((self) => { + let skipInteractions = false; + + return { + views: { + getSkipInteractions() { + const manager = self.getToolsManager(); + + const isPanning = manager.findSelectedTool()?.toolName === 'ZoomPanTool'; + + return skipInteractions || isPanning; + }, + }, + actions: { + setSkipInteractions(value) { + skipInteractions = value; + }, + updateSkipInteractions(e) { + const currentTool = self.getToolsManager().findSelectedTool(); + + if (currentTool?.shouldSkipInteractions) { + return self.setSkipInteractions(currentTool.shouldSkipInteractions(e)); + } + self.setSkipInteractions(e.evt && (e.evt.metaKey || e.evt.ctrlKey)); + }, + }, + }; + }).actions(self => ({ + freezeHistory() { + //self.annotation.history.freeze(); + }, + + afterRegionSelected(region) { + if (self.multiObject3D) { + self.setCurrentObject3D(region.item_index); + } + }, + + createDrawingRegion(areaValue, resultValue, control, dynamic) { + const controlTag = self.annotation.names.get(control.name); + + const result = { + from_name: controlTag, + to_name: self, + type: control.resultType, + value: resultValue, + }; + + const areaRaw = { + id: guidGenerator(), + object: self, + ...areaValue, + results: [result], + dynamic, + item_index: self.currentObject3D, + }; + + self.drawingRegion = areaRaw; + return self.drawingRegion; + }, + + deleteDrawingRegion() { + const { drawingRegion } = self; + + if (!drawingRegion) return; + self.drawingRegion = null; + destroy(drawingRegion); + }, + + setSelectionStart(point) { + self.selectionArea.setStart(point); + }, + setSelectionEnd(point) { + self.selectionArea.setEnd(point); + }, + resetSelection() { + self.selectionArea.setStart(null); + self.selectionArea.setEnd(null); + }, + + updateBrushControl(arg) { + self.brushControl = arg; + }, + + updateBrushStrokeWidth(arg) { + self.brushStrokeWidth = arg; + }, + + /** + * Update brightnessGrade of Object3D + * @param {number} value + */ + setBrightnessGrade(value) { + self.brightnessGrade = value; + }, + + setContrastGrade(value) { + self.contrastGrade = value; + }, + + setGridSize(value) { + self.gridsize = String(value); + }, + + // an alias of setCurrentObject3D for making an interface reusable + setCurrentItem(index = 0) { + self.setCurrentObject3D(index); + }, + + setCurrentObject3D(index = 0) { + index = index ?? 0; + if (index === self.currentObject3D) return; + + self.currentObject3D = index; + self.currentObject3DEntity = self.findObject3DEntity(index); + }, + + preloadObject3Ds() { + self.currentObject3DEntity.setObject3DLoaded(false); + self.currentObject3DEntity.preload(); + + if (self.multiObject3D) { + const [currentIndex, length] = [self.currentObject3D, self.object3dEntities.length]; + const prevSliceIndex = clamp(currentIndex - IMAGE_PRELOAD_COUNT, 0, currentIndex); + const nextSliceIndex = clamp(currentIndex + 1 + IMAGE_PRELOAD_COUNT, currentIndex, length - 1); + + const object3ds = [ + ...self.object3dEntities.slice(prevSliceIndex, currentIndex), + ...self.object3dEntities.slice(currentIndex + 1, nextSliceIndex), + ]; + + object3ds.forEach((object3dEntity) => { + object3dEntity.preload(); + }); + } + }, + + /** + * Set pointer of X and Y + */ + setPointerPosition({ x, y }) { + self.freezeHistory(); + self.cursorPositionX = x; + self.cursorPositionY = y; + }, + + /** + * Set zoom + */ + setZoom(scale) { + scale = clamp(scale, 1, Infinity); + self.currentZoom = scale; + + // cool comment about all this stuff + const maxScale = self.maxScale; + const coverScale = self.coverScale; + + if (maxScale > 1) { // object3d < container + if (scale < maxScale) { // scale = 1 or before stage size is max + self.stageZoom = scale; // scale stage + self.zoomScale = 1; // don't scale object3d + } else { + self.stageZoom = maxScale; // scale stage to max + self.zoomScale = scale / maxScale; // scale object3d for the rest scale + } + } else { // object3d > container + if (scale > maxScale) { // scale = 1 or any other zoom bigger then viewport + self.stageZoom = maxScale; // stage squizzed + self.zoomScale = scale; // scale object3d for the rest scale : scale object3d usually + } else { // negative zoom bigger than object3d negative scale + self.stageZoom = scale; // squize stage more + self.zoomScale = 1; // don't scale object3d + } + } + + if (self.zoomScale > 1) { + // zoomScale scales object3d above maxScale, so scale the rest of stage the same way + const z = Math.min(maxScale * self.zoomScale, coverScale); + + if (self.containerWidth / self.naturalWidth > self.containerHeight / self.naturalHeight) { + self.stageZoomX = z; + self.stageZoomY = self.stageZoom; + } else { + self.stageZoomX = self.stageZoom; + self.stageZoomY = z; + } + } else { + self.stageZoomX = self.stageZoom; + self.stageZoomY = self.stageZoom; + } + }, + + updateObject3DAfterZoom() { + const { stageWidth, stageHeight } = self; + + self._recalculateObject3DParams(); + + if (stageWidth !== self.stageWidth || stageHeight !== self.stageHeight) { + self._updateRegionsSizes({ + width: self.stageWidth, + height: self.stageHeight, + naturalWidth: self.naturalWidth, + naturalHeight: self.naturalHeight, + }); + } + }, + + setZoomPosition(x, y) { + const [width, height] = [self.containerWidth, self.containerHeight]; + + const [minX, minY] = [ + width - self.stageComponentSize.width * self.zoomScale, + height - self.stageComponentSize.height * self.zoomScale, + ]; + + self.zoomingPositionX = clamp(x, minX, 0); + self.zoomingPositionY = clamp(y, minY, 0); + }, + + resetZoomPositionToCenter() { + const { stageComponentSize, zoomScale } = self; + const { width, height } = stageComponentSize; + + const [containerWidth, containerHeight] = [self.containerWidth, self.containerHeight]; + + self.setZoomPosition((containerWidth - width * zoomScale) / 2, (containerHeight - height * zoomScale) / 2); + }, + + sizeToFit() { + const { maxScale } = self; + + self.defaultzoom = 'fit'; + self.setZoom(maxScale); + self.updateObject3DAfterZoom(); + self.resetZoomPositionToCenter(); + }, + + sizeToOriginal() { + const { maxScale } = self; + + self.defaultzoom = 'original'; + self.setZoom(maxScale > 1 ? 1 : 1 / maxScale); + self.updateObject3DAfterZoom(); + self.resetZoomPositionToCenter(); + }, + + sizeToAuto() { + self.defaultzoom = 'auto'; + self.setZoom(1); + self.updateObject3DAfterZoom(); + self.resetZoomPositionToCenter(); + }, + + handleZoom(val, mouseRelativePos = { x: self.canvasSize.width / 2, y: self.canvasSize.height / 2 }) { + if (val) { + let zoomScale = self.currentZoom; + + zoomScale = val > 0 ? zoomScale * self.zoomBy : zoomScale / self.zoomBy; + if (self.negativezoom !== true && zoomScale <= 1) { + self.setZoom(1); + self.setZoomPosition(0, 0); + self.updateObject3DAfterZoom(); + return; + } + if (zoomScale <= 1) { + self.setZoom(zoomScale); + self.setZoomPosition(0, 0); + self.updateObject3DAfterZoom(); + return; + } + + // DON'T TOUCH THIS + let stageScale = self.zoomScale; + + const mouseAbsolutePos = { + x: (mouseRelativePos.x - self.zoomingPositionX) / stageScale, + y: (mouseRelativePos.y - self.zoomingPositionY) / stageScale, + }; + + self.setZoom(zoomScale); + + stageScale = self.zoomScale; + + const zoomingPosition = { + x: -(mouseAbsolutePos.x - mouseRelativePos.x / stageScale) * stageScale, + y: -(mouseAbsolutePos.y - mouseRelativePos.y / stageScale) * stageScale, + }; + + self.setZoomPosition(zoomingPosition.x, zoomingPosition.y); + self.updateObject3DAfterZoom(); + } + }, + + /** + * Set mode of Object3D (drawing and viewing) + * @param {string} mode + */ + setMode(mode) { + self.mode = mode; + }, + + setObject3DRef(ref) { + self.object3dRef = ref; + }, + + setContainerRef(ref) { + self.containerRef = ref; + }, + + setStageRef(ref) { + self.stageRef = ref; + + const currentTool = self.getToolsManager().findSelectedTool(); + + currentTool?.updateCursor?.(); + }, + + setOverlayRef(ref) { + self.overlayRef = ref; + }, + + // @todo remove + setSelected() { + // self.selectedShape = shape; + }, + + rotate(degree = -90) { + self.rotation = (self.rotation + degree + 360) % 360; + + let ratioK = 1 / self.stageRatio; + + if (self.isSideways) { + self.stageRatio = self.naturalWidth / self.naturalHeight; + } else { + self.stageRatio = 1; + } + ratioK = ratioK * self.stageRatio; + + self.setZoom(self.currentZoom); + + if (degree === -90) { + this.setZoomPosition( + self.zoomingPositionY * ratioK, + self.stageComponentSize.height - + self.zoomingPositionX * ratioK - + self.stageComponentSize.height * self.zoomScale, + ); + } + if (degree === 90) { + this.setZoomPosition( + self.stageComponentSize.width - + self.zoomingPositionY * ratioK - + self.stageComponentSize.width * self.zoomScale, + self.zoomingPositionX * ratioK, + ); + } + + self.updateObject3DAfterZoom(); + }, + + _recalculateObject3DParams() { + self.stageWidth = Math.round(self.naturalWidth * self.stageZoom); + self.stageHeight = Math.round(self.naturalHeight * self.stageZoom); + }, + + _updateObject3DSize({ width, height, userResize }) { + if (self.naturalWidth === undefined) { + return; + } + if (width > 1 && height > 1) { + const prevWidth = self.canvasSize.width; + const prevHeight = self.canvasSize.height; + const prevStageZoom = self.stageZoom; + const prevZoomScale = self.zoomScale; + + self.containerWidth = width; + self.containerHeight = height; + + // reinit zoom to calc stageW/H + self.setZoom(self.currentZoom); + + self._recalculateObject3DParams(); + + const zoomChangeRatio = self.stageZoom / prevStageZoom; + const scaleChangeRatio = self.zoomScale / prevZoomScale; + const changeRatio = zoomChangeRatio * scaleChangeRatio; + + + self.setZoomPosition( + self.zoomingPositionX * changeRatio + (self.canvasSize.width / 2 - prevWidth / 2 * changeRatio), + self.zoomingPositionY * changeRatio + (self.canvasSize.height / 2 - prevHeight / 2 * changeRatio), + ); + } + + self.sizeUpdated = true; + self._updateRegionsSizes({ + width: self.stageWidth, + height: self.stageHeight, + naturalWidth: self.naturalWidth, + naturalHeight: self.naturalHeight, + userResize, + }); + }, + + _updateRegionsSizes({ width, height, naturalWidth, naturalHeight, userResize }) { + const _historyLength = self.annotation?.history?.history?.length; + + self.annotation.history.freeze(); + + self.regions.forEach(shape => { + shape.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize); + }); + self.regs.forEach(shape => { + shape.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize); + }); + self.drawingRegion?.updateObject3DSize(width / naturalWidth, height / naturalHeight, width, height, userResize); + + setTimeout(self.annotation.history.unfreeze, 0); + + //sometimes when user zoomed in, annotation was creating a new history. This fix that in case the user has nothing in the history yet + if (_historyLength <= 1) { + // Don't force unselection of regions during the updateObjects callback from history reinit + setTimeout(() => self.annotation?.reinitHistory(false), 0); + } + }, + + updateObject3DSize(ev) { + const { naturalWidth, naturalHeight } = self.object3dRef ?? ev.target; + const { offsetWidth, offsetHeight } = self.containerRef; + + self.naturalWidth = naturalWidth; + self.naturalHeight = naturalHeight; + + self._updateObject3DSize({ width: offsetWidth, height: offsetHeight }); + // after regions' sizes adjustment we have to reset all saved history changes + // mobx do some batch update here, so we have to reset it asynchronously + // this happens only after initial load, so it's safe + self.setReady(true); + + if (self.defaultzoom === 'fit') { + self.sizeToFit(); + } else { + self.sizeToAuto(); + } + // Don't force unselection of regions during the updateObjects callback from history reinit + setTimeout(() => self.annotation?.reinitHistory(false), 0); + }, + + checkLabels() { + const labelStates = (self.states() || []).filter(s => s.type.includes('labels')); + const selectedStates = self.getAvailableStates(); + + return selectedStates.length !== 0 || labelStates.length === 0; + }, + + addShape(shape) { + self.regions.push(shape); + self.annotation.addRegion(shape); + self.setSelected(shape.id); + shape.selectRegion(); + }, + + /** + * Resize of object3d canvas + * @param {*} width + * @param {*} height + */ + onResize(width, height, userResize) { + self._updateObject3DSize({ width, height, userResize }); + }, + + event(name, ev, screenX, screenY) { + const [canvasX, canvasY] = self.fixZoomedCoords([screenX, screenY]); + + const x = self.canvasToInternalX(canvasX); + const y = self.canvasToInternalY(canvasY); + + self.getToolsManager().event(name, ev.evt || ev, x, y, canvasX, canvasY); + }, + })); + +const CoordsCalculations = types.model() + .actions(self => ({ + // convert screen coords to object3d coords considering zoom + fixZoomedCoords([x, y]) { + if (!self.stageRef) { + return [x, y]; + } + + // good official way, but maybe a bit slower and with repeating cloning + const p = self.stageRef.getAbsoluteTransform().copy().invert().point({ x, y }); + + return [p.x, p.y]; + }, + + // convert object3d coords to screen coords considering zoom + zoomOriginalCoords([x, y]) { + const p = self.stageRef.getAbsoluteTransform().point({ x, y }); + + return [p.x, p.y]; + }, + + /** + * @typedef {number[]|{ x: number, y: number }} Point + */ + + /** + * @callback PointFn + * @param {Point} point + * @returns Point + */ + + /** + * Wrap point operations to convert zoomed coords from screen to object3d and back + * Good for event handlers, receiving screen coords, but working with object3d coords + * Accepts both [x, y] and {x, y} points; preserves this format + * @param {PointFn} fn wrapped function do some math with object3d coords + * @return {PointFn} outer function do some math with screen coords + */ + fixForZoom(fn) { + return p => this.fixForZoomWrapper(p, fn); + }, + fixForZoomWrapper(p, fn) { + const asArray = p.x === undefined; + const [x, y] = self.fixZoomedCoords(asArray ? p : [p.x, p.y]); + const modified = fn(asArray ? [x, y] : { x, y }); + const zoomed = self.zoomOriginalCoords(asArray ? modified : [modified.x, modified.y]); + + return asArray ? zoomed : { x: zoomed[0], y: zoomed[1] }; + }, + })) + // putting this transforms to views forces other getters to be recalculated on resize + .views(self => ({ + // helps to calculate rotation because internal coords are square and real one usually aren't + get whRatio() { + return self.stageWidth / self.stageHeight; + }, + + // @todo scale? + canvasToInternalX(n) { + return n / self.stageWidth * RELATIVE_STAGE_WIDTH; + }, + + canvasToInternalY(n) { + return n / self.stageHeight * RELATIVE_STAGE_HEIGHT; + }, + + internalToCanvasX(n) { + return n / RELATIVE_STAGE_WIDTH * self.stageWidth; + }, + + internalToCanvasY(n) { + return n / RELATIVE_STAGE_HEIGHT * self.stageHeight; + }, + })); + +// mock coords calculations to transparently pass coords with FF 3793 off +const AbsoluteCoordsCalculations = CoordsCalculations + .views(() => ({ + canvasToInternalX(n) { + return n; + }, + canvasToInternalY(n) { + return n; + }, + internalToCanvasX(n) { + return n; + }, + internalToCanvasY(n) { + return n; + }, + })); + +const Object3DModel = types.compose( + 'Object3DModel', + TagAttrs, + ObjectBase, + AnnotationMixin, + IsReadyWithDepsMixin, + Object3DEntityMixin, + Model, + AbsoluteCoordsCalculations, +); + +const HtxObject3D = inject('store')(Object3DView); + +Registry.addTag('object3d', Object3DModel, HtxObject3D); +Registry.addObjectType(Object3DModel); + +export { Object3DModel, HtxObject3D }; diff --git a/src/tags/object/Object3D/Object3DEntity.js b/src/tags/object/Object3D/Object3DEntity.js new file mode 100644 index 0000000000..674a306f5d --- /dev/null +++ b/src/tags/object/Object3D/Object3DEntity.js @@ -0,0 +1,189 @@ +import { types } from 'mobx-state-tree'; +import { FileLoader } from '../../../utils/FileLoader'; +import { clamp } from '../../../utils/utilities'; + +const fileLoader = new FileLoader(); + +export const Object3DEntity = types.model({ + id: types.identifier, + src: types.string, + index: types.number, + + rotation: types.optional(types.number, 0), + + /** + * Natural sizes of Object3D + * Constants + */ + naturalWidth: types.optional(types.integer, 1), + naturalHeight: types.optional(types.integer, 1), + + stageWidth: types.optional(types.number, 1), + stageHeight: types.optional(types.number, 1), + + /** + * Zoom Scale + */ + zoomScale: types.optional(types.number, 1), + + /** + * Coordinates of left top corner + * Default: { x: 0, y: 0 } + */ + zoomingPositionX: types.optional(types.number, 0), + zoomingPositionY: types.optional(types.number, 0), + + /** + * Brightness of Canvas + */ + brightnessGrade: types.optional(types.number, 100), + + contrastGrade: types.optional(types.number, 100), +}).volatile(() => ({ + stageRatio: 1, + // Container's sizes causing limits to calculate a scale factor + containerWidth: 1, + containerHeight: 1, + + stageZoom: 1, + stageZoomX: 1, + stageZoomY: 1, + currentZoom: 1, + + /** Is object3d downloaded to local cache */ + downloaded: false, + /** Is object3d being downloaded */ + downloading: false, + /** If error happened during download */ + error: false, + /** Download progress 0..1 */ + progress: 0, + /** Local object3d src created with URL.createURLObject */ + currentSrc: undefined, + /** Is object3d loaded using `` tag and cached by the browser */ + object3dLoaded: false, +})).actions((self) => ({ + preload() { + if (self.ensurePreloaded()) return; + + self.setDownloading(true); + + fileLoader.download(self.src, (_t, _l, progress) => { + self.setProgress(progress); + }).then((url) => { + self.setDownloaded(true); + self.setDownloading(false); + self.setCurrentSrc(url); + }).catch(() => { + self.setDownloading(false); + self.setError(true); + }); + }, + + ensurePreloaded() { + if (fileLoader.isError(self.src)) { + self.setDownloading(false); + self.setError(true); + return true; + } else if (fileLoader.isPreloaded(self.src)) { + self.setDownloading(false); + self.setDownloaded(true); + self.setProgress(1); + self.setCurrentSrc(fileLoader.getPreloadedURL(self.src)); + return true; + } + return false; + }, + + setObject3DLoaded(value) { + self.object3dLoaded = value; + }, + + setProgress(progress) { + self.progress = clamp(progress, 0, 100); + }, + + setDownloading(downloading) { + self.downloading = downloading; + }, + + setDownloaded(downloaded) { + self.downloaded = downloaded; + }, + + setCurrentSrc(src) { + self.currentSrc = src; + }, + + setError() { + self.error = true; + }, +})).actions(self => ({ + setRotation(angle) { + self.rotation = angle; + }, + + setNaturalWidth(width) { + self.naturalWidth = width; + }, + + setNaturalHeight(height) { + self.naturalHeight = height; + }, + + setStageWidth(width) { + self.stageWidth = width; + }, + + setStageHeight(height) { + self.stageHeight = height; + }, + + setStageRatio(ratio) { + self.stageRatio = ratio; + }, + + setContainerWidth(width) { + self.containerWidth = width; + }, + + setContainerHeight(height) { + self.containerHeight = height; + }, + + setStageZoom(zoom) { + self.stageZoom = zoom; + }, + + setStageZoomX(zoom) { + self.stageZoomX = zoom; + }, + + setStageZoomY(zoom) { + self.stageZoomY = zoom; + }, + + setCurrentZoom(zoom) { + self.currentZoom = zoom; + }, + + setZoomScale(zoomScale) { + self.zoomScale = zoomScale; + }, + + setZoomingPositionX(x) { + self.zoomingPositionX = x; + }, + + setZoomingPositionY(y) { + self.zoomingPositionY = y; + }, + + setBrightnessGrade(grade) { + self.brightnessGrade = grade; + }, + + setContrastGrade(grade) { + self.contrastGrade = grade; + }, +})); diff --git a/src/tags/object/Object3D/Object3DEntityMixin.js b/src/tags/object/Object3D/Object3DEntityMixin.js new file mode 100644 index 0000000000..ac137494a9 --- /dev/null +++ b/src/tags/object/Object3D/Object3DEntityMixin.js @@ -0,0 +1,167 @@ +import { isAlive, types } from 'mobx-state-tree'; +import { Object3DEntity } from './Object3DEntity'; + +export const Object3DEntityMixin = types + .model({ + currentObject3DEntity: types.maybeNull(types.reference(Object3DEntity)), + + object3dEntities: types.optional(types.array(Object3DEntity), []), + }) + .actions(self => { + return { + beforeDestroy() { + self.currentObject3DEntity = null; + }, + }; + }) + .views(self => ({ + get maxItemIndex() { + return self.object3dEntities.length - 1; + }, + + get object3dIsLoaded() { + const object3dEntity = self.currentObject3DEntity; + + return ( + !object3dEntity.downloading && + !object3dEntity.error && + object3dEntity.downloaded && + object3dEntity.object3dLoaded + ); + }, + get rotation() { + if (!isAlive(self)) { + return void 0; + } + return self.currentObject3DEntity?.rotation; + }, + set rotation(value) { + self.currentObject3DEntity?.setRotation(value); + }, + + get naturalWidth() { + return self.currentObject3DEntity?.naturalWidth; + }, + set naturalWidth(value) { + self.currentObject3DEntity?.setNaturalWidth(value); + }, + + get naturalHeight() { + return self.currentObject3DEntity?.naturalHeight; + }, + set naturalHeight(value) { + self.currentObject3DEntity?.setNaturalHeight(value); + }, + + get stageWidth() { + return self.currentObject3DEntity?.stageWidth; + }, + set stageWidth(value) { + self.currentObject3DEntity?.setStageWidth(value); + }, + + get stageHeight() { + return self.currentObject3DEntity?.stageHeight; + }, + set stageHeight(value) { + self.currentObject3DEntity?.setStageHeight(value); + }, + + get stageRatio() { + return self.currentObject3DEntity?.stageRatio; + }, + set stageRatio(value) { + self.currentObject3DEntity?.setStageRatio(value); + }, + + get containerWidth() { + return self.currentObject3DEntity?.containerWidth; + }, + set containerWidth(value) { + self.currentObject3DEntity?.setContainerWidth(value); + }, + + get containerHeight() { + return self.currentObject3DEntity?.containerHeight; + }, + set containerHeight(value) { + self.currentObject3DEntity?.setContainerHeight(value); + }, + + get stageZoom() { + return self.currentObject3DEntity?.stageZoom; + }, + set stageZoom(value) { + self.currentObject3DEntity?.setStageZoom(value); + }, + + get stageZoomX() { + return self.currentObject3DEntity?.stageZoomX; + }, + set stageZoomX(value) { + self.currentObject3DEntity?.setStageZoomX(value); + }, + + get stageZoomY() { + return self.currentObject3DEntity?.stageZoomY; + }, + set stageZoomY(value) { + self.currentObject3DEntity?.setStageZoomY(value); + }, + + get currentZoom() { + return self.currentObject3DEntity?.currentZoom; + }, + set currentZoom(value) { + self.currentObject3DEntity?.setCurrentZoom(value); + }, + + get zoomScale() { + if (!isAlive(self)) { + return void 0; + } + return self.currentObject3DEntity?.zoomScale; + }, + set zoomScale(value) { + self.currentObject3DEntity?.setZoomScale(value); + }, + + get zoomingPositionX() { + if (!isAlive(self)) { + return void 0; + } + return self.currentObject3DEntity?.zoomingPositionX; + }, + set zoomingPositionX(value) { + self.currentObject3DEntity?.setZoomingPositionX(value); + }, + + get zoomingPositionY() { + if (!isAlive(self)) { + return null; + } + return self.currentObject3DEntity?.zoomingPositionY; + }, + set zoomingPositionY(value) { + self.currentObject3DEntity?.setZoomingPositionY(value); + }, + + get brightnessGrade() { + return self.currentObject3DEntity?.brightnessGrade; + }, + set brightnessGrade(value) { + self.currentObject3DEntity?.setBrightnessGrade(value); + }, + + get contrastGrade() { + return self.currentObject3DEntity?.contrastGrade; + }, + set contrastGrade(value) { + self.currentObject3DEntity?.setContrastGrade(value); + }, + + findObject3DEntity(index) { + index = index ?? 0; + return self.object3dEntities.find(entity => entity.index === index); + }, + })); diff --git a/src/tags/object/Object3D/Object3DSelection.js b/src/tags/object/Object3D/Object3DSelection.js new file mode 100644 index 0000000000..afae1f0441 --- /dev/null +++ b/src/tags/object/Object3D/Object3DSelection.js @@ -0,0 +1,131 @@ +import { getParent, types } from 'mobx-state-tree'; +import { Object3DSelectionPoint } from './Object3DSelectionPoint'; +import { FF_DEV_3793, isFF } from '../../../utils/feature-flags'; +import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH } from '../../../components/Object3DView/Object3D'; + +export const Object3DSelection = types.model({ + start: types.maybeNull(Object3DSelectionPoint), + end: types.maybeNull(Object3DSelectionPoint), +}).views(self => { + return { + get obj() { + return getParent(self); + }, + get annotation() { + return self.obj.annotation; + }, + get highlightedNodeExists() { + return !!self.annotation.highlightedNode; + }, + get isActive() { + return self.start && self.end; + }, + get x() { + return Math.min((self.start.x * self.scale), (self.end.x * self.scale)); + }, + get y() { + return Math.min((self.start.y * self.scale), (self.end.y * self.scale)); + }, + get width() { + return Math.abs((self.end.x * self.scale) - (self.start.x * self.scale)); + }, + get height() { + return Math.abs((self.end.y * self.scale) - (self.start.y * self.scale)); + }, + get scale() { + return self.obj.zoomScale; + }, + get bbox() { + const { start, end } = self; + + return self.isActive ? { + left: Math.min(start.x, end.x), + top: Math.min(start.y, end.y), + right: Math.max(start.x, end.x), + bottom: Math.max(start.y, end.y), + } : null; + }, + get onCanvasBbox() { + if (!self.isActive) return null; + + const { start, end } = self; + + return { + left: self.obj.internalToCanvasX(Math.min(start.x, end.x)), + top: self.obj.internalToCanvasY(Math.min(start.y, end.y)), + right: self.obj.internalToCanvasX(Math.max(start.x, end.x)), + bottom: self.obj.internalToCanvasY(Math.max(start.y, end.y)), + }; + }, + get onCanvasRect() { + if (!isFF(FF_DEV_3793)) return self; + + if (!self.isActive) return null; + + const bbox = self.onCanvasBbox; + + return { + x: bbox.left, + y: bbox.top, + width: bbox.right - bbox.left, + height: bbox.bottom - bbox.top, + }; + }, + includesBbox(bbox) { + if (!self.isActive || !bbox) return false; + const isLeftOf = self.bbox.left <= bbox.left; + const isAbove = self.bbox.top <= bbox.top; + const isRightOf = self.bbox.right >= bbox.right; + const isBelow = self.bbox.bottom >= bbox.bottom; + + return isLeftOf && isAbove && isRightOf && isBelow; + }, + intersectsBbox(bbox) { + if (!self.isActive || !bbox) return false; + const selfCenterX = (self.bbox.left + self.bbox.right) / 2; + const selfCenterY = (self.bbox.top + self.bbox.bottom) / 2; + const selfWidth = self.bbox.right - self.bbox.left; + const selfHeight = self.bbox.bottom - self.bbox.top; + const targetCenterX = (bbox.left + bbox.right) / 2; + const targetCenterY = (bbox.top + bbox.bottom) / 2; + const targetWidth = bbox.right - bbox.left; + const targetHeight = bbox.bottom - bbox.top; + + return (Math.abs(selfCenterX - targetCenterX) * 2 < (selfWidth + targetWidth)) && + (Math.abs(selfCenterY - targetCenterY) * 2 < (selfHeight + targetHeight)); + }, + get selectionBorders() { + if (self.isActive || !self.obj.selectedRegions.length) return null; + + const initial = isFF(FF_DEV_3793) + ? { left: RELATIVE_STAGE_WIDTH, top: RELATIVE_STAGE_HEIGHT, right: 0, bottom: 0 } + : { left: self.obj.stageWidth, top: self.obj.stageHeight, right: 0, bottom: 0 }; + const bbox = self.obj.selectedRegions.reduce((borders, region) => { + return region.bboxCoords ? { + left: Math.min(borders.left, region.bboxCoords.left), + top: Math.min(borders.top,region.bboxCoords.top), + right: Math.max(borders.right, region.bboxCoords.right), + bottom: Math.max(borders.bottom, region.bboxCoords.bottom), + } : borders; + }, initial); + + if (!isFF(FF_DEV_3793)) return bbox; + + return { + left: self.obj.internalToCanvasX(bbox.left), + top: self.obj.internalToCanvasY(bbox.top), + right: self.obj.internalToCanvasX(bbox.right), + bottom: self.obj.internalToCanvasY(bbox.bottom), + }; + }, + }; +}).actions(self => { + return { + setStart(point) { + self.start = point; + }, + setEnd(point) { + self.end = point; + }, + }; +}); diff --git a/src/tags/object/Object3D/Object3DSelectionPoint.js b/src/tags/object/Object3D/Object3DSelectionPoint.js new file mode 100644 index 0000000000..dac85aabf3 --- /dev/null +++ b/src/tags/object/Object3D/Object3DSelectionPoint.js @@ -0,0 +1,6 @@ +import { types } from 'mobx-state-tree'; + +export const Object3DSelectionPoint = types.model({ + x: types.number, + y: types.number, +}); diff --git a/src/tags/object/Object3D/index.js b/src/tags/object/Object3D/index.js new file mode 100644 index 0000000000..1822688d52 --- /dev/null +++ b/src/tags/object/Object3D/index.js @@ -0,0 +1 @@ +export { Object3DModel, HtxObject3D } from './Object3D'; diff --git a/src/tags/object/PagedView.js b/src/tags/object/PagedView.js index 7fcdf5d8bb..de57a06e23 100644 --- a/src/tags/object/PagedView.js +++ b/src/tags/object/PagedView.js @@ -27,12 +27,14 @@ const Model = types.model({ 'number', 'rating', 'ranker', + 'cube', 'rectangle', 'ellipse', 'polygon', 'keypoint', 'brush', 'magicwand', + 'cubelabels', 'rectanglelabels', 'ellipselabels', 'polygonlabels', @@ -62,6 +64,7 @@ const Model = types.model({ 'paragraphlabels', 'video', 'videorectangle', + 'object3d', ]), }); diff --git a/src/tags/object/index.js b/src/tags/object/index.js index a0497f6616..26700e9204 100644 --- a/src/tags/object/index.js +++ b/src/tags/object/index.js @@ -7,6 +7,7 @@ import { TimeSeriesModel } from './TimeSeries'; import { PagedViewModel } from './PagedView'; import { VideoModel } from './Video'; import { ListModel } from './List'; +import { Object3DModel } from './Object3D'; // stub files to keep docs of these tags import './HyperText'; @@ -21,5 +22,6 @@ export { VideoModel, TableModel, PagedViewModel, - ListModel + ListModel, + Object3DModel }; diff --git a/src/tags/visual/Collapse.js b/src/tags/visual/Collapse.js index b350fb5e7d..5e0d519db3 100644 --- a/src/tags/visual/Collapse.js +++ b/src/tags/visual/Collapse.js @@ -40,11 +40,13 @@ const PanelModel = types.model({ 'choice', 'rating', 'ranker', + 'cube', 'rectangle', 'ellipse', 'polygon', 'keypoint', 'brush', + 'cubelabels', 'rectanglelabels', 'ellipselabels', 'polygonlabels', @@ -68,6 +70,7 @@ const PanelModel = types.model({ 'timeserieslabels', 'paragraphs', 'paragraphlabels', + 'object3d', ]), }); diff --git a/src/tags/visual/View.js b/src/tags/visual/View.js index 52cbd32c6e..e80918f4b4 100644 --- a/src/tags/visual/View.js +++ b/src/tags/visual/View.js @@ -57,12 +57,14 @@ const Model = types 'number', 'rating', 'ranker', + 'cube', 'rectangle', 'ellipse', 'polygon', 'keypoint', 'brush', 'magicwand', + 'cubelabels', 'rectanglelabels', 'ellipselabels', 'polygonlabels', @@ -93,6 +95,7 @@ const Model = types 'video', 'videorectangle', 'ranker', + 'object3d', ]), }); diff --git a/src/tools/Cube.js b/src/tools/Cube.js new file mode 100644 index 0000000000..13b09d3ea5 --- /dev/null +++ b/src/tools/Cube.js @@ -0,0 +1,107 @@ +import { types } from 'mobx-state-tree'; + +import BaseTool, { DEFAULT_DIMENSIONS } from './Base'; +import ToolMixin from '../mixins/Tool'; +import { CubeDrawingTool } from '../mixins/DrawingTool'; +import { AnnotationMixin } from '../mixins/AnnotationMixin'; +import { NodeViews } from '../components/Node/Node'; +import { FF_DEV_3793, isFF } from '../utils/feature-flags'; + +const _BaseNPointTool = types + .model('BaseNTool', { + group: 'segmentation', + smart: true, + shortcut: 'R', + }) + .views(self => { + const Super = { + createRegionOptions: self.createRegionOptions, + isIncorrectControl: self.isIncorrectControl, + isIncorrectLabel: self.isIncorrectLabel, + }; + + return { + get getActivePolygon() { + const poly = self.currentArea; + + if (poly && poly.closed) return null; + if (poly === undefined) return null; + if (poly && poly.type !== 'rectangleregion') return null; + + return poly; + }, + + get tagTypes() { + return { + stateTypes: 'rectanglelabels', + controlTagTypes: ['rectanglelabels', 'rectangle'], + }; + }, + get defaultDimensions() { + return DEFAULT_DIMENSIONS.rect; + }, + createRegionOptions({ x, y }) { + return Super.createRegionOptions({ + x, + y, + height: isFF(FF_DEV_3793) ? self.obj.canvasToInternalY(1) : 1, + width: isFF(FF_DEV_3793) ? self.obj.canvasToInternalX(1) : 1, + }); + }, + + isIncorrectControl() { + return Super.isIncorrectControl() && self.current() === null; + }, + isIncorrectLabel() { + return !self.current() && Super.isIncorrectLabel(); + }, + canStart() { + return self.current() === null && !self.annotation.isReadOnly(); + }, + + current() { + return self.getActivePolygon; + }, + }; + }) + .actions(self => ({ + beforeCommitDrawing() { + const s = self.getActiveShape; + + return s.width > self.MIN_SIZE.X && s.height * self.MIN_SIZE.Y; + }, + })); + +const _Tool = types + .model('RectangleTool', { + shortcut: 'R', + }) + .views(self => ({ + get viewTooltip() { + return 'Rectangle'; + }, + get iconComponent() { + return self.dynamic + ? NodeViews.RectRegionModel.altIcon + : NodeViews.RectRegionModel.icon; + }, + })); + +const _Tool3Point = types + .model('Rectangle3PointTool', { + shortcut: 'shift+R', + }) + .views(self => ({ + get viewTooltip() { + return '3 Point Rectangle'; + }, + get iconComponent() { + return self.dynamic + ? NodeViews.Rect3PointRegionModel.altIcon + : NodeViews.Rect3PointRegionModel.icon; + }, + })); + +const Rect = types.compose(_Tool.name, ToolMixin, BaseTool, TwoPointsDrawingTool, _BaseNPointTool, _Tool, AnnotationMixin); + +export { Rect }; diff --git a/yarn.lock b/yarn.lock index eff97bbe9a..792479c6ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4533,6 +4533,7 @@ ansi-escapes@^3.0.0: ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== dependencies: type-fest "^0.21.3" @@ -5178,6 +5179,7 @@ camelcase@^5.3.1: camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-api@^3.0.0: version "3.0.0" @@ -5688,6 +5690,7 @@ css-loader@^6.7.3: css-minimizer-webpack-plugin@^3.0.2: version "3.4.1" resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz#ab78f781ced9181992fe7b6e4f3422e76429878f" + integrity sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q== dependencies: cssnano "^5.0.6" jest-worker "^27.0.2" @@ -9602,6 +9605,7 @@ parse5-htmlparser2-tree-adapter@^6.0.1: parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== parse5@^7.0.0, parse5@^7.1.1: version "7.1.2" @@ -10208,6 +10212,7 @@ promise-polyfill@^8.1.3: prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== dependencies: kleur "^3.0.3" sisteransi "^1.0.5" @@ -10677,6 +10682,7 @@ react-beautiful-dnd@^13.1.1: react-dom@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -10801,6 +10807,7 @@ react-window@^1.8.6: react@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -12542,6 +12549,7 @@ which-typed-array@^1.1.9: which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== dependencies: isexe "^2.0.0"