diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index eeeac1fe665..c9656d6b48f 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - ### Changed +- The menu for viewing, editing and creating annotations for a dataset in the dashboard was cleaned up a bit. Creating a hybrid (skeleton + volume) annotation is now the default way of annotating a dataset. The other options are still available via a dropdown. [#4939](https://github.com/scalableminds/webknossos/pull/4939) - For 2d-only datasets the view mode toggle is hidden. [#4952](https://github.com/scalableminds/webknossos/pull/4952) ### Fixed diff --git a/app/models/binary/DataSetService.scala b/app/models/binary/DataSetService.scala index ed8382eea18..db1e23b5a0e 100644 --- a/app/models/binary/DataSetService.scala +++ b/app/models/binary/DataSetService.scala @@ -241,22 +241,20 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, def dataSourceFor(dataSet: DataSet, organization: Option[Organization] = None, skipResolutions: Boolean = false)( implicit ctx: DBAccessContext): Fox[InboxDataSource] = - for { + (for { organization <- Fox.fillOption(organization) { organizationDAO.findOne(dataSet._organization)(GlobalAccessContext) ?~> "organization.notFound" } - dataLayersBox <- dataSetDataLayerDAO.findAllForDataSet(dataSet._id, skipResolutions).futureBox + dataLayers <- dataSetDataLayerDAO.findAllForDataSet(dataSet._id, skipResolutions) dataSourceId = DataSourceId(dataSet.name, organization.name) } yield { - dataLayersBox match { - case Full(dataLayers) if (dataLayers.nonEmpty) => - for { - scale <- dataSet.scale - } yield GenericDataSource[DataLayer](dataSourceId, dataLayers, scale) - case _ => - Some(UnusableDataSource[DataLayer](dataSourceId, dataSet.status, dataSet.scale)) - } - } + if (dataSet.isUsable) + for { + scale <- dataSet.scale.toFox ?~> "dataSet.source.usableButNoScale" + } yield GenericDataSource[DataLayer](dataSourceId, dataLayers, scale) + else + Fox.successful(UnusableDataSource[DataLayer](dataSourceId, dataSet.status, dataSet.scale)) + }).flatten def logoUrlFor(dataSet: DataSet, organization: Option[Organization]): Fox[String] = dataSet.logoUrl match { diff --git a/conf/messages b/conf/messages index ed4e361b00f..5d4cbe63b51 100644 --- a/conf/messages +++ b/conf/messages @@ -94,6 +94,7 @@ dataSet.import.impossible.name=Import impossible. Dataset name can only consist dataSet.name.alreadyTaken=This dataset name is already in use. dataSet.name.notInSamples=This dataset name is not one of the available sample datasets. dataSet.source.notFound=The data source for the data source couldn’t be found +dataSet.source.usableButNoScale=Dataset {0} is marked as active but has no scale. dataSet.import.success=The import of the dataset was successful dataSet.import.failed=Failed to import the dataset dataSet.import.fileAccessDenied=Cannot create organization folder. Please make sure webKnossos has write permissions in the “binaryData” directory diff --git a/frontend/javascripts/admin/admin_rest_api.js b/frontend/javascripts/admin/admin_rest_api.js index 3e5b68cf28a..55fba93c41d 100644 --- a/frontend/javascripts/admin/admin_rest_api.js +++ b/frontend/javascripts/admin/admin_rest_api.js @@ -761,7 +761,6 @@ export async function getDatasets( const parameters = isUnreported != null ? `?isUnreported=${String(isUnreported)}` : ""; const datasets = await Request.receiveJSON(`/api/datasets${parameters}`); assertResponseLimit(datasets); - return datasets; } diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.js b/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.js index 5b1c632c84f..4d49d75b87f 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.js +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_action_view.js @@ -3,68 +3,136 @@ import { Dropdown, Menu, Icon, Tooltip } from "antd"; import { Link, withRouter } from "react-router-dom"; import * as React from "react"; -import type { APIMaybeUnimportedDataset, TracingType } from "types/api_flow_types"; +import type { APIMaybeUnimportedDataset, APIDataset } from "types/api_flow_types"; import { clearCache } from "admin/admin_rest_api"; +import { + getSegmentationLayer, + doesSupportVolumeWithFallback, +} from "oxalis/model/accessors/dataset_accessor"; import Toast from "libs/toast"; import messages from "messages"; -const createTracingOverlayMenu = (dataset: APIMaybeUnimportedDataset, type: TracingType) => { - const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1); - - const hasSegmentationLayer = - dataset.dataSource.dataLayers != null - ? dataset.dataSource.dataLayers.find(layer => layer.category === "segmentation") != null - : false; - const disabledLinkStyle = { pointerEvents: "none", color: "rgb(173, 173, 173)" }; +const getNewTracingMenu = (maybeUnimportedDataset: APIMaybeUnimportedDataset) => { + if (!maybeUnimportedDataset.isActive) { + // The dataset isn't imported. This menu won't be shown, anyway. + return ; + } + const dataset: APIDataset = maybeUnimportedDataset; - return ( - - - - Use Existing Segmentation Layer - - - - { + const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1); + return ( + + - Use a New Segmentation Layer - + {label} + - - ); + ); + }; + + const segmentationLayer = getSegmentationLayer(dataset); + + if (segmentationLayer != null) { + if (doesSupportVolumeWithFallback(dataset)) { + return ( + + + {buildMenuItem("hybrid", false, "New Annotation (Without Existing Segmentation)")} + {buildMenuItem("skeleton", false, "New Skeleton-only Annotation")} + + {buildMenuItem("volume", true, "With Existing Segmentation")} + {buildMenuItem("volume", false, "Without Existing Segmentation")} + + + ); + } else { + return ( + + + {buildMenuItem("skeleton", false, "New Skeleton-only Annotation")} + + {buildMenuItem("volume", true, "With existing Segmentation", true)} + {buildMenuItem("volume", false, "Without Existing Segmentation")} + + + ); + } + } else { + return ( + + + {buildMenuItem("skeleton", false, "New Skeleton-only Annotation")} + {buildMenuItem("volume", true, "New Volume-only Annotation")} + + ); + } }; -export const createTracingOverlayMenuWithCallback = ( - dataset: APIMaybeUnimportedDataset, - type: TracingType, - onClick: (APIMaybeUnimportedDataset, TracingType, boolean) => Promise, -) => { - const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1); +const disabledStyle = { pointerEvents: "none", color: "rgba(0, 0, 0, 0.25)" }; +function getDisabledWhenReloadingStyle(isReloading) { + return isReloading ? disabledStyle : null; +} + +function NewAnnotationLink({ dataset, isReloading }) { + const newTracingMenu = getNewTracingMenu(dataset); + const withFallback = doesSupportVolumeWithFallback(dataset) ? "true" : "false"; + return ( - - onClick(dataset, type, true)}> - - Use Existing Segmentation Layer - - - onClick(dataset, type, false)}> - - Use a New Segmentation Layer - - - + + {isReloading ? null : ( +
+ + + New Annotation + + + | + + + e.preventDefault()}> + + + +
+ )} +
); -}; +} + +function ImportLink({ dataset }) { + return ( +
+ + + Import + + +
{dataset.dataSource.status}
+
+ ); +} type Props = { dataset: APIMaybeUnimportedDataset, @@ -74,6 +142,28 @@ type State = { isReloading: boolean, }; +function LinkWithDisabled({ + disabled, + ...rest +}: { + disabled?: boolean, + style?: Object, + to: string, +}) { + const maybeDisabledStyle = disabled ? disabledStyle : null; + const adaptedStyle = + rest.style != null + ? { + ...rest.style, + ...maybeDisabledStyle, + } + : maybeDisabledStyle; + + return ( + (disabled ? e.preventDefault() : null)} /> + ); +} + class DatasetActionView extends React.PureComponent { state = { isReloading: false, @@ -93,130 +183,55 @@ class DatasetActionView extends React.PureComponent { render() { const { dataset } = this.props; const { isReloading } = this.state; - const centerBackgroundImageStyle: { verticalAlign: string, filter?: string } = { - verticalAlign: "middle", - }; - if (isReloading) { - // We need to explicitly grayscale the images when the dataset is being reloaded. - centerBackgroundImageStyle.filter = "grayscale(100%) opacity(25%)"; - } - const disabledWhenReloadingStyle = isReloading - ? { pointerEvents: "none", color: "rgba(0, 0, 0, 0.25)" } - : null; - const disabledWhenReloadingAction = e => (isReloading ? e.preventDefault() : null); - - const volumeTracingMenu = ( - - - volume icon{" "} - Start Volume Annotation - - - ); - const hybridTracingMenu = ( - - - - Start Hybrid Annotation - - - ); + const disabledWhenReloadingStyle = getDisabledWhenReloadingStyle(isReloading); return (
- {dataset.isEditable && dataset.dataSource.dataLayers == null ? ( -
- - - Import - - -
{dataset.dataSource.status}
-
- ) : null} + {dataset.isEditable && !dataset.isActive ? : null} {dataset.isActive ? (
+ {!dataset.isForeign ? ( + + ) : ( +

+ New Annotation   + + + +

+ )} + + + View + {dataset.isEditable ? ( - - - Edit - + + Settings + {!dataset.isForeign ? ( this.clearCache(dataset)} title="Reload Dataset" style={disabledWhenReloadingStyle} + type="link" > - {isReloading ? : } + {isReloading ? : } Reload ) : null} ) : null} - - - View - - {!dataset.isForeign ? ( - - { - if (isReloading) { - e.preventDefault(); - } - }} - title="Create Skeleton Annotation" - > - skeleton icon{" "} - Start Skeleton Annotation - - {volumeTracingMenu} - {hybridTracingMenu} - - ) : ( -

- Start Annotation   - - - -

- )}
) : null}
diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js index 9cc05f5274e..5a3ec77ecdf 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js @@ -88,7 +88,7 @@ class DatasetTable extends React.PureComponent { const filterByHasLayers = datasets => this.props.isUserAdmin || this.props.isUserDatasetManager ? datasets - : datasets.filter(dataset => dataset.dataSource.dataLayers != null); + : datasets.filter(dataset => dataset.isActive && dataset.dataSource.dataLayers.length > 0); return filterByQuery(filterByMode(filterByHasLayers(this.props.datasets))); } @@ -189,7 +189,9 @@ class DatasetTable extends React.PureComponent { key="scale" width={230} render={(__, dataset: APIMaybeUnimportedDataset) => - `${formatScale(dataset.dataSource.scale)} ${getDatasetExtentAsString(dataset)}` + `${ + dataset.isActive ? formatScale(dataset.dataSource.scale) : "" + } ${getDatasetExtentAsString(dataset)}` } /> { dataIndex="dataSource.dataLayers" render={(__, dataset: APIMaybeUnimportedDataset) => (
- {(dataset.dataSource.dataLayers || []).map(layer => ( + {(dataset.isActive ? dataset.dataSource.dataLayers : []).map(layer => ( {layer.category} - {layer.elementClass} diff --git a/frontend/javascripts/dashboard/dataset/dataset_cache_provider.js b/frontend/javascripts/dashboard/dataset/dataset_cache_provider.js index 9a66388d522..0d9778f3eee 100644 --- a/frontend/javascripts/dashboard/dataset/dataset_cache_provider.js +++ b/frontend/javascripts/dashboard/dataset/dataset_cache_provider.js @@ -20,7 +20,7 @@ type Context = { fetchDatasets: (options?: Options) => Promise, }; -const wkDatasetsCacheKey = "wk.datasets"; +const wkDatasetsCacheKey = "wk.datasets-v2"; export const datasetCache = { set(datasets: APIMaybeUnimportedDataset[]): void { UserLocalStorage.setItem(wkDatasetsCacheKey, JSON.stringify(datasets)); diff --git a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.js b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.js index b078bd53383..fbd748d1a0c 100644 --- a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.js +++ b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.js @@ -427,15 +427,14 @@ export function getDatasetExtentAsString( dataset: APIMaybeUnimportedDataset, inVoxel: boolean = true, ): string { - if (!dataset.dataSource.dataLayers || !dataset.dataSource.scale || !dataset.isActive) { + if (!dataset.isActive) { return ""; } - const importedDataset = ((dataset: any): APIDataset); if (inVoxel) { - const extentInVoxel = getDatasetExtentInVoxel(importedDataset); + const extentInVoxel = getDatasetExtentInVoxel(dataset); return `${formatExtentWithLength(extentInVoxel, x => `${x}`)} voxel³`; } - const extent = getDatasetExtentInLength(importedDataset); + const extent = getDatasetExtentInLength(dataset); return formatExtentWithLength(extent, formatNumberToLength); } @@ -522,7 +521,11 @@ export function isColorLayer(dataset: APIDataset, layerName: string): boolean { return getLayerByName(dataset, layerName).category === "color"; } -export function getSegmentationLayer(dataset: APIDataset): ?APISegmentationLayer { +export function getSegmentationLayer(dataset: APIMaybeUnimportedDataset): ?APISegmentationLayer { + if (!dataset.isActive) { + return null; + } + // $FlowIssue[incompatible-type] // $FlowIssue[prop-missing] const segmentationLayers: Array = dataset.dataSource.dataLayers.filter( @@ -538,6 +541,21 @@ export function hasSegmentation(dataset: APIDataset): boolean { return getSegmentationLayer(dataset) != null; } +export function doesSupportVolumeWithFallback(dataset: APIMaybeUnimportedDataset): boolean { + if (!dataset.isActive) { + return false; + } + const segmentationLayer = getSegmentationLayer(dataset); + if (!segmentationLayer) { + return false; + } + + const isUint64 = + segmentationLayer.elementClass === "uint64" || segmentationLayer.elementClass === "int64"; + const isFallbackSupported = !isUint64; + return isFallbackSupported; +} + export function getColorLayers(dataset: APIDataset): Array { return dataset.dataSource.dataLayers.filter(dataLayer => isColorLayer(dataset, dataLayer.name)); } diff --git a/frontend/javascripts/oxalis/view/action_bar_view.js b/frontend/javascripts/oxalis/view/action_bar_view.js index 1c2162d864a..9367d2130e6 100644 --- a/frontend/javascripts/oxalis/view/action_bar_view.js +++ b/frontend/javascripts/oxalis/view/action_bar_view.js @@ -1,7 +1,8 @@ // @flow -import { Alert, Dropdown } from "antd"; +import { Alert } from "antd"; import { connect } from "react-redux"; import * as React from "react"; +import _ from "lodash"; import type { APIDataset, @@ -19,6 +20,7 @@ import { import { trackAction } from "oxalis/model/helpers/analytics"; import { updateUserSettingAction } from "oxalis/model/actions/settings_actions"; import AddNewLayoutModal from "oxalis/view/action-bar/add_new_layout_modal"; +import AuthenticationModal from "admin/auth/authentication_modal"; import ButtonComponent from "oxalis/view/components/button_component"; import Constants, { type ControlMode, ControlModeEnum, type ViewMode } from "oxalis/constants"; import DatasetPositionView from "oxalis/view/action-bar/dataset_position_view"; @@ -30,9 +32,10 @@ import TracingActionsView, { import ViewDatasetActionsView from "oxalis/view/action-bar/view_dataset_actions_view"; import ViewModesView from "oxalis/view/action-bar/view_modes_view"; import VolumeActionsView from "oxalis/view/action-bar/volume_actions_view"; -import AuthenticationModal from "admin/auth/authentication_modal"; -import { createTracingOverlayMenuWithCallback } from "dashboard/advanced_dataset/dataset_action_view"; -import { is2dDataset } from "oxalis/model/accessors/dataset_accessor"; +import { + is2dDataset, + doesSupportVolumeWithFallback, +} from "oxalis/model/accessors/dataset_accessor"; const VersionRestoreWarning = ( { state = { isNewLayoutModalVisible: false, isAuthenticationModalVisible: false, - useExistingSegmentation: true, }; handleResetLayout = () => { @@ -96,8 +97,12 @@ class ActionBarView extends React.PureComponent { } }; - createTracing = async (dataset: APIMaybeUnimportedDataset, useExistingSegmentation: boolean) => { - const annotation = await createExplorational(dataset, "hybrid", useExistingSegmentation); + createTracing = async (dataset: APIMaybeUnimportedDataset) => { + // If the dataset supports creating an annotation with a fallback segmentation, + // use it (as the fallback can always be removed later) + const withFallback = doesSupportVolumeWithFallback(dataset); + + const annotation = await createExplorational(dataset, "hybrid", withFallback); trackAction("Create hybrid tracing (from view mode)"); location.href = `${location.origin}/annotations/${annotation.typ}/${annotation.id}${ location.hash @@ -107,44 +112,24 @@ class ActionBarView extends React.PureComponent { renderStartTracingButton(): React.Node { const { activeUser, dataset } = this.props; const needsAuthentication = activeUser == null; - const hasSegmentationLayer = dataset.dataSource.dataLayers.some( - layer => layer.category === "segmentation", - ); - const handleCreateTracing = async ( - _dataset: APIMaybeUnimportedDataset, - _type: TracingType, - useExistingSegmentation: boolean, - ) => { + const handleCreateTracing = async (_dataset: APIMaybeUnimportedDataset, _type: TracingType) => { if (needsAuthentication) { - this.setState({ isAuthenticationModalVisible: true, useExistingSegmentation }); + this.setState({ isAuthenticationModalVisible: true }); } else { - this.createTracing(dataset, useExistingSegmentation); + this.createTracing(dataset); } }; - if (hasSegmentationLayer) { - return ( - - - Create Annotation - - - ); - } else { - return ( - handleCreateTracing(dataset, "hybrid", false)} - > - Create Annotation - - ); - } + return ( + handleCreateTracing(dataset, "hybrid")} + > + Create Annotation + + ); } render() { @@ -197,7 +182,7 @@ class ActionBarView extends React.PureComponent { { this.setState({ isAuthenticationModalVisible: false }); - this.createTracing(dataset, this.state.useExistingSegmentation); + this.createTracing(dataset); }} onCancel={() => this.setState({ isAuthenticationModalVisible: false })} visible={this.state.isAuthenticationModalVisible} diff --git a/frontend/javascripts/types/api_flow_types.js b/frontend/javascripts/types/api_flow_types.js index 9c4b83a9633..5ad619aceb1 100644 --- a/frontend/javascripts/types/api_flow_types.js +++ b/frontend/javascripts/types/api_flow_types.js @@ -79,10 +79,7 @@ type APIDataSourceBase = { +status?: string, }; -export type APIMaybeUnimportedDataSource = APIDataSourceBase & { - +dataLayers?: Array, - +scale?: ?Vector3, -}; +type APIUnimportedDatasource = APIDataSourceBase; export type APIDataSource = APIDataSourceBase & { +dataLayers: Array, @@ -147,16 +144,18 @@ type APIDatasetBase = APIDatasetId & { +publication: ?APIPublication, }; -export type APIMaybeUnimportedDataset = APIDatasetBase & { - +dataSource: APIMaybeUnimportedDataSource, - +isActive: boolean, -}; - export type APIDataset = APIDatasetBase & { +dataSource: APIDataSource, +isActive: true, }; +type APIUnimportedDataset = APIDatasetBase & { + +dataSource: APIUnimportedDatasource, + +isActive: false, +}; + +export type APIMaybeUnimportedDataset = APIUnimportedDataset | APIDataset; + export type APISampleDataset = { +name: string, +description: string, diff --git a/frontend/javascripts/types/schemas/dataset_view_configuration_defaults.js b/frontend/javascripts/types/schemas/dataset_view_configuration_defaults.js index 486e99cca6d..1168f294146 100644 --- a/frontend/javascripts/types/schemas/dataset_view_configuration_defaults.js +++ b/frontend/javascripts/types/schemas/dataset_view_configuration_defaults.js @@ -51,8 +51,7 @@ export const enforceValidatedDatasetViewConfiguration = ( const { layers } = datasetViewConfiguration; const newLayerConfig = {}; - if (maybeUnimportedDataset.dataSource.dataLayers != null) { - // $FlowFixMe[incompatible-type] + if (maybeUnimportedDataset.isActive) { const dataset: APIDataset = maybeUnimportedDataset; dataset.dataSource.dataLayers.forEach(layer => { const layerConfigDefault = getDefaultLayerViewConfiguration( diff --git a/frontend/stylesheets/_dashboard.less b/frontend/stylesheets/_dashboard.less index f7349fec02b..fc3ba04a12d 100644 --- a/frontend/stylesheets/_dashboard.less +++ b/frontend/stylesheets/_dashboard.less @@ -239,7 +239,7 @@ } .dataset-actions { - a { + & > a { display: block; img { width: 14px;