Skip to content

Commit

Permalink
chore: Initial refactor. #1597
Browse files Browse the repository at this point in the history
  • Loading branch information
mturoci committed Sep 26, 2022
1 parent f0acf0c commit 75e0ecf
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 163 deletions.
225 changes: 62 additions & 163 deletions ui/src/image_annotator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import * as Fluent from '@fluentui/react'
import { B, F, Id, Rec, S, U } from 'h2o-wave'
import React from 'react'
import { stylesheet } from 'typestyle'
import { AnnotatorRect, getCorner, isIntersectingRect } from './image_annotator_rect'
import { AnnotatorTags } from './text_annotator'
import { clas, cssVar, cssVarValue, px } from './theme'
import { wave } from './ui'

/** Create a rectangular annotation shape. */
interface ImageAnnotatorRect {
export interface ImageAnnotatorRect {
/** `x` coordinate of the rectangle's corner. */
x1: F
/** `y` coordinate of the rectangle's corner. */
Expand All @@ -21,6 +22,7 @@ interface ImageAnnotatorRect {
/** Create a shape to be rendered as an annotation on an image annotator. */
interface ImageAnnotatorShape {
rect?: ImageAnnotatorRect
polygon?: ImageAnnotatorRect
}

/** Create a unique tag type for use in an image annotator. */
Expand Down Expand Up @@ -63,13 +65,13 @@ export interface ImageAnnotator {
image_height?: S
}

type Position = {
export type Position = {
x: U
y: U
dragging?: B
}

type DrawnShape = ImageAnnotatorItem & {
export type DrawnShape = ImageAnnotatorItem & {
isFocused?: B
}

Expand All @@ -94,57 +96,7 @@ const
margin: 8
}
}),
ARC_RADIUS = 4,
MIN_RECT_WIDTH = 5,
MIN_RECT_HEIGHT = 5,
isIntersectingRect = (cursor_x: U, cursor_y: U, rect?: ImageAnnotatorRect) => {
if (!rect) return false
const
{ x2, x1, y2, y1 } = rect,
x_min = Math.min(x1, x2),
x_max = Math.max(x1, x2),
y_min = Math.min(y1, y2),
y_max = Math.max(y1, y2)

return cursor_x > x_min && cursor_x < x_max && cursor_y > y_min && cursor_y < y_max
},
eventToCursor = (event: React.MouseEvent, rect: DOMRect) => ({ cursor_x: event.clientX - rect.left, cursor_y: event.clientY - rect.top }),
drawCircle = (ctx: CanvasRenderingContext2D, x: U, y: U, fillColor: S) => {
ctx.beginPath()
ctx.fillStyle = fillColor
const path = new Path2D()
path.arc(x, y, ARC_RADIUS, 0, 2 * Math.PI)
ctx.fill(path)
ctx.closePath()
},
drawRect = (ctx: CanvasRenderingContext2D, { x1, x2, y1, y2 }: ImageAnnotatorRect, strokeColor: S, isFocused = false) => {
ctx.beginPath()
ctx.lineWidth = 3
ctx.strokeStyle = strokeColor
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1)
ctx.closePath()
if (isFocused) {
ctx.beginPath()
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(x1, y1, x2 - x1, y2 - y1)
ctx.closePath()
drawCircle(ctx, x1, y1, strokeColor)
drawCircle(ctx, x2, y1, strokeColor)
drawCircle(ctx, x2, y2, strokeColor)
drawCircle(ctx, x1, y2, strokeColor)
}
},
getCorner = (x: U, y: U, { x1, y1, x2, y2 }: ImageAnnotatorRect, ignoreMaxMin = false) => {
const
x_min = ignoreMaxMin ? x1 : Math.min(x1, x2),
x_max = ignoreMaxMin ? x2 : Math.max(x1, x2),
y_min = ignoreMaxMin ? y1 : Math.min(y1, y2),
y_max = ignoreMaxMin ? y2 : Math.max(y1, y2)
if (x > x_min - ARC_RADIUS && x < x_min + ARC_RADIUS && y > y_min - ARC_RADIUS && y < y_min + ARC_RADIUS) return 'topLeft'
else if (x > x_min - ARC_RADIUS && x < x_min + ARC_RADIUS && y > y_max - ARC_RADIUS && y < y_max + ARC_RADIUS) return 'topRight'
else if (x > x_max - ARC_RADIUS && x < x_max + ARC_RADIUS && y > y_min - ARC_RADIUS && y < y_min + ARC_RADIUS) return 'bottomLeft'
else if (x > x_max - ARC_RADIUS && x < x_max + ARC_RADIUS && y > y_max - ARC_RADIUS && y < y_max + ARC_RADIUS) return 'bottomRight'
},
getCornerCursor = (shape: ImageAnnotatorRect, cursor_x: U, cursor_y: U) => {
const corner = getCorner(cursor_x, cursor_y, shape)
if (corner === 'topLeft' || corner === 'bottomRight') return 'nwse-resize'
Expand All @@ -156,27 +108,20 @@ const
else if (focused?.shape.rect) return getCornerCursor(focused.shape.rect, cursor_x, cursor_y) || 'crosshair'
else if (intersected) return 'pointer'
return 'crosshair'
},
createRect = (x1: F, x2: F, y1: F, y2: F, { width, height }: HTMLCanvasElement) =>
Math.abs(x2 - x1) <= MIN_RECT_WIDTH || Math.abs(y2 - y1) <= MIN_RECT_HEIGHT ? undefined : {
x1: x1 > width ? width : x1 < 0 ? 0 : x1,
x2: x2 > width ? width : x2 < 0 ? 0 : x2,
y1: y1 > height ? height : y1 < 0 ? 0 : y1,
y2: y2 > height ? height : y2 < 0 ? 0 : y2,
}
}

export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
const
colorsMap = React.useMemo(() => new Map<S, S>(model.tags.map(tag => [tag.name, cssVarValue(tag.color)])), [model.tags]),
[activeTag, setActiveTag] = React.useState<S>(model.tags[0]?.name || ''),
[activeShape, setActiveShape] = React.useState<keyof ImageAnnotatorShape>('rect'),
[drawnShapes, setDrawnShapes] = React.useState<DrawnShape[]>([]),
imgRef = React.useRef<HTMLCanvasElement>(null),
canvasRef = React.useRef<HTMLCanvasElement>(null),
rectRef = React.useRef<AnnotatorRect | null>(null),
[aspectRatio, setAspectRatio] = React.useState(1),
startPosition = React.useRef<Position | undefined>(undefined),
ctxRef = React.useRef<CanvasRenderingContext2D | undefined | null>(undefined),
resizedCornerRef = React.useRef<S | undefined>(undefined),
movedShapeRef = React.useRef<DrawnShape | undefined>(undefined),
activateTag = React.useCallback((tagName: S) => () => setActiveTag(tagName), [setActiveTag]),
redrawExistingShapes = React.useCallback(() => {
const canvas = canvasRef.current
Expand All @@ -186,7 +131,7 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
setDrawnShapes(shapes => {
shapes.forEach(item => {
if (item.shape.rect) drawRect(ctx, item.shape.rect, colorsMap.get(item.tag) || cssVarValue('$red'), item.isFocused)
if (item.shape.rect) rectRef.current?.drawRect(item.shape.rect, colorsMap.get(item.tag) || cssVarValue('$red'), item.isFocused)
})
return shapes
})
Expand All @@ -200,90 +145,29 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
{ cursor_x, cursor_y } = eventToCursor(e, canvas.getBoundingClientRect()),
focused = drawnShapes.find(({ isFocused }) => isFocused)

if (focused?.shape.rect) resizedCornerRef.current = getCorner(cursor_x, cursor_y, focused.shape.rect, true)
if (focused?.shape.rect) rectRef.current?.onMouseDown(cursor_x, cursor_y, focused.shape.rect)
startPosition.current = { x: cursor_x, y: cursor_y }
},
onMouseMove = (e: React.MouseEvent) => {
const
canvas = canvasRef.current,
ctx = ctxRef.current
if (!canvas || !ctx) return
const canvas = canvasRef.current
if (!canvas) return

const
{ cursor_x, cursor_y } = eventToCursor(e, canvas.getBoundingClientRect()),
focused = drawnShapes.find(({ isFocused }) => isFocused),
clickStartPosition = startPosition.current

if (clickStartPosition) {
const
intersected = drawnShapes.find(shape => isIntersectingRect(cursor_x, cursor_y, shape.shape.rect)),
x1 = clickStartPosition.x,
y1 = clickStartPosition.y

const intersected = drawnShapes.find(shape => isIntersectingRect(cursor_x, cursor_y, shape.shape.rect))
let newShape = null
clickStartPosition.dragging = true
if (focused?.shape.rect && resizedCornerRef.current) {
if (resizedCornerRef.current === 'topLeft') {
focused.shape.rect.x1 += cursor_x - x1
focused.shape.rect.y1 += cursor_y - y1
}
else if (resizedCornerRef.current === 'topRight') {
focused.shape.rect.x1 += cursor_x - x1
focused.shape.rect.y2 += cursor_y - y1
}
else if (resizedCornerRef.current === 'bottomLeft') {
focused.shape.rect.x2 += cursor_x - x1
focused.shape.rect.y1 += cursor_y - y1
}
else if (resizedCornerRef.current === 'bottomRight') {
focused.shape.rect.x2 += cursor_x - x1
focused.shape.rect.y2 += cursor_y - y1
}
startPosition.current = { x: cursor_x, y: cursor_y }
redrawExistingShapes()
}
else if (movedShapeRef.current || intersected?.isFocused) {
movedShapeRef.current = movedShapeRef.current || intersected
canvas.style.cursor = 'move'
if (!movedShapeRef.current?.shape.rect) return

const
rect = movedShapeRef.current.shape.rect,
xIncrement = cursor_x - x1,
yIncrement = cursor_y - y1,
newX1 = rect.x1 + xIncrement,
newX2 = rect.x2 + xIncrement,
newY1 = rect.y1 + yIncrement,
newY2 = rect.y2 + yIncrement,
{ width, height } = canvas
if (activeShape === 'rect') newShape = rectRef.current?.onMouseMove(clickStartPosition, cursor_x, cursor_y, focused, intersected)

// Prevent moving behind image boundaries.
// FIXME: Hitting boundary repeatedly causes rect to increase in size.
if (newX1 < rect.x1 && newX1 < 0) rect.x1 = Math.max(0, newX1)
else if (newX2 < rect.x2 && newX2 < 0) rect.x2 = Math.max(0, newX2)
else if (newY1 < rect.y1 && newY1 < 0) rect.y1 = Math.max(0, newY1)
else if (newY2 < rect.y2 && newY2 < 0) rect.y2 = Math.max(0, newY2)
else if (newX1 > rect.x1 && newX1 > width) rect.x1 = Math.min(newX1, width)
else if (newX2 > rect.x2 && newX2 > width) rect.x2 = Math.min(newX2, width)
else if (newY1 > rect.y1 && newY1 > height) rect.y1 = Math.min(newY1, height)
else if (newY2 > rect.y2 && newY2 > height) rect.y2 = Math.min(newY2, height)
else {
rect.x1 = newX1
rect.x2 = newX2
rect.y1 = newY1
rect.y2 = newY2
}
if (newShape) setDrawnShapes(shapes => shapes.map(shape => ({ ...shape, isFocused: false })))

startPosition.current = { x: cursor_x, y: cursor_y }
redrawExistingShapes()
}
else {
setDrawnShapes(shapes => shapes.map(shape => ({ ...shape, isFocused: false })))
redrawExistingShapes()
const newRect = createRect(x1, cursor_x, y1, cursor_y, canvas)
if (newRect) {
drawRect(ctx, newRect, colorsMap.get(activeTag) || cssVarValue('$red'))
}
}
redrawExistingShapes()
if (newShape?.rect) rectRef.current?.drawRect(newShape.rect, colorsMap.get(activeTag) || cssVarValue('$red'))
}
else {
canvas.style.cursor = getCorrectCursor(drawnShapes, cursor_x, cursor_y, focused)
Expand All @@ -299,20 +183,11 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
{ cursor_x, cursor_y } = eventToCursor(e, rect),
newShapes = [...drawnShapes]

if (start && !resizedCornerRef.current) {
const newRect = createRect(start.x, cursor_x, start.y, cursor_y, canvas)
if (newRect) newShapes.unshift({ shape: { rect: newRect }, tag: activeTag })
}

if (!resizedCornerRef.current && !start?.dragging && e.type !== 'mouseleave') {
newShapes.forEach(shape => shape.isFocused = false)
const intersecting = drawnShapes.find(shape => isIntersectingRect(cursor_x, cursor_y, shape.shape.rect))
if (intersecting) intersecting.isFocused = true
if (activeShape === 'rect') {
rectRef.current?.onClick(e, cursor_x, cursor_y, newShapes, drawnShapes, activeTag, start)
startPosition.current = undefined
}

startPosition.current = undefined
movedShapeRef.current = undefined
resizedCornerRef.current = ''
canvas.style.cursor = getCorrectCursor(newShapes, cursor_x, cursor_y, newShapes.find(({ isFocused }) => isFocused))
setDrawnShapes(newShapes)
redrawExistingShapes()
Expand All @@ -321,7 +196,8 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
if (!item) return
setDrawnShapes(shapes => item.key === 'remove-selected' ? shapes.filter(s => !s.isFocused) : [])
redrawExistingShapes()
}
},
chooseShape = (_e?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>, i?: Fluent.IContextualMenuItem) => setActiveShape(i?.key as keyof ImageAnnotatorShape)

React.useEffect(() => {
const img = new Image()
Expand All @@ -345,6 +221,8 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
canvas.style.zIndex = '1'
canvas.parentElement!.style.height = px(height)

rectRef.current = new AnnotatorRect(canvas)

ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, img.width * aspectRatio, img.height * aspectRatio)
setDrawnShapes((model.items || []).map(({ tag, shape }) => {
if (shape.rect) return {
Expand All @@ -362,6 +240,7 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
}))
redrawExistingShapes()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model.name, model.image, model.image_height, redrawExistingShapes, model.items])

React.useEffect(() => {
Expand All @@ -385,22 +264,42 @@ export const XImageAnnotator = ({ model }: { model: ImageAnnotator }) => {
<div data-test={model.name}>
<div className={clas('wave-s16 wave-w6', css.title)}>{model.title}</div>
<AnnotatorTags tags={model.tags} activateTag={activateTag} activeTag={activeTag} />
<Fluent.CommandBar styles={{ root: { padding: 0 } }} items={[
{
key: 'remove-selected',
text: 'Remove selection',
onClick: remove,
disabled: drawnShapes.every(s => !s.isFocused),
iconProps: { iconName: 'RemoveContent', styles: { root: { fontSize: 20 } } },
},
{
key: 'remove-all',
text: 'Remove all',
onClick: remove,
disabled: drawnShapes.length === 0,
iconProps: { iconName: 'DependencyRemove', styles: { root: { fontSize: 20 } } },
},
]} />
<Fluent.CommandBar
styles={{ root: { padding: 0 } }}
items={[
{
key: 'remove-selected',
text: 'Remove selection',
onClick: remove,
disabled: drawnShapes.every(s => !s.isFocused),
iconProps: { iconName: 'RemoveContent', styles: { root: { fontSize: 20 } } },
},
{
key: 'remove-all',
text: 'Remove all',
onClick: remove,
disabled: drawnShapes.length === 0,
iconProps: { iconName: 'DependencyRemove', styles: { root: { fontSize: 20 } } },
},
]}
farItems={[
{
key: 'rect',
text: 'Rectangle',
onClick: chooseShape,
canCheck: true,
checked: activeShape === 'rect',
iconProps: { iconName: 'RectangleShape', styles: { root: { fontSize: 20 } } },
},
{
key: 'polygon',
text: 'Polygon',
onClick: chooseShape,
canCheck: true,
checked: activeShape === 'polygon',
iconProps: { iconName: 'SixPointStar', styles: { root: { fontSize: 20 } } },
},
]} />
<div className={css.canvasContainer}>
<canvas ref={imgRef} className={css.canvas} />
<canvas ref={canvasRef} className={css.canvas} onMouseMove={onMouseMove} onMouseDown={onMouseDown} onMouseLeave={onClick} onClick={onClick} />
Expand Down
4 changes: 4 additions & 0 deletions ui/src/image_annotator_polygon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const
handlePolygonOnClick = () => {

}
Loading

0 comments on commit 75e0ecf

Please sign in to comment.