You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

347 lines
10 KiB

  1. <template>
  2. <v-stage
  3. ref="stageRef"
  4. :config="{ ...configStage, dragBoundFunc }"
  5. @mousedown="onMouseDown"
  6. @mouseup="onMouseUp"
  7. @mousemove="onMouseMove"
  8. >
  9. <v-layer>
  10. <base-image :image-url="imageUrl" @loaded="imageLoaded" />
  11. </v-layer>
  12. <v-layer>
  13. <v-rectangle
  14. v-for="rect in annotationsToDraw"
  15. :key="rect.id"
  16. :rect="rect"
  17. :color="rect.getColor(labels)"
  18. :highlight-id="highlightId"
  19. :max-width="imageSize.width"
  20. :max-height="imageSize.height"
  21. :scale="scale"
  22. @dragend="onDragEnd"
  23. @transformend="handleTransformEnd"
  24. />
  25. <v-transformer
  26. ref="transformerRef"
  27. :config="{
  28. rotateEnabled: false,
  29. flipEnabled: false,
  30. keepRatio: false,
  31. boundBoxFunc
  32. }"
  33. />
  34. </v-layer>
  35. </v-stage>
  36. </template>
  37. <script lang="ts">
  38. import Konva from 'konva'
  39. import { Box } from 'konva/lib/shapes/Transformer.d'
  40. import type { PropType } from 'vue'
  41. import Vue from 'vue'
  42. import VRectangle from './VRectangle.vue'
  43. import BaseImage from '@/components/tasks/image/BaseImage.vue'
  44. import Rectangle from '@/domain/models/tasks/boundingbox/Rectangle'
  45. import RectangleProps from '@/domain/models/tasks/boundingbox/RectangleProps'
  46. import LabelProps from '@/domain/models/tasks/shared/LabelProps'
  47. import { inverseTransform, transform } from '@/domain/models/tasks/shared/Scaler'
  48. export default Vue.extend({
  49. name: 'VBoundingBox',
  50. components: {
  51. BaseImage,
  52. VRectangle
  53. },
  54. props: {
  55. imageUrl: {
  56. type: String,
  57. required: true
  58. },
  59. labels: {
  60. type: Array as PropType<LabelProps[]>,
  61. required: true
  62. },
  63. rectangles: {
  64. type: Array as PropType<RectangleProps[]>,
  65. required: true
  66. },
  67. selectedLabel: {
  68. type: Object as PropType<LabelProps | undefined>,
  69. default: undefined
  70. },
  71. scale: {
  72. type: Number,
  73. required: true
  74. },
  75. highlightId: {
  76. type: String,
  77. required: false,
  78. default: 'uuid'
  79. }
  80. },
  81. data() {
  82. return {
  83. selectedRectangle: null as string | null,
  84. newRectangle: null as Rectangle | null,
  85. imageSize: {
  86. width: 0,
  87. height: 0
  88. },
  89. configStage: {
  90. width: window.innerWidth,
  91. height: window.innerHeight,
  92. draggable: true
  93. },
  94. stage: {} as Konva.Stage
  95. }
  96. },
  97. computed: {
  98. annotations(): Rectangle[] {
  99. return this.rectangles.map((r) => new Rectangle(r.label, r.x, r.y, r.width, r.height, r.id))
  100. },
  101. transformer(): Konva.Transformer {
  102. return (this.$refs.transformerRef as unknown as Konva.TransformerConfig).getNode()
  103. },
  104. annotationsToDraw(): Rectangle[] {
  105. if (this.newRectangle) {
  106. return this.annotations.concat(this.newRectangle)
  107. }
  108. return this.annotations
  109. }
  110. },
  111. watch: {
  112. scale() {
  113. this.setZoom()
  114. },
  115. imageUrl() {
  116. this.selectedRectangle = null
  117. this.updateTransformer()
  118. }
  119. },
  120. mounted() {
  121. document.addEventListener('keydown', this.removeRectangle)
  122. window.addEventListener('resize', this.setZoom)
  123. this.stage = (this.$refs.stageRef as unknown as Konva.StageConfig).getNode()
  124. },
  125. beforeDestroy() {
  126. document.removeEventListener('keydown', this.removeRectangle)
  127. window.removeEventListener('resize', this.setZoom)
  128. },
  129. methods: {
  130. updateTransformer() {
  131. // here we need to manually attach or detach Transformer node
  132. const selectedNode = this.stage.findOne(`#${this.selectedRectangle}`)
  133. // do nothing if selected node is already attached
  134. if (selectedNode === this.transformer.getNode()) {
  135. return
  136. }
  137. if (selectedNode) {
  138. // attach to another node
  139. this.transformer.nodes([selectedNode])
  140. } else {
  141. // remove transformer
  142. this.transformer.nodes([])
  143. }
  144. this.$emit('select-rectangle', this.selectedRectangle)
  145. },
  146. boundBoxFunc(_: Box, newBoundBox: Box) {
  147. const box = { ...newBoundBox }
  148. const { x: stageX = 0, y: stageY = 0 } = this.stage.attrs
  149. box.x = transform(box.x, stageX, this.scale)
  150. box.y = transform(box.y, stageY, this.scale)
  151. box.width = transform(box.width, 0, this.scale)
  152. box.height = transform(box.height, 0, this.scale)
  153. if (box.x < 0) {
  154. box.width += box.x
  155. box.x = 0
  156. }
  157. if (box.y < 0) {
  158. box.height += box.y
  159. box.y = 0
  160. }
  161. if (box.x + box.width > this.imageSize.width) box.width = this.imageSize.width - box.x
  162. if (box.y + box.height > this.imageSize.height) box.height = this.imageSize.height - box.y
  163. box.x = inverseTransform(box.x, stageX, this.scale)
  164. box.y = inverseTransform(box.y, stageY, this.scale)
  165. box.width = inverseTransform(box.width, 0, this.scale)
  166. box.height = inverseTransform(box.height, 0, this.scale)
  167. return box
  168. },
  169. onMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
  170. if (e.target instanceof Konva.Image) {
  171. // while new polygon is creating, prevent to select polygon.
  172. const clickedOutsideOfRectangle = !!this.selectedRectangle
  173. this.selectedRectangle = null
  174. this.updateTransformer()
  175. if (clickedOutsideOfRectangle) {
  176. return
  177. }
  178. }
  179. // prevent multiple event.
  180. if (e.target instanceof HTMLCanvasElement) {
  181. return
  182. }
  183. // clicked on transformer - do nothing
  184. const clickedOnTransformer = e.target.getParent().className === 'Transformer'
  185. if (clickedOnTransformer) {
  186. return
  187. }
  188. // prevent to create circle on Polygon.
  189. if (e.target instanceof Konva.Rect) {
  190. const rectId = e.target.id()
  191. const rect = this.annotations.find((r) => r.id === rectId)
  192. if (rect && !this.selectedRectangle) {
  193. this.selectedRectangle = rectId
  194. } else {
  195. this.selectedRectangle = null
  196. }
  197. this.updateTransformer()
  198. return
  199. }
  200. if (!this.newRectangle && !!this.selectedLabel) {
  201. this.configStage.draggable = false
  202. const pos = this.stage.getPointerPosition()!
  203. const { x: stageX = 0, y: stageY = 0 } = this.stage.attrs
  204. pos.x = transform(pos.x, stageX, this.scale)
  205. pos.y = transform(pos.y, stageY, this.scale)
  206. this.newRectangle = new Rectangle(this.selectedLabel.id, pos.x, pos.y, 0, 0)
  207. }
  208. },
  209. onMouseUp() {
  210. if (this.newRectangle && this.newRectangle.exists()) {
  211. const pos = this.stage.getPointerPosition()!
  212. const { x: stageX = 0, y: stageY = 0 } = this.stage.attrs
  213. pos.x = transform(pos.x, stageX, this.scale)
  214. pos.y = transform(pos.y, stageY, this.scale)
  215. let x = this.newRectangle.x
  216. let y = this.newRectangle.y
  217. let width = pos.x - x
  218. let height = pos.y - y
  219. if (width < 0) {
  220. x += width
  221. width = -width
  222. }
  223. if (height < 0) {
  224. y += height
  225. height = -height
  226. }
  227. const annotationToAdd = this.newRectangle.transform(x, y, width, height)
  228. this.newRectangle = null
  229. this.configStage.draggable = true
  230. this.$emit('add-rectangle', annotationToAdd.toProps())
  231. }
  232. },
  233. onMouseMove() {
  234. if (this.newRectangle) {
  235. const sx = this.newRectangle.x
  236. const sy = this.newRectangle.y
  237. const pos = this.stage.getPointerPosition()!
  238. const { x: stageX = 0, y: stageY = 0 } = this.stage.attrs
  239. pos.x = transform(pos.x, stageX, this.scale)
  240. pos.y = transform(pos.y, stageY, this.scale)
  241. this.newRectangle = this.newRectangle.transform(sx, sy, pos.x - sx, pos.y - sy)
  242. }
  243. },
  244. handleTransformEnd(e: Konva.KonvaEventObject<MouseEvent>) {
  245. // shape is transformed, let us save new attrs back to the node
  246. // find element in our state
  247. const rect = this.annotations.find((r) => r.id === this.selectedRectangle)
  248. // update the state
  249. if (rect) {
  250. const x = e.target.x()
  251. const y = e.target.y()
  252. const width = rect.width * e.target.scaleX()
  253. const height = rect.height * e.target.scaleY()
  254. const newRect = rect.transform(x, y, width, height)
  255. e.target.scaleX(1)
  256. e.target.scaleY(1)
  257. this.$emit('update-rectangle', newRect.toProps())
  258. }
  259. },
  260. onDragEnd(rect: Rectangle) {
  261. this.$emit('update-rectangle', rect.toProps())
  262. },
  263. removeRectangle(e: KeyboardEvent) {
  264. if (e.key === 'Backspace' || e.key === 'Delete') {
  265. if (this.selectedRectangle !== null) {
  266. this.$emit('delete-rectangle', this.selectedRectangle)
  267. this.selectedRectangle = null
  268. this.updateTransformer()
  269. }
  270. }
  271. },
  272. dragBoundFunc(pos: { x: number; y: number }) {
  273. const { stageX = 0, stageY = 0 } = this.stage.attrs
  274. let x = pos.x - stageX
  275. let y = pos.y - stageY
  276. const paddingX = this.imageSize.width * this.scale - this.configStage.width
  277. const paddingY = this.imageSize.height * this.scale - this.configStage.height
  278. if (paddingX + x < 0) x = -paddingX
  279. if (paddingY + y < 0) y = -paddingY
  280. if (this.configStage.width + paddingX + x > this.imageSize.width * this.scale) x = 0
  281. if (this.configStage.height + paddingY + y > this.imageSize.height * this.scale) y = 0
  282. x += stageX
  283. y += stageY
  284. return { x, y }
  285. },
  286. imageLoaded(width: number, height: number) {
  287. const maxScale = this.$el.clientWidth / width
  288. const imageIsSmallerThanContainer = maxScale > 1
  289. this.imageSize.width = width
  290. this.imageSize.height = height
  291. if (imageIsSmallerThanContainer) {
  292. this.configStage.width = width
  293. this.configStage.height = height
  294. this.stage.scale({ x: 1, y: 1 })
  295. this.$emit('update-scale', 1)
  296. } else {
  297. this.configStage.width = width * maxScale
  298. this.configStage.height = height * maxScale
  299. this.stage.scale({ x: maxScale, y: maxScale })
  300. this.$emit('update-scale', maxScale)
  301. }
  302. this.stage.draw()
  303. },
  304. setZoom() {
  305. if (this.scale < 0) {
  306. return
  307. }
  308. const maxScale = this.$el.clientWidth / this.imageSize.width
  309. this.stage.scale({ x: this.scale, y: this.scale })
  310. if (this.scale <= maxScale) {
  311. this.configStage.width = this.imageSize.width * this.scale
  312. } else {
  313. this.configStage.width = this.imageSize.width * maxScale
  314. }
  315. this.configStage.height = this.imageSize.height * this.scale
  316. this.$el.setAttribute('style', `min-height: ${this.configStage.height}px`)
  317. }
  318. }
  319. })
  320. </script>