lost-sia
Version:
Single Image Annotation Tool
1,636 lines (1,541 loc) • 68 kB
JSX
import _ from "lodash";
import React, { Component } from "react";
import { uniqueId } from "lodash-es";
import Annotation from "./Annotation/Annotation";
import AnnoLabelInput from "./AnnoLabelInput";
import AnnoToolBar from "./AnnoToolBar";
import ImgBar from "./ImgBar";
import LabelInput from "./LabelInput";
import Prompt from "./Prompt";
import { Button, Dimmer, Header, Icon, Loader } from "semantic-ui-react";
import * as annoStatus from "./types/annoStatus";
import * as canvasActions from "./types/canvasActions";
import * as modes from "./types/modes";
import * as notificationType from "./types/notificationType";
import * as TOOLS from "./types/tools";
import * as annoConversion from "./utils/annoConversion";
import * as colorlut from "./utils/colorlut";
import UndoRedo from "./utils/hist";
import KeyMapper, * as keyActions from "./utils/keyActions";
import * as mouse from "./utils/mouse";
import * as transformAnnos from "./utils/transform";
import * as wv from "./utils/windowViewport";
import InfoBoxes from "./InfoBoxes/InfoBoxArea";
import "./SIA.scss";
/**
* SIA Canvas element that handles annotations within an image
*
* @param {React.Ref} container - A react ref to a div that defines the
* space where this Canvas lives in.
* @param {object} annos - A json object containing all annotation
* information for an image
* {
* image : {
* id: int,
* number: int,
* amount: int,
* isFirst: bool,
* isLast: bool,
* description: string, // -> optional
* imgActions: list of string, // -> optional
* },
* annotations: {
* bBoxes: [{
* id: int, // -> Not required if status === annoStatus.NEW
* data: {},
* labelIds: list of int, // -> optional
* status: see annoStatus, // -> optional
* annoTime: float, // -> optional
* },...],
* points: []
* lines: []
* polygons: []
* }
* }
* @param {object} annoSaveResponse - Backend response when updating an annotation in backend
* {
* tempId: int or str, // temporal frontend Id
* dbId: int, // Id from backend
* newStatus: str // new Status for the annotation
* }
* @param {object} possibleLabels - Possible labels that can be assigned to
* an annotation.
* {
* id: int,
* description: str,
* label: str, (name of the label)
* color: str (color is optional)
* }
* @param {blob} imageBlob - The actual image blob that will be displayed
* @param {object} uiConfig - User interface configs
* {
* nodesRadius: int, strokeWidth: int,
* layoutOffset: {left:int, top:int, right:int, bottom:int}, -> Offset of the canvas inside the container
* imgBarVisible: bool,
* imgLabelInputVisible: bool,
* centerCanvasInContainer: bool, -> Center the canvas in the middle of the container.
* maxCanvas: bool -> Maximize Canvas Size. Do not fit canvas to image size.
* }
* @param {int} layoutUpdate - A counter that triggers a layout update
* everytime it is incremented.
* @param {string} selectedTool - The tool that is selected to draw an
* annotation. Possible choices are: 'bBox', 'point', 'line', 'polygon'
* @param {object} canvasConfig - Configuration for this canvas
* {
* annos:{
* tools: {
* point: bool,
* line: bool,
* polygon: bool,
* bbox: bool
* },
* multilabels: bool,
* actions: {
* draw: bool,
* label: bool,
* edit: bool,
* },
* maxAnnos: null or int,
* minArea: int
* },
* img: {
* multilabels: bool,
* actions: {
* label: bool,
* }
* },
* allowedToMarkExample: bool, -> Indicates wether the current user is allowed to mark an annotation as example.
* }
* @param {str or int} defaultLabel (optional) - Name or ID of the default label that is used
* when no label was selected by the annotator. If not set "no label" will be used.
* If ID is used, it needs to be one of the possible label ids.
* @param {bool} blocked Block canvas view with loading dimmer.
* @param {bool} preventScrolling Prevent scrolling on mouseEnter
* @param {bool} lockedAnnos A list of AnnoIds of annos that should only be displayed.
* Such annos can not be edited in any way.
* @event onAnnoSaveEvent - Callback with update information for a single
* annotation or the current image that can be used for backend updates
* args: {
* action: the action that was performed in frontend,
* anno: anno information,
* img: image information
* }
* @event onNotification - Callback for Notification messages
* args: {title: str, message: str, type: str}
* @event onKeyDown - Fires for keyDown on canvas
* @event onKeyUp - Fires for keyUp on canvas
* @event onAnnoEvent - Fires when an anno performed an action
* args: {anno: annoObject, newAnnos: list of annoObjects, pAction: str}
* @event onCanvasEvent - Fires on canvas event
* args: {action: action, data: dataObject}
* action -> CANVAS_SVG_UPDATE
* data: {width: int, height: int, scale: float, translateX: float,
* translateY:float}
* action -> CANVAS_UI_CONFIG_UPDATE
* action -> CANVAS_LABEL_INPUT_CLOSE
* action -> CANVAS_IMG_LOADED
* action -> CANVAS_IMGBAR_CLOSE
* @event onImgBarClose - Fires when close button on ImgBar was hit.
* @event onGetFunction - Get special canvas functions for manipulation from outside canvas
* deleteAllAnnos()
* unloadImage()
* resetZoom()
* getAnnos(annos,removeFrontendIds)
*/
class Canvas extends Component {
constructor(props) {
super(props);
this.state = {
svg: {
width: "100%",
height: "100%",
scale: 1.0,
translateX: 0,
translateY: 0,
},
image: {
width: undefined,
height: undefined,
},
imageOffset: {
x: 0,
y: 0,
},
annos: [],
mode: modes.VIEW,
selectedAnnoId: undefined,
showSingleAnno: undefined,
showLabelInput: false,
imageLoaded: false,
imgLoadCount: 0,
imgLabelIds: [],
imgLabelChanged: false,
imgAnnoTime: 0,
imgLoadTimestamp: 0,
performedImageInit: false,
prevLabel: [],
imageBlob: undefined,
isJunk: props.isJunk,
imgBarVisible: false,
annoToolBarVisible: false,
possibleLabels: undefined,
annoCommentInputTrigger: 0,
imgActions: [],
isDrawingSamBBox: false, // Flag to indicate if a SAM bbox is currently being drawn,
samBBoxStartPoint: null,
};
this.img = React.createRef();
this.svg = React.createRef();
this.container = React.createRef();
this.hist = new UndoRedo();
this.keyMapper = new KeyMapper((keyAction) =>
this.handleKeyAction(keyAction),
);
this.mousePosAbs = undefined;
this.clipboard = undefined;
this.delayedBackendUpdates = {};
this.tempIdMap = {};
}
componentDidMount() {
this.updatePossibleLabels();
if (Number.isInteger(this.props.defaultLabel)) {
this.setState({ prevLabel: [this.props.defaultLabel] });
}
if (this.props.onGetFunction) {
this.props.onGetFunction({
deleteAllAnnos: () => this.deleteAllAnnos(),
unloadImage: () => this.unloadImage(),
resetZoom: () => this.resetZoom(),
getAnnos: (annos, removeFrontendIds) =>
this.getAnnos(annos, removeFrontendIds),
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.annoSaveResponse !== this.props.annoSaveResponse) {
this.updateAnnoBySaveResponse(this.props.annoSaveResponse);
}
if (prevProps.imageMeta !== this.props.imageMeta) {
if (this.props.imageMeta) {
this.setState({
imgLabelIds: this.props.imageMeta.labelIds,
imgAnnoTime: this.props.imageMeta.annoTime,
imgActions: this.props.imageMeta.imgActions
? this.props.imageMeta.imgActions
: [],
imgLoadTimestamp: performance.now(),
});
}
}
if (prevProps.annos !== this.props.annos) {
if (this.state.imageBlob) {
this.updateCanvasView(annoConversion.fixBackendAnnos(this.props.annos));
}
}
if (prevProps.isJunk !== this.props.isJunk) {
if (this.state.isJunk !== this.props.isJunk) {
this.setState({
isJunk: this.props.isJunk,
});
// do not save junk changes when image is currently changing (comparing junk state of previous and next image)
if (this.state.imageLoaded && !this.props.isImageChanging) {
this.handleAnnoSaveEvent(canvasActions.IMG_JUNK_UPDATE, undefined, {
isJunk: this.props.isJunk,
});
}
}
}
if (this.state.imageBlob !== this.props.imageBlob) {
this.setState({ imageBlob: this.props.imageBlob });
}
if (this.props.possibleLabels !== prevProps.possibleLabels) {
this.updatePossibleLabels();
}
if (this.state.performedImageInit) {
// Initialize canvas history
this.setState({
performedImageInit: false,
annoToolBarVisible: false,
});
if (this.props.uiConfig.imgBarVisible) {
this.setState({ imgBarVisible: true });
}
this.hist.clearHist();
this.hist.push(
{
...this.getAnnos(),
selectedAnnoId: undefined,
},
"init",
);
}
if (this.state.imageLoaded) {
// Selected annotation should be on top
this.putSelectedOnTop(prevState);
if (prevState.imgLoadCount !== this.state.imgLoadCount) {
this.updateCanvasView(annoConversion.fixBackendAnnos(this.props.annos));
if (this.props.imageMeta) {
this.setImageLabels(this.props.imageMeta.labelIds);
this.setState({
performedImageInit: true,
});
}
}
if (prevProps.layoutUpdate !== this.props.layoutUpdate) {
this.selectAnnotation(undefined);
this.updateCanvasView(
annoConversion.canvasToBackendAnnos(
this.state.annos,
this.state.svg,
false,
this.state.imageOffset,
),
);
}
}
}
onImageLoad() {
this.setState({
imageLoaded: true,
imgLoadCount: this.state.imgLoadCount + 1,
showLabelInput: false,
showSingleAnno: undefined,
selectedAnnoId: undefined,
});
this.triggerCanvasEvent(canvasActions.CANVAS_IMG_LOADED);
}
onMouseOver() {
this.svg.current.focus();
//Prevent scrolling on svg
if (this.props.preventScrolling) {
document.body.style.overflow = "hidden";
}
}
onMouseLeave() {
if (this.props.preventScrolling) {
document.body.style.overflow = "";
}
}
onWheel(e) {
// Zoom implementation. Note that svg is first scaled and
// second translated!
const up = e.deltaY < 0;
const mousePos = this.getMousePosition(e);
const zoomFactor = 1.25;
let nextScale;
if (up) {
nextScale = this.state.svg.scale * zoomFactor;
} else {
nextScale = this.state.svg.scale / zoomFactor;
}
let newTranslation;
//Constrain zoom
if (nextScale < 1.0) {
nextScale = 1.0;
newTranslation = { x: 0, y: 0 };
} else if (nextScale > 200.0) {
nextScale = 200.0;
newTranslation = wv.getZoomTranslation(
mousePos,
this.state.svg,
nextScale,
);
} else {
newTranslation = wv.getZoomTranslation(
mousePos,
this.state.svg,
nextScale,
);
}
this.setState({
svg: {
...this.state.svg,
scale: nextScale,
// translateX: -1*(mousePos.x * nextScale - mousePos.x)/nextScale,
// translateY: -1*(mousePos.y * nextScale - mousePos.y)/nextScale
translateX: newTranslation.x,
translateY: newTranslation.y,
},
});
return false;
}
onRightClick(e) {
e.preventDefault();
}
onMouseDown(e) {
if (e.button === 0) {
this.selectAnnotation(undefined);
} else if (e.button === 1) {
this.setMode(modes.CAMERA_MOVE);
} else if (e.button === 2) {
if (
this.props.selectedTool === TOOLS.POSITIVE_POINT ||
this.props.selectedTool === TOOLS.NEGATIVE_POINT ||
this.props.selectedTool === TOOLS.SAM_BBOX
) {
return; // do nothing for sam tools
}
//Create annotation on right click
this.createNewAnnotation(e);
}
}
onAnnoMouseDown(e) {
if (e.button === 1) {
// this.collectAnnos()
this.setMode(modes.CAMERA_MOVE);
} else if (e.button === 2) {
if (
this.props.selectedTool === TOOLS.POSITIVE_POINT ||
this.props.selectedTool === TOOLS.NEGATIVE_POINT ||
this.props.selectedTool === TOOLS.SAM_BBOX
) {
return; // Note: do nothing for sam tools
}
//Create annotation on right click
this.createNewAnnotation(e);
} else if (e.button === 0) {
if (this.state.showLabelInput) {
const anno = this.findAnno(this.state.selectedAnnoId);
this.updateSelectedAnno(anno, modes.VIEW);
this.showSingleAnno(undefined);
this.showLabelInput(false);
}
}
}
onMouseUp(e) {
switch (e.button) {
case 1:
this.setMode(modes.VIEW);
break;
default:
break;
}
}
updateAnnoComment(comment) {
const anno = this.findAnno(this.state.selectedAnnoId);
anno.comment = comment;
this.handleAnnoEvent(anno, canvasActions.ANNO_COMMENT_UPDATE);
}
handleKeyAction(action) {
const anno = this.findAnno(this.state.selectedAnnoId);
const camKeyStepSize = 20 * this.state.svg.scale;
console.log('handleKeyAction', action, anno)
switch (action) {
case keyActions.EDIT_LABEL:
// Need to get the newest version of annotation data directly
// from annotation object, when editing label/ hitting enter
// in create mode, since annotation data in canvas are not updated
// to this point in time.
const ar = this.findAnnoRef(this.state.selectedAnnoId);
let myAnno = undefined;
if (ar !== undefined) {
myAnno = ar.current.myAnno.current.getResult();
}
this.editAnnoLabel(myAnno);
break;
case keyActions.DELETE_ANNO:
this.deleteAnnotation(anno);
break;
case keyActions.TOGGLE_ANNO_COMMENT_INPUT:
if (this.state.selectedAnnoId) {
this.setState({
annoCommentInputTrigger: this.state.annoCommentInputTrigger + 1,
});
}
break;
case keyActions.DELETE_ANNO_IN_CREATION:
this.deleteAnnoInCreationMode(anno);
break;
case keyActions.ENTER_ANNO_ADD_MODE:
if (anno) {
this.updateSelectedAnno(anno, modes.ADD);
}
break;
case keyActions.LEAVE_ANNO_ADD_MODE:
if (anno) {
this.updateSelectedAnno(anno, modes.VIEW);
}
break;
case keyActions.UNDO:
this.undo();
break;
case keyActions.REDO:
this.redo();
break;
case keyActions.TRAVERSE_ANNOS:
this.traverseAnnos();
break;
case keyActions.CAM_MOVE_LEFT:
this.moveCamera(camKeyStepSize, 0);
break;
case keyActions.CAM_MOVE_RIGHT:
this.moveCamera(-camKeyStepSize, 0);
break;
case keyActions.CAM_MOVE_UP:
this.moveCamera(0, camKeyStepSize);
break;
case keyActions.CAM_MOVE_DOWN:
this.moveCamera(0, -camKeyStepSize);
break;
case keyActions.CAM_MOVE_STOP:
break;
case keyActions.COPY_ANNOTATION:
this.copyAnnotation();
break;
case keyActions.PASTE_ANNOTATION:
this.pasteAnnotation(0);
break;
case keyActions.RECREATE_ANNO:
// recreate selected annotation using the anno id
if (this.state.selectedAnnoId)
this.recreateAnnotation(this.state.selectedAnnoId);
break;
default:
// console.warn("Unknown key action", action);
}
}
onKeyDown(e) {
e.preventDefault();
this.keyMapper.keyDown(e.key);
if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
onKeyUp(e) {
e.preventDefault();
this.keyMapper.keyUp(e.key);
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
onMouseMove(e) {
if (this.state.mode === modes.CAMERA_MOVE) {
this.moveCamera(e.movementX, e.movementY);
}
}
onLabelInputDeleteClick(annoId) {
this.removeSelectedAnno();
}
/**
* Trigger canvas event
* @param {String} action Action that was performed
* @param {Object} data Data object of the action
*/
triggerCanvasEvent(action, data) {
if (this.props.onCanvasEvent) {
this.props.onCanvasEvent(action, data);
}
}
checkAndCorrectAnno(anno) {
// Check if annoation is within image bounds
const corrected = transformAnnos.correctAnnotation(
anno.data,
this.state.svg,
this.state.imageOffset,
);
let newAnno = { ...anno, data: corrected };
const area = transformAnnos.getArea(
corrected,
this.state.svg,
anno.type,
this.state.image,
);
if (area !== undefined) {
if (area < this.props.canvasConfig.annos.minArea) {
this.handleNotification({
title: "Annotation to small",
message:
"Annotation area was " +
Math.round(area) +
"px but needs to be bigger than " +
this.props.canvasConfig.annos.minArea +
" px",
type: notificationType.WARNING,
});
// newAnno = {...newAnno, mode: modes.DELETED}
newAnno = { ...newAnno, mode: modes.DELETED };
}
}
if (!this.checkAnnoLength(anno)) {
newAnno = { ...newAnno, mode: modes.DELETED };
}
return newAnno;
}
/**
* Handle actions that have been performed by an annotation
* @param {Number} anno Id of the annotation
* @param {String} pAction Action that was performed
*/
handleAnnoEvent(anno, pAction) {
// console.log("handleAnnoEvent", pAction, anno);
let newAnnos = undefined;
let actionHistoryStore = undefined;
console.log('canvasActions: ', anno, pAction)
switch (pAction) {
case canvasActions.ANNO_ENTER_CREATE_MODE:
break;
case canvasActions.ANNO_MARK_EXAMPLE:
newAnnos = this.updateSelectedAnno(anno, modes.VIEW);
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
this.handleAnnoSaveEvent(pAction, anno);
break;
case canvasActions.ANNO_SELECTED:
this.selectAnnotation(anno.id);
// this.pushHist(
// this.state.annos, anno.id,
// pAction, this.state.showSingleAnno
// )
break;
case canvasActions.ANNO_START_CREATING:
newAnnos = this.updateSelectedAnno(anno);
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
break;
case canvasActions.ANNO_CREATED:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
newAnnos = this.updateSelectedAnno(
{ ...anno, status: annoStatus.DATABASE },
modes.VIEW,
);
this.pushHist(newAnnos, anno.id, pAction, undefined);
this.showSingleAnno(undefined);
this.setState({ annoToolBarVisible: true });
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_MOVED:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
newAnnos = this.updateSelectedAnno(anno, modes.VIEW);
this.showSingleAnno(undefined);
this.pushHist(newAnnos, anno.id, pAction, undefined);
this.setState({ annoToolBarVisible: true });
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_ENTER_MOVE_MODE:
anno = this.startAnnotimeMeasure(anno);
this.updateSelectedAnno(anno, modes.MOVE);
this.showSingleAnno(anno.id);
this.setState({ annoToolBarVisible: false });
break;
case canvasActions.ANNO_ENTER_EDIT_MODE:
anno = this.startAnnotimeMeasure(anno);
this.updateSelectedAnno(anno, modes.EDIT);
// this.showSingleAnno(anno.id)
this.setState({ annoToolBarVisible: false });
break;
case canvasActions.ANNO_ADDED_NODE:
actionHistoryStore = [...this.state.imgActions, pAction];
newAnnos = this.updateSelectedAnno(anno, modes.ADD);
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_REMOVED_NODE:
actionHistoryStore = [...this.state.imgActions, pAction];
if (!this.checkAnnoLength(anno)) {
newAnnos = this.updateSelectedAnno(anno, modes.DELETED);
this.showSingleAnno(undefined);
} else {
newAnnos = this.updateSelectedAnno(anno, modes.CREATE);
}
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
if (anno.status !== annoStatus.NEW) {
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
}
break;
case canvasActions.ANNO_REMOVED_SPECIFIC_NODE:
actionHistoryStore = [...this.state.imgActions, pAction];
if (!this.checkAnnoLength(anno)) {
newAnnos = this.updateSelectedAnno(anno, modes.DELETED);
this.showSingleAnno(undefined);
} else {
newAnnos = this.updateSelectedAnno(anno, modes.ADD)
}
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
if (anno.status !== annoStatus.NEW) {
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
}
break;
case canvasActions.ANNO_EDITED:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
newAnnos = this.updateSelectedAnno(anno, modes.VIEW);
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
this.setState({ annoToolBarVisible: true });
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_DELETED:
actionHistoryStore = [...this.state.imgActions, pAction];
const res = this.updateSelectedAnno(anno, modes.DELETED, true);
newAnnos = res.newAnnos;
this.selectAnnotation(undefined);
this.showSingleAnno(undefined);
this.pushHist(newAnnos, undefined, pAction, this.state.showSingleAnno);
this.handleAnnoSaveEvent(pAction, res.newAnno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_COMMENT_UPDATE:
actionHistoryStore = [...this.state.imgActions, pAction];
const res_comment = this.updateSelectedAnno(anno, modes.VIEW, true);
newAnnos = res_comment.newAnnos;
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
this.handleNotification({
title: "Saved comment",
message: `Saved comment: ${anno.comment}`,
type: notificationType.SUCCESS,
});
this.handleAnnoSaveEvent(pAction, res_comment.newAnno, {
imgActions: actionHistoryStore,
});
break;
case canvasActions.ANNO_LABEL_UPDATE:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
anno = this.checkAndCorrectAnno(anno);
// console.log("ANNO_LABEL_UPDATE aftercheckAndCorrect", anno);
// this.updateSelectedAnno(anno, anno.mode)
if (anno.mode === modes.DELETED) {
this.updateSelectedAnno(anno, modes.DELETED);
} else {
this.updateSelectedAnno(
{ ...anno, status: annoStatus.DATABASE },
modes.VIEW,
);
}
// if (!this.checkAnnoLength(anno)){
// newAnnos = this.updateSelectedAnno(anno, modes.DELETED)
// } else {
// newAnnos = this.updateSelectedAnno(anno, modes.VIEW)
// }
this.setState({ annoToolBarVisible: true });
if (anno.mode !== modes.DELETED) {
this.pushHist(newAnnos, anno.id, pAction, undefined);
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
}
break;
case canvasActions.ANNO_CREATED_NODE:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
newAnnos = this.updateSelectedAnno(anno, modes.CREATE);
this.pushHist(newAnnos, anno.id, pAction, this.state.showSingleAnno);
break;
case canvasActions.ANNO_CREATED_FINAL_NODE:
actionHistoryStore = [...this.state.imgActions, pAction];
anno = this.stopAnnotimeMeasure(anno);
newAnnos = this.updateSelectedAnno(
{ ...anno, status: annoStatus.DATABASE },
modes.VIEW,
);
this.pushHist(newAnnos, anno.id, pAction, undefined);
this.showSingleAnno(undefined);
this.setState({ annoToolBarVisible: true });
this.handleAnnoSaveEvent(pAction, anno, {
imgActions: actionHistoryStore,
});
break;
default:
// console.warn("Action not handled", pAction);
break;
}
if (actionHistoryStore) {
this.setState({ imgActions: actionHistoryStore });
}
if (this.props.onAnnoEvent) {
this.props.onAnnoEvent(anno, newAnnos, pAction);
}
}
handleAnnoSaveEvent(action, anno, img) {
const imgData = {
...img,
imgId: this.props.imageMeta.id,
annoTime:
this.props.imageMeta.annoTime +
(performance.now() - this.state.imgLoadTimestamp) / 1000,
};
let backendAnno = undefined;
if (anno) {
let myAnno = this.addDelayedBackendUpdate(anno, action);
if (!myAnno) return;
if (myAnno.id in this.tempIdMap) {
myAnno = { ...myAnno, id: this.tempIdMap[myAnno.id] };
}
backendAnno = annoConversion.canvasToBackendSingleAnno(
myAnno,
this.state.svg,
false,
this.state.imageOffset,
);
}
const saveData = {
anno: backendAnno,
img: imgData,
action,
};
if (this.props.onAnnoSaveEvent) {
this.props.onAnnoSaveEvent(saveData);
}
}
onAnnoLabelInputUpdate(anno) {
this.updateSelectedAnno(anno);
}
onAnnoLabelInputClose() {
this.svg.current.focus();
this.showLabelInput(false);
this.showSingleAnno(undefined);
const anno = this.findAnno(this.state.selectedAnnoId);
this.handleAnnoEvent(anno, canvasActions.ANNO_LABEL_UPDATE);
}
handleImgBarClose() {
this.triggerCanvasEvent(canvasActions.CANVAS_IMGBAR_CLOSE);
}
gotNewLabel(label) {
let ret = false;
if (label.length === 0) {
if (this.state.imgLabelIds.length !== 0) {
return true;
} else {
return false;
}
}
label.forEach((e) => {
if (!this.state.imgLabelIds.includes(e)) ret = true;
});
return ret;
}
handleImgLabelUpdate(label) {
if (this.gotNewLabel(label)) {
const imgActions = [
...this.state.imgActions,
canvasActions.IMG_LABEL_UPDATE,
];
// console.log("gotNewLabel", label);
this.setState({
imgLabelIds: label,
imgLabelChanged: true,
imgActions: imgActions,
});
this.pushHist(
this.state.annos,
this.state.selectedAnnoId,
canvasActions.IMG_LABEL_UPDATE,
this.state.showSingleAnno,
label,
);
const imgData = {
imgLabelIds: label,
imgLabelChanged: true,
imgActions: imgActions,
};
this.handleAnnoSaveEvent(
canvasActions.IMG_LABEL_UPDATE,
undefined,
imgData,
);
}
}
handleCanvasClick(e) {
if (this.props.uiConfig.imgBarVisible) {
this.setState({ imgBarVisible: true });
}
}
handleContextMenu(e) {
e.preventDefault();
if (
this.props.selectedTool === TOOLS.POSITIVE_POINT ||
this.props.selectedTool === TOOLS.NEGATIVE_POINT
) {
const { x, y } = this.getMousePosition(e);
const imgWidth = this.state.svg.width - 2 * this.state.imageOffset.x;
const imgHeight = this.state.svg.height - 2 * this.state.imageOffset.y;
const normX = (x - this.state.imageOffset.x) / imgWidth;
const normY = (y - this.state.imageOffset.y) / imgHeight;
const type =
this.props.selectedTool === TOOLS.POSITIVE_POINT
? "positive"
: "negative";
this.props.onSamPointClick(x, y, normX, normY, type);
}
}
handleMouseDown(e) {
if (this.props.selectedTool === TOOLS.SAM_BBOX) {
if (e.button !== 2) {
return;
}
e.preventDefault();
const { x, y } = this.getMousePosition(e);
this.setState({
isDrawingSamBBox: true,
samBBoxStartPoint: { x, y },
});
// start a new rectangle
this.props.onUpdateSamBBox({
x,
y,
width: 0,
height: 0,
});
}
}
handleImgBarMouseEnter(e) {
this.setState({ imgBarVisible: false });
}
handleImgLabelInputClose() {
this.triggerCanvasEvent(canvasActions.CANVAS_LABEL_INPUT_CLOSE);
}
handleSvgMouseMove(e) {
this.mousePosAbs = mouse.getMousePositionAbs(e, this.state.svg);
if (this.props.selectedTool === TOOLS.SAM_BBOX) {
if (!this.state.isDrawingSamBBox || !this.state.samBBoxStartPoint) {
// If not drawing a SAM bbox, do nothing
return;
}
const { x, y } = this.getMousePosition(e);
const startPoint = this.state.samBBoxStartPoint;
const imgWidth = this.state.svg.width - 2 * this.state.imageOffset.x;
const imgHeight = this.state.svg.height - 2 * this.state.imageOffset.y;
const xMin = Math.min(startPoint.x, x);
const yMin = Math.min(startPoint.y, y);
const width = Math.abs(x - startPoint.x);
const height = Math.abs(y - startPoint.y);
// normalized coordinates
const xMinNorm = (xMin - this.state.imageOffset.x) / imgWidth;
const yMinNorm = (yMin - this.state.imageOffset.y) / imgHeight;
const xMaxNorm = (xMin + width - this.state.imageOffset.x) / imgWidth;
const yMaxNorm = (yMin + height - this.state.imageOffset.y) / imgHeight;
// update the rectangle with the new width and height
this.props.onUpdateSamBBox({
x: xMin,
y: yMin,
width,
height,
xMinNorm,
yMinNorm,
xMaxNorm,
yMaxNorm,
});
}
}
handleMouseUp(e) {
if (this.props.selectedTool === TOOLS.SAM_BBOX) {
if (e.button !== 2) {
return;
}
e.preventDefault();
this.setState({
isDrawingSamBBox: false,
});
}
}
handleMouseLeave(e) {
if (this.props.selectedTool === TOOLS.SAM_BBOX) {
// If mouse leaves the canvas while drawing a SAM bbox, reset the state
this.setState({
isDrawingSamBBox: false,
});
}
}
handleNotification(messageObj) {
if (this.props.onNotification) {
this.props.onNotification(messageObj);
}
}
handleHideLbl(lbl, hide) {
let hiddenSelected = false;
const newAnnos = this.state.annos.map((anno) => {
const newAnno = { ...anno };
if (anno.labelIds.includes(lbl.id)) {
newAnno.visible = !hide;
if (anno.id === this.state.selectedAnnoId) hiddenSelected = true;
} else if (anno.labelIds.length === 0) {
// no label case
if (lbl.id === -1) {
// -1 indicates no label
newAnno.visible = !hide;
if (anno.id === this.state.selectedAnnoId) hiddenSelected = true;
}
}
return newAnno;
});
this.setState({ annos: newAnnos });
if (hiddenSelected) {
this.selectAnnotation(undefined);
}
}
handleMarkExample(anno) {
const newAnno = { ...anno };
if (newAnno.isExample == undefined) {
newAnno.isExample = true;
} else if (newAnno.isExample) {
newAnno.isExample = false;
} else {
newAnno.isExample = true;
}
this.handleAnnoEvent(newAnno, canvasActions.ANNO_MARK_EXAMPLE);
}
/*************
* LOGIC *
**************/
copyAnnotation() {
this.clipboard = this.findAnno(this.state.selectedAnnoId);
this.handleNotification({
title: "Copyed annotation to clipboard",
message: "Copyed " + this.clipboard.type,
type: notificationType.SUCCESS,
});
}
pasteAnnotation(offset = 0) {
// const corrected = transform.correctAnnotation(anno.data, this.props.svg, this.props.imageOffset)
if (this.clipboard) {
// let annos = [...this.state.annos]
const uid = uniqueId("new");
// this.handleAnnoEvent()
const newData = this.clipboard.data.map((e) => {
return { x: e.x + offset, y: e.y + offset };
});
const newAnno = {
...this.clipboard,
id: uid,
annoTime: 0,
status: annoStatus.NEW,
mode: modes.VIEW,
data: transformAnnos.correctAnnotation(
newData,
this.state.svg,
this.state.imageOffset,
),
};
// annos.push(newAnno)
// this.setState({annos: annos, selectedAnnoId: uid})
this.handleNotification({
title: "Pasted annotation to canvas",
message: "Pasted and selected " + this.clipboard.type,
type: notificationType.SUCCESS,
});
this.handleAnnoEvent(newAnno, canvasActions.ANNO_CREATED);
// this.handleAnnoSaveEvent(canvasActions.ANNO_CREATED, newAnno)
}
}
checkAnnoLength(anno) {
if (anno.type === "polygon" && anno.data.length < 3) {
this.handleNotification({
title: "Invalid polygon!",
message: "A vaild polygon needs at least 3 points!",
type: notificationType.WARNING,
});
return false;
}
return true;
}
startAnnotimeMeasure(anno) {
anno.timestamp = performance.now();
return anno;
}
stopAnnotimeMeasure(anno) {
if (anno.timestamp === undefined) {
// console.warn(
// "No timestamp for annotime measurement. Check if you started measurement",
// anno,
// );
} else {
let now = performance.now();
anno.annoTime += (now - anno.timestamp) / 1000;
anno.timestamp = now;
return anno;
}
return anno;
}
updatePossibleLabels() {
if (!this.props.possibleLabels) return;
if (this.props.possibleLabels.length <= 0) return;
let lbls = this.props.possibleLabels;
lbls = lbls.map((e) => {
if (!("color" in e)) {
return {
...e,
color: colorlut.getColor(e.id),
};
} else {
return { ...e };
}
});
this.setState({
possibleLabels: [...lbls],
});
}
editAnnoLabel(anno) {
if (this.state.selectedAnnoId) {
let myAnno;
if (anno === undefined) {
myAnno = this.findAnno(this.state.selectedAnnoId);
} else {
myAnno = { ...anno };
}
myAnno = this.startAnnotimeMeasure(myAnno);
this.showLabelInput();
this.updateSelectedAnno(myAnno, modes.EDIT_LABEL);
}
}
unloadImage() {
// console.log("unloadImage", this.state, this.props.imageMeta);
if (this.state.imageLoaded) {
this.setState({ imageLoaded: false });
}
this.handleAnnoSaveEvent(
canvasActions.IMG_ANNO_TIME_UPDATE,
undefined,
undefined,
);
}
/**
* Find a annotation by id in current state
*
* @param {int} annoId - Id of the annotation to find
*/
findAnno(annoId) {
return this.state.annos.find((e) => {
return e.id === annoId;
});
}
findAnnoRef(annoId) {
if (this.state.selectedAnnoId === undefined) return undefined;
return this.annoRefs.find((e) => {
if (e.current) {
return e.current.isSelected();
} else {
return false;
}
});
}
pushHist(
annos,
selectedAnnoId,
pAction,
showSingleAnno,
imgLabelIds = this.state.imgLabelIds,
) {
this.hist.push(
{
...this.getAnnos(annos, false),
selectedAnnoId: selectedAnnoId,
showSingleAnno: showSingleAnno,
imgLabelIds: imgLabelIds,
},
pAction,
);
// console.log("hist", this.hist);
}
undo() {
this.handleNotification({
title: "Redo/ Undo not supported",
message: `Redo and Undo functions are currently not supported`,
type: notificationType.WARNING,
});
return;
//TODO: Make UNDO great again
// if (!this.hist.isEmpty()){
// const cState = this.hist.undo()
// // console.log('hist', this.hist)
// this.setCanvasState(
// cState.entry.annotations,
// cState.entry.imgLabelIds,
// cState.entry.selectedAnnoId,
// cState.entry.showSingleAnno)
// }
}
redo() {
this.handleNotification({
title: "Redo/ Undo not supported",
message: `Redo and Undo functions are currently not supported`,
type: notificationType.WARNING,
});
return;
//TODO: Make REDO great again
// if (!this.hist.isEmpty()){
// const cState = this.hist.redo()
// // console.log('hist', this.hist)
// this.setCanvasState(
// cState.entry.annotations,
// cState.entry.imgLabelIds,
// cState.entry.selectedAnnoId,
// cState.entry.showSingleAnno
// )
// }
}
deleteAnnotation(anno) {
if (anno) {
if (anno.mode === modes.CREATE) {
const ar = this.findAnnoRef(this.state.selectedAnnoId);
if (ar !== undefined) ar.current.myAnno.current.removeLastNode();
} else {
this.handleAnnoEvent(anno, canvasActions.ANNO_DELETED);
}
}
}
deleteAnnoInCreationMode(anno) {
if (anno) {
if (anno.mode === modes.CREATE) {
this.handleAnnoEvent(anno, canvasActions.ANNO_DELETED);
} else {
}
}
}
deleteAllAnnos() {
let newAnnos = [];
this.state.annos.forEach((e) => {
if (typeof e.id !== "string") {
const anno = { ...e, status: annoStatus.DELETED };
this.handleAnnoEvent(anno, canvasActions.ANNO_DELETED);
}
});
this.selectAnnotation(undefined);
this.showSingleAnno(undefined);
}
/**
* Set state of Canvas annotations and imageLabels.
*
* @param {list} annotations - Annotations in backend format
* @param {list} imgLabelIds - IDs of the image labels
* @param {object} selectedAnno - The selected annotation
* @param {int} showSingleAnno - The id of the single annotation
* that should be visible
*/
setCanvasState(annotations, imgLabelIds, selectedAnnoId, showSingleAnno) {
this.updateCanvasView({ ...annotations });
this.setImageLabels([...imgLabelIds]);
this.selectAnnotation(selectedAnnoId);
this.setState({ showSingleAnno: showSingleAnno });
}
isLocked(annoId) {
if (this.props.lockedAnnos) {
if (this.props.lockedAnnos.includes(annoId)) {
return true;
}
}
return false;
}
selectAnnotation(annoId) {
if (this.isLocked(annoId)) {
this.handleNotification({
title: "Annotation locked",
message: `Annotation with id ${annoId} is locked and can not be edited`,
type: notificationType.WARNING,
});
return;
}
if (annoId) {
const anno = this.findAnno(annoId);
this.setState({
selectedAnnoId: annoId,
});
if (anno) {
if (anno.mode !== modes.CREATE) {
this.setState({
annoToolBarVisible: true,
});
}
}
} else {
this.setState({
selectedAnnoId: undefined,
annoToolBarVisible: false,
});
if (this.state.showLabelInput) {
this.onAnnoLabelInputClose();
}
}
}
/**
* Traverse annotations by key hit
*/
traverseAnnos() {
if (this.state.annos.length > 0) {
const myAnnos = this.state.annos.filter((e) => {
return (
e.status !== annoStatus.DELETED &&
!this.isLocked(e.id) &&
!(e.visible === false)
);
});
if (myAnnos.length > 0) {
if (!this.state.selectedAnnoId) {
this.selectAnnotation(myAnnos[0].id);
} else {
let currentIdx = myAnnos.findIndex((e) => {
return e.id === this.state.selectedAnnoId;
});
if (currentIdx + 1 < myAnnos.length) {
this.selectAnnotation(myAnnos[currentIdx + 1].id);
} else {
this.selectAnnotation(myAnnos[0].id);
}
}
}
}
}
getAnnos(annos = undefined, removeFrontedIds = true) {
const myAnnos = annos ? annos : this.state.annos;
// const backendFormat = this.getAnnoBackendFormat(removeFrontedIds, myAnnos)
const backendFormat = annoConversion.canvasToBackendAnnos(
myAnnos,
this.state.svg,
removeFrontedIds,
this.state.imageOffset,
);
const finalData = {
imgId: this.props.imageMeta.id,
imgLabelIds: this.state.imgLabelIds,
imgLabelChanged: this.state.imgLabelChanged,
imgActions: this.state.imgActions,
annotations: backendFormat,
isJunk: this.state.isJunk,
annoTime:
this.props.imageMeta.annoTime +
(performance.now() - this.state.imgLoadTimestamp) / 1000,
};
return finalData;
}
/**
* Reset zoom level on Canvas
*/
resetZoom() {
this.setState({
svg: {
...this.state.svg,
translateX: 0,
translateY: 0,
scale: 1.0,
},
});
}
moveCamera(movementX, movementY) {
let trans_x = this.state.svg.translateX + movementX / this.state.svg.scale;
let trans_y = this.state.svg.translateY + movementY / this.state.svg.scale;
const vXMin = this.state.svg.width * 0.25;
const vXMax = this.state.svg.width * 0.75;
const yXMin = this.state.svg.height * 0.25;
const yXMax = this.state.svg.height * 0.75;
const vLeft = wv.getViewportCoordinates({ x: 0, y: 0 }, this.state.svg);
const vRight = wv.getViewportCoordinates(
{ x: this.state.svg.width, y: this.state.svg.height },
this.state.svg,
);
if (vLeft.vX >= vXMin) {
trans_x = this.state.svg.translateX - 5;
} else if (vRight.vX <= vXMax) {
trans_x = this.state.svg.translateX + 5;
}
if (vLeft.vY >= yXMin) {
trans_y = this.state.svg.translateY - 5;
} else if (vRight.vY <= yXMax) {
trans_y = this.state.svg.translateY + 5;
}
this.setState({
svg: {
...this.state.svg,
translateX: trans_x,
translateY: trans_y,
},
});
}
setMode(mode) {
if (this.state.mode !== mode) {
this.setState({ mode: mode });
}
}
getMousePosition(e) {
const absPos = this.getMousePositionAbs(e);
return {
x: absPos.x / this.state.svg.scale - this.state.svg.translateX,
y: absPos.y / this.state.svg.scale - this.state.svg.translateY,
};
}
getMousePositionAbs(e) {
return {
x: e.pageX - this.svg.current.getBoundingClientRect().left,
y: e.pageY - this.svg.current.getBoundingClientRect().top,
};
}
showLabelInput(visible = true) {
this.setState({
showLabelInput: visible,
});
if (visible) {
this.showSingleAnno(this.state.selectedAnnoId);
}
}
createNewAnnotation(e) {
//Do not create new Annotation if controlKey was pressed!
let allowed = false;
if (this.keyMapper.controlDown) return;
if (this.keyMapper.shiftDown) return;
if (this.props.selectedTool) {
const maxAnnos = this.props.canvasConfig.annos.maxAnnos;
if (maxAnnos) {
if (this.state.annos.length < maxAnnos) {
allowed = true;
} else {
// console.warn(
// "Maximum number of annotations reached! MaxAnnos:",
// maxAnnos,
// );
this.handleNotification({
title: "Maximum number of annotations reached!",
message: `Only ${maxAnnos} annotations per image are allowed by config`,
type: notificationType.WARNING,
});
}
} else {
allowed = true;
}
} else {
// console.warn("No annotation tool selected!");
this.handleNotification({
title: "No tool selected!",
message: "Please select an annotation tool in the toolbar.",
type: notificationType.INFO,
});
}
if (allowed) {
const mousePos = this.getMousePosition(e);
// const selAnno = this.findAnno(this.state.selectedAnnoId)
let newAnno = {
id: this.props.nextAnnoId ? this.props.nextAnnoId : uniqueId("new"),
type: this.props.selectedTool,
data: [
{
x: mousePos.x,
y: mousePos.y,
},
{
x: mousePos.x,
y: mousePos.y,
},
],
mode: modes.CREATE,
status: annoStatus.NEW,
labelIds: this.state.prevLabel,
selectedNode: 1,
annoTime: 0.0,
};
newAnno = this.startAnnotimeMeasure(newAnno);
this.setState({
annos: [...this.state.annos, newAnno],
selectedAnnoId: newAnno.id,
showSingleAnno: newAnno.id,
annoToolBarVisible: false,
});
if (
this.props.selectedTool !== TOOLS.BBOX &&
this.props.selectedTool !== TOOLS.POINT
) {
const merged = this.mergeSelectedAnno(newAnno);
this.pushHist(
merged.newAnnos,
newAnno.id,
canvasActions.ANNO_CREATED_NODE,
newAnno.id,
);
}
this.handleAnnoEvent(newAnno, canvasActions.ANNO_ENTER_CREATE_MODE);
}
}
/**
* recreate an existing annotation in case the creation process was not finished
* @param {string} id of annotation
*/
recreateAnnotation(annoID) {
// console.log("AnnoSave -> recreateAnnotation ", annoID);
let annos = this.state.annos;
// search for id of selected anno in all annos (should normally be last item in list, but to be sure)
let annoIndex;
let anno;
for (var k in annos)
if (annos[k].id == annoID) {
annoIndex = k;
anno = annos[k];
break;
}
// editing is only allowed on line and polygon
if (!["line", "polygon"].includes(anno.type)) return; // console.log(
// "Cant recreate annotation: Type " + anno.type + " is forbidden",
// );
// remove the old annotation
this.state.annos.splice(annoIndex, 1);
// create a new annotation based on the datapoints of the old annotation
let newAnno = {
id: anno.id,
type: anno.type,
data: anno.data,
mode: modes.CREATE,
status:
anno.status === "database" || anno.status === "changed"
? annoStatus.CHANGED
: annoStatus.NEW,
labelIds: anno.labelIds,
selectedNode: anno.data.length - 1,
annoTime: anno.annoTime,
};
newAnno = this.startAnnotimeMeasure(newAnno);
this.setState({
annos: [...this.state.annos, newAnno],
selectedAnnoId: newAnno.id,
showSingleAnno: newAnno.id,
annoToolBarVisible: false,
});
// console.log("Annotation recreated");
this.handleAnnoEvent(newAnno, canvasActions.ANNO_ENTER_CREATE_MODE);
}
putSelectedOnTop(prevState) {
// The selected annotation need to be rendered as last one in
// oder to be above all other annotations.
if (this.state.selectedAnnoId) {
if (prevState.selectedAnnoId !== this.state.selectedAnnoId) {
const annos = this.state.annos.filter((el) => {
return el.id !== this.state.selectedAnnoId;
});
const lastAnno = this.state.annos.find((el) => {
return el.id === this.state.selectedAnnoId;
});
annos.push(lastAnno);
this.setState({
annos: [...annos],
});
}
}
}
getLabel(lblId) {
return this.state.possibleLabels.find((e) => {
return e.id === lblId;
});
}
getAnnoColor() {
if (this.state.selectedAnnoId) {
const anno = this.findAnno(this.state.selectedAnnoId);
if (anno) {
if (anno.labelIds.length > 0) {
return this.getLabel(anno.labelIds[0]).color;
}
}
}
return colorlut.getDefaultColor();
}
updateDelayedBackendUpdates(tempId, dbId) {
// console.log(
// "updateDelayedBackendUpdates ",
// tempId,
// dbId,
// t