未验证 提交 75242024 编写于 作者: B Boris Sekachev 提交者: GitHub

Added hotkeys to change labels (#3070)

* temp commit

* Added ability to change default label and object label by using Ctrl+{number}

* Removed extra changes

* Minor refactoring

* Added ability to change assigned keys

* Redesigned popover

* Added changelog record & updated version

* Added memoization

* Some minor changes

* Applied comments
Co-authored-by: NDmitry Kalinin <dmitry.kalinin@intel.com>
上级 d2a1d12f
......@@ -8,23 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.4.0] - Unreleased
### Added
- Documentation on mask annotation (<https://github.com/openvinotoolkit/cvat/pull/3044>)
- Hotkeys to switch a label of existing object or to change default label (for objects created with N) (<https://github.com/openvinotoolkit/cvat/pull/3070>)
### Changed
-
### Deprecated
-
### Removed
-
### Fixed
- Export of instance masks with holes (<https://github.com/openvinotoolkit/cvat/pull/3044>)
### Security
-
-
## [1.3.0] - 3/31/2021
......
{
"name": "cvat-ui",
"version": "1.18.1",
"version": "1.19.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
{
"name": "cvat-ui",
"version": "1.18.1",
"version": "1.19.0",
"description": "CVAT single-page application",
"main": "src/index.tsx",
"scripts": {
......
......@@ -1130,36 +1130,16 @@ export function saveAnnotationsAsync(sessionInstance: any, afterSave?: () => voi
}
// used to reproduce the latest drawing (in case of tags just creating) by using N
export function rememberObject(
objectType: ObjectType,
labelID: number,
shapeType?: ShapeType,
points?: number,
rectDrawingMethod?: RectDrawingMethod,
): AnyAction {
let activeControl = ActiveControl.CURSOR;
if (shapeType === ShapeType.RECTANGLE) {
activeControl = ActiveControl.DRAW_RECTANGLE;
} else if (shapeType === ShapeType.POLYGON) {
activeControl = ActiveControl.DRAW_POLYGON;
} else if (shapeType === ShapeType.POLYLINE) {
activeControl = ActiveControl.DRAW_POLYLINE;
} else if (shapeType === ShapeType.POINTS) {
activeControl = ActiveControl.DRAW_POINTS;
} else if (shapeType === ShapeType.CUBOID) {
activeControl = ActiveControl.DRAW_CUBOID;
}
export function rememberObject(createParams: {
activeObjectType?: ObjectType;
activeLabelID?: number;
activeShapeType?: ShapeType;
activeNumOfPoints?: number;
activeRectDrawingMethod?: RectDrawingMethod;
}): AnyAction {
return {
type: AnnotationActionTypes.REMEMBER_CREATED_OBJECT,
payload: {
shapeType,
labelID,
objectType,
points,
activeControl,
rectDrawingMethod,
},
payload: createParams,
};
}
......
......@@ -4,7 +4,6 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { CombinedState } from 'reducers/interfaces';
import {
LeftOutlined, RightOutlined, EyeInvisibleFilled, EyeOutlined,
} from '@ant-design/icons';
......@@ -14,6 +13,7 @@ import { Row, Col } from 'antd/lib/grid';
import { changeFrameAsync } from 'actions/annotation-actions';
import { reviewActions } from 'actions/review-actions';
import CVATTooltip from 'components/common/cvat-tooltip';
import { CombinedState } from 'reducers/interfaces';
export default function LabelsListComponent(): JSX.Element {
const dispatch = useDispatch();
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -10,22 +10,30 @@ import {
LockFilled, UnlockOutlined, EyeInvisibleFilled, EyeOutlined,
} from '@ant-design/icons';
import CVATTooltip from 'components/common/cvat-tooltip';
import LabelKeySelectorPopover from './label-key-selector-popover';
interface Props {
labelName: string;
labelColor: string;
labelID: number;
visible: boolean;
statesHidden: boolean;
statesLocked: boolean;
keyToLabelMapping: Record<string, number>;
hideStates(): void;
showStates(): void;
lockStates(): void;
unlockStates(): void;
updateLabelShortcutKey(updatedKey: string, labelID: number): void;
}
function LabelItemComponent(props: Props): JSX.Element {
const {
labelName,
labelColor,
labelID,
keyToLabelMapping,
visible,
statesHidden,
statesLocked,
......@@ -33,8 +41,14 @@ function LabelItemComponent(props: Props): JSX.Element {
showStates,
lockStates,
unlockStates,
updateLabelShortcutKey,
} = props;
// create reversed mapping just to receive key easily
const labelToKeyMapping: Record<string, string> = Object.fromEntries(
Object.entries(keyToLabelMapping).map(([key, _labelID]) => [_labelID, key]),
);
const labelShortcutKey = labelToKeyMapping[labelID] || '?';
const classes = {
lock: {
enabled: { className: 'cvat-label-item-button-lock cvat-label-item-button-lock-enabled' },
......@@ -48,22 +62,37 @@ function LabelItemComponent(props: Props): JSX.Element {
return (
<Row
align='middle'
align='stretch'
justify='space-around'
className='cvat-objects-sidebar-label-item'
style={{ display: visible ? 'flex' : 'none' }}
className={[
'cvat-objects-sidebar-label-item',
visible ? '' : 'cvat-objects-sidebar-label-item-disabled',
].join(' ')}
>
<Col span={4}>
<Button style={{ background: labelColor }} className='cvat-label-item-color-button'>
<Col span={2}>
<div style={{ background: labelColor }} className='cvat-label-item-color'>
{' '}
</Button>
</div>
</Col>
<Col span={14}>
<Text strong className='cvat-text'>
{labelName}
</Text>
<Col span={12}>
<CVATTooltip title={labelName}>
<Text strong className='cvat-text'>
{labelName}
</Text>
</CVATTooltip>
</Col>
<Col span={3}>
<LabelKeySelectorPopover
keyToLabelMapping={keyToLabelMapping}
labelID={labelID}
updateLabelShortcutKey={updateLabelShortcutKey}
>
<Button className='cvat-label-item-setup-shortcut-button' size='small' ghost type='dashed'>
{labelShortcutKey}
</Button>
</LabelKeySelectorPopover>
</Col>
<Col span={2} offset={1}>
{statesLocked ? (
<LockFilled {...classes.lock.enabled} onClick={unlockStates} />
) : (
......
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useSelector } from 'react-redux';
import Popover from 'antd/lib/popover';
import Button from 'antd/lib/button';
import { Row, Col } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import { CombinedState } from 'reducers/interfaces';
import CVATTooltip from 'components/common/cvat-tooltip';
interface LabelKeySelectorPopoverProps {
updateLabelShortcutKey(updatedKey: string, labelID: number): void;
keyToLabelMapping: Record<string, number>;
labelID: number;
children: JSX.Element;
}
interface LabelKeySelectorPopoverContentProps {
updateLabelShortcutKey(updatedKey: string, labelID: number): void;
labelID: number;
keyToLabelMapping: Record<string, number>;
}
function PopoverContent(props: LabelKeySelectorPopoverContentProps): JSX.Element {
const { keyToLabelMapping, labelID, updateLabelShortcutKey } = props;
const labels = useSelector((state: CombinedState) => state.annotation.job.labels);
return (
<div className='cvat-label-item-setup-shortcut-popover'>
{[['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['0']].map((arr, i_) => (
<Row justify='space-around' gutter={[16, 16]} key={i_}>
{arr.map((i) => {
const previousLabelID = keyToLabelMapping[i];
const labelName = Number.isInteger(previousLabelID) ?
labels.filter((label: any): boolean => label.id === previousLabelID)[0]?.name ||
'undefined' :
'None';
return (
<Col key={i} span={8}>
<CVATTooltip title={labelName}>
<Button onClick={() => updateLabelShortcutKey(i, labelID)}>
<Text>{`${i}:`}</Text>
<Text type='secondary'>{labelName}</Text>
</Button>
</CVATTooltip>
</Col>
);
})}
</Row>
))}
</div>
);
}
const MemoizedContent = React.memo(PopoverContent);
function LabelKeySelectorPopover(props: LabelKeySelectorPopoverProps): JSX.Element {
const {
children, labelID, updateLabelShortcutKey, keyToLabelMapping,
} = props;
return (
<Popover
destroyTooltipOnHide={{ keepParent: false }}
trigger='click'
content={(
<MemoizedContent
keyToLabelMapping={keyToLabelMapping}
labelID={labelID}
updateLabelShortcutKey={updateLabelShortcutKey}
/>
)}
placement='left'
>
{children}
</Popover>
);
}
export default React.memo(LabelKeySelectorPopover);
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import message from 'antd/lib/message';
import { CombinedState } from 'reducers/interfaces';
import { rememberObject, updateAnnotationsAsync } from 'actions/annotation-actions';
import LabelItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/label-item';
import GlobalHotKeys from 'utils/mousetrap-react';
interface Props {
labelIDs: number[];
listHeight: number;
}
function LabelsListComponent(): JSX.Element {
const dispatch = useDispatch();
const {
annotation: {
job: { labels },
tabContentHeight: listHeight,
annotations: { activatedStateID, states },
},
shortcuts: { keyMap },
} = useSelector((state: CombinedState) => state);
const labelIDs = labels.map((label: any): number => label.id);
const [keyToLabelMapping, setKeyToLabelMapping] = useState<Record<string, number>>(
Object.fromEntries(labelIDs.slice(0, 10).map((labelID: number, idx: number) => [(idx + 1) % 10, labelID])),
);
const updateLabelShortcutKey = useCallback(
(key: string, labelID: number) => {
// unassign any keys assigned to the current labels
const keyToLabelMappingCopy = { ...keyToLabelMapping };
for (const shortKey of Object.keys(keyToLabelMappingCopy)) {
if (keyToLabelMappingCopy[shortKey] === labelID) {
delete keyToLabelMappingCopy[shortKey];
}
}
if (key === '') {
setKeyToLabelMapping(keyToLabelMappingCopy);
return;
}
export default function LabelsListComponent(props: Props): JSX.Element {
const { listHeight, labelIDs } = props;
// check if this key is assigned to another label
if (key in keyToLabelMappingCopy) {
// try to find a new key for the other label
for (let i = 0; i < 10; i++) {
const adjustedI = (i + 1) % 10;
if (!(adjustedI in keyToLabelMappingCopy)) {
keyToLabelMappingCopy[adjustedI] = keyToLabelMappingCopy[key];
break;
}
}
// delete assigning to the other label
delete keyToLabelMappingCopy[key];
}
// assigning to the current label
keyToLabelMappingCopy[key] = labelID;
setKeyToLabelMapping(keyToLabelMappingCopy);
},
[keyToLabelMapping],
);
const subKeyMap = {
SWITCH_LABEL: keyMap.SWITCH_LABEL,
};
const handlers = {
SWITCH_LABEL: (event: KeyboardEvent | undefined, shortcut: string) => {
if (event) event.preventDefault();
const labelID = keyToLabelMapping[shortcut.split('+')[1].trim()];
const label = labels.filter((_label: any) => _label.id === labelID)[0];
if (Number.isInteger(labelID) && label) {
if (Number.isInteger(activatedStateID)) {
const activatedState = states.filter((state: any) => state.clientID === activatedStateID)[0];
if (activatedState) {
activatedState.label = label;
dispatch(updateAnnotationsAsync([activatedState]));
}
} else {
dispatch(rememberObject({ activeLabelID: labelID }));
message.destroy();
message.success(`Default label was changed to "${label.name}"`);
}
}
},
};
return (
<div style={{ height: listHeight }} className='cvat-objects-sidebar-labels-list'>
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} />
{labelIDs.map(
(labelID: number): JSX.Element => (
<LabelItemContainer key={labelID} labelID={labelID} />
<LabelItemContainer
key={labelID}
labelID={labelID}
keyToLabelMapping={keyToLabelMapping}
updateLabelShortcutKey={updateLabelShortcutKey}
/>
),
)}
</div>
);
}
export default React.memo(LabelsListComponent);
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -13,7 +13,7 @@ import Layout from 'antd/lib/layout';
import { Canvas } from 'cvat-canvas-wrapper';
import { CombinedState } from 'reducers/interfaces';
import LabelsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/labels-list';
import LabelsList from 'components/annotation-page/standard-workspace/objects-side-bar/labels-list';
import {
collapseSidebar as collapseSidebarAction,
updateTabContentHeight as updateTabContentHeightAction,
......@@ -123,8 +123,8 @@ function ObjectsSideBar(props: StateToProps & DispatchToProps & OwnProps): JSX.E
<Tabs.TabPane tab={<Text strong>Objects</Text>} key='objects'>
{objectsList}
</Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Labels</Text>} key='labels'>
<LabelsListContainer />
<Tabs.TabPane forceRender tab={<Text strong>Labels</Text>} key='labels'>
<LabelsList />
</Tabs.TabPane>
<Tabs.TabPane tab={<Text strong>Issues</Text>} key='issues'>
<IssuesListComponent />
......
......@@ -167,7 +167,8 @@
padding: 3px 1px 1px 3px;
}
.cvat-objects-sidebar-state-item-color {
.cvat-objects-sidebar-state-item-color,
.cvat-objects-sidebar-label-item-color {
border: 1px solid $object-item-border-color;
width: 7px;
opacity: 1;
......@@ -288,12 +289,16 @@
.cvat-objects-sidebar-label-active-item {
background: $active-label-background-color;
border-top: 2px solid $object-item-border-color;
border-right: 2px solid $object-item-border-color;
border-bottom: 2px solid $object-item-border-color;
padding: 3px 1px 1px 3px;
}
.cvat-objects-sidebar-label-item {
height: 2.5em;
border-bottom: 1px solid $border-color-1;
padding: 5px;
padding: 5px 3px 3px 3px;
span {
@extend .cvat-object-sidebar-icon;
......@@ -311,10 +316,39 @@
}
}
.cvat-label-item-color-button {
width: 30px;
height: 20px;
border-radius: 5px;
.cvat-label-item-color {
background: rgb(25, 184, 14);
height: 80%;
width: 90%;
border-radius: $grid-unit-size / 2;
}
.cvat-label-item-setup-shortcut-button {
border-color: $objects-bar-icons-color;
}
.cvat-label-item-setup-shortcut-popover {
margin-top: -$grid-unit-size;
margin-bottom: -$grid-unit-size;
> div {
padding-top: $grid-unit-size;
padding-bottom: $grid-unit-size;
> div {
display: flex;
justify-content: center;
> button {
width: $grid-unit-size * 15;
overflow-x: hidden;
span:first-child {
margin-right: $grid-unit-size;
}
}
}
}
}
.cvat-objects-appearance-content {
......@@ -361,3 +395,7 @@
margin-right: $grid-unit-size;
}
}
.cvat-objects-sidebar-label-item-disabled {
opacity: 0.5;
}
......@@ -82,7 +82,7 @@ function mapDispatchToProps(dispatch: ThunkDispatch<CombinedState, {}, Action>):
dispatch(removeObjectAsync(jobInstance, objectState, true));
},
onRememberObject(labelID: number): void {
dispatch(rememberObject(ObjectType.TAG, labelID));
dispatch(rememberObject({ activeObjectType: ObjectType.TAG, activeLabelID: labelID }));
},
};
}
......
......@@ -41,7 +41,15 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
points?: number,
rectDrawingMethod?: RectDrawingMethod,
): void {
dispatch(rememberObject(objectType, labelID, shapeType, points, rectDrawingMethod));
dispatch(
rememberObject({
activeObjectType: objectType,
activeShapeType: shapeType,
activeLabelID: labelID,
activeNumOfPoints: points,
activeRectDrawingMethod: rectDrawingMethod,
}),
);
},
};
}
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -32,7 +32,7 @@ function mapDispatchToProps(dispatch: any): DispatchToProps {
dispatch(createAnnotationsAsync(sessionInstance, frame, states));
},
onRememberObject(labelID: number): void {
dispatch(rememberObject(ObjectType.TAG, labelID));
dispatch(rememberObject({ activeObjectType: ObjectType.TAG, activeLabelID: labelID }));
},
};
}
......
// Copyright (C) 2020 Intel Corporation
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
......@@ -12,6 +12,8 @@ import { CombinedState, ObjectType } from 'reducers/interfaces';
interface OwnProps {
labelID: number;
keyToLabelMapping: Record<string, number>;
updateLabelShortcutKey(updatedKey: string, labelID: number): void;
}
interface StateToProps {
......@@ -20,7 +22,7 @@ interface StateToProps {
labelColor: string;
objectStates: any[];
jobInstance: any;
frameNumber: any;
frameNumber: number;
}
interface DispatchToProps {
......@@ -127,35 +129,38 @@ class LabelItemContainer extends React.PureComponent<Props, State> {
private switchHidden(value: boolean): void {
const { updateAnnotations } = this.props;
const { ownObjectStates } = this.state;
for (const state of ownObjectStates) {
state.hidden = value;
}
updateAnnotations(ownObjectStates);
if (ownObjectStates.length) {
// false alarm
// eslint-disable-next-line
updateAnnotations(ownObjectStates.map((state: any) => ((state.hidden = value), state)));
}
}
private switchLock(value: boolean): void {
const { updateAnnotations } = this.props;
const { ownObjectStates } = this.state;
for (const state of ownObjectStates) {
state.lock = value;
}
updateAnnotations(ownObjectStates);
if (ownObjectStates.length) {
// false alarm
// eslint-disable-next-line
updateAnnotations(ownObjectStates.map((state: any) => ((state.lock = value), state)));
}
}
public render(): JSX.Element {
const {
labelName, labelColor, keyToLabelMapping, labelID, updateLabelShortcutKey,
} = this.props;
const { visible, statesHidden, statesLocked } = this.state;
const { labelName, labelColor } = this.props;
return (
<LabelItemComponent
labelName={labelName}
labelColor={labelColor}
labelID={labelID}
keyToLabelMapping={keyToLabelMapping}
visible={visible}
statesHidden={statesHidden}
statesLocked={statesLocked}
......@@ -163,6 +168,7 @@ class LabelItemContainer extends React.PureComponent<Props, State> {
showStates={this.showStates}
lockStates={this.lockStates}
unlockStates={this.unlockStates}
updateLabelShortcutKey={updateLabelShortcutKey}
/>
);
}
......
// Copyright (C) 2020 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { connect } from 'react-redux';
import LabelsListComponent from 'components/annotation-page/standard-workspace/objects-side-bar/labels-list';
import { CombinedState } from 'reducers/interfaces';
interface StateToProps {
labelIDs: number[];
listHeight: number;
}
function mapStateToProps(state: CombinedState): StateToProps {
const {
annotation: {
job: { labels },
tabContentHeight: listHeight,
},
} = state;
return {
labelIDs: labels.map((label: any): number => label.id),
listHeight,
};
}
export default connect(mapStateToProps)(LabelsListComponent);
......@@ -456,9 +456,22 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
};
}
case AnnotationActionTypes.REMEMBER_CREATED_OBJECT: {
const {
shapeType, labelID, objectType, points, activeControl, rectDrawingMethod,
} = action.payload;
const { payload } = action;
let { activeControl } = state.canvas;
if (payload.activeShapeType === ShapeType.RECTANGLE) {
activeControl = ActiveControl.DRAW_RECTANGLE;
} else if (payload.activeShapeType === ShapeType.POLYGON) {
activeControl = ActiveControl.DRAW_POLYGON;
} else if (payload.activeShapeType === ShapeType.POLYLINE) {
activeControl = ActiveControl.DRAW_POLYLINE;
} else if (payload.activeShapeType === ShapeType.POINTS) {
activeControl = ActiveControl.DRAW_POINTS;
} else if (payload.activeShapeType === ShapeType.CUBOID) {
activeControl = ActiveControl.DRAW_CUBOID;
} else if (payload.activeObjectType === ObjectType.TAG) {
activeControl = ActiveControl.CURSOR;
}
return {
...state,
......@@ -471,12 +484,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
activeControl,
},
drawing: {
...state.drawing,
...payload,
activeInteractor: undefined,
activeLabelID: labelID,
activeNumOfPoints: points,
activeObjectType: objectType,
activeShapeType: shapeType,
activeRectDrawingMethod: rectDrawingMethod,
},
};
}
......
......@@ -381,6 +381,7 @@ export interface PredictorState {
fetching: boolean;
annotationAmount: number;
mediaAmount: number;
annotatedFrames: number[];
}
export interface AnnotationState {
......
......@@ -287,10 +287,16 @@ const defaultKeyMap = ({
},
TOGGLE_LAYOUT_GRID: {
name: 'Toggle layout grid',
description: 'Is used in development',
description: 'The grid is used to UI development',
sequences: ['ctrl+alt+enter'],
action: 'keydown',
},
SWITCH_LABEL: {
name: 'Switch label',
description: 'Changes a label for an activated object or for the next drawn object if no objects are activated',
sequences: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].map((val: string): string => `ctrl+${val}`),
action: 'keydown',
},
} as any) as KeyMap;
const defaultState: ShortcutsState = {
......
......@@ -17,7 +17,7 @@ export interface KeyMap {
}
export interface Handlers {
[index: string]: (event: KeyboardEvent) => void;
[index: string]: (event: KeyboardEvent, shortcut: string) => void;
}
interface Props {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册