diff --git a/app/controllers/AnnotationIOController.scala b/app/controllers/AnnotationIOController.scala index 26e221b2cdd..97a198c8ed8 100755 --- a/app/controllers/AnnotationIOController.scala +++ b/app/controllers/AnnotationIOController.scala @@ -370,7 +370,8 @@ Expects: None, organizationName, Some(user), - taskOpt) + taskOpt, + skipVolumeData) temporaryFile = temporaryFileCreator.create() zipper = ZipIO.startZip(new BufferedOutputStream(new FileOutputStream(new File(temporaryFile.path.toString)))) _ <- zipper.addFileFromEnumerator(name + ".nml", nmlStream) diff --git a/app/models/annotation/nml/NmlWriter.scala b/app/models/annotation/nml/NmlWriter.scala index 42343641b04..ae7ddd66d11 100644 --- a/app/models/annotation/nml/NmlWriter.scala +++ b/app/models/annotation/nml/NmlWriter.scala @@ -39,7 +39,8 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { volumeFilename: Option[String], organizationName: String, annotationOwner: Option[User], - annotationTask: Option[Task]): Enumerator[Array[Byte]] = Enumerator.outputStream { os => + annotationTask: Option[Task], + skipVolumeData: Boolean = false): Enumerator[Array[Byte]] = Enumerator.outputStream { os => implicit val writer: IndentingXMLStreamWriter = new IndentingXMLStreamWriter(outputService.createXMLStreamWriter(os)) @@ -50,7 +51,8 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { volumeFilename, organizationName, annotationOwner, - annotationTask) + annotationTask, + skipVolumeData) _ = os.close() } yield nml } @@ -61,7 +63,8 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { volumeFilename: Option[String], organizationName: String, annotationOwner: Option[User], - annotationTask: Option[Task])(implicit writer: XMLStreamWriter): Fox[Unit] = + annotationTask: Option[Task], + skipVolumeData: Boolean)(implicit writer: XMLStreamWriter): Fox[Unit] = for { _ <- Xml.withinElement("things") { for { @@ -81,7 +84,8 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { case _ => () } _ = volumeLayers.zipWithIndex.foreach { - case (volumeLayer, index) => writeVolumeThings(volumeLayer, index, volumeLayers.length == 1, volumeFilename) + case (volumeLayer, index) => + writeVolumeThings(volumeLayer, index, volumeLayers.length == 1, volumeFilename, skipVolumeData) } } yield () } @@ -193,14 +197,21 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { def writeVolumeThings(volumeLayer: FetchedAnnotationLayer, index: Int, isSingle: Boolean, - volumeFilename: Option[String])(implicit writer: XMLStreamWriter): Unit = - Xml.withinElementSync("volume") { - writer.writeAttribute("id", index.toString) - writer.writeAttribute("location", volumeFilename.getOrElse(volumeLayer.volumeDataZipName(index, isSingle))) - volumeLayer.name.foreach(n => writer.writeAttribute("name", n)) - volumeLayer.tracing match { - case Right(volumeTracing) => volumeTracing.fallbackLayer.foreach(writer.writeAttribute("fallbackLayer", _)) - case _ => () + volumeFilename: Option[String], + skipVolumeData: Boolean)(implicit writer: XMLStreamWriter): Unit = + if (skipVolumeData) { + val nameLabel = volumeLayer.name.map(n => f"named $n ").getOrElse("") + writer.writeComment( + f"A volume layer $nameLabel(id = $index) was omitted here while downloading this annotation without volume data.") + } else { + Xml.withinElementSync("volume") { + writer.writeAttribute("id", index.toString) + writer.writeAttribute("location", volumeFilename.getOrElse(volumeLayer.volumeDataZipName(index, isSingle))) + volumeLayer.name.foreach(n => writer.writeAttribute("name", n)) + volumeLayer.tracing match { + case Right(volumeTracing) => volumeTracing.fallbackLayer.foreach(writer.writeAttribute("fallbackLayer", _)) + case _ => () + } } } diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index bb4c81bfd2f..8d434a65626 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -874,11 +874,12 @@ export function convertToHybridTracing( }); } -export async function downloadNml( +export async function downloadAnnotation( annotationId: string, annotationType: APIAnnotationType, showVolumeFallbackDownloadWarning: boolean = false, versions: Versions = {}, + includeVolumeData: boolean = true, ) { const possibleVersionString = Object.entries(versions) .map(([key, val]) => `${key}Version=${val}`) @@ -889,11 +890,14 @@ export async function downloadNml( timeout: 12000, }); } + const skipVolumeDataString = includeVolumeData ? "" : "skipVolumeData=true"; + const maybeAmpersand = possibleVersionString === "" && !includeVolumeData ? "" : "&"; - const downloadUrl = `/api/annotations/${annotationType}/${annotationId}/download?${possibleVersionString}`; + const downloadUrl = `/api/annotations/${annotationType}/${annotationId}/download?${possibleVersionString}${maybeAmpersand}${skipVolumeDataString}`; const { buffer, headers } = await Request.receiveArraybuffer(downloadUrl, { extractHeaders: true, }); + // Using headers to determine the name and type of the file. const contentDispositionHeader = headers["content-disposition"]; const filenameStartingPart = 'filename="'; diff --git a/frontend/javascripts/admin/job/job_list_view.tsx b/frontend/javascripts/admin/job/job_list_view.tsx index dbbb01b5359..0d419ebfd2b 100644 --- a/frontend/javascripts/admin/job/job_list_view.tsx +++ b/frontend/javascripts/admin/job/job_list_view.tsx @@ -174,6 +174,20 @@ class JobListView extends React.PureComponent { {" "} ); + } else if ( + job.type === "infer_neurons" && + job.organizationName && + job.datasetName && + job.layerName + ) { + return ( + + Neuron inferral for layer {job.layerName} of{" "} + + {job.datasetName} + {" "} + + ); } else if ( job.type === "materialize_volume_annotation" && job.organizationName && diff --git a/frontend/javascripts/admin/project/project_list_view.tsx b/frontend/javascripts/admin/project/project_list_view.tsx index e67e96fd57d..22e9fd1f451 100644 --- a/frontend/javascripts/admin/project/project_list_view.tsx +++ b/frontend/javascripts/admin/project/project_list_view.tsx @@ -29,7 +29,7 @@ import { deleteProject, pauseProject, resumeProject, - downloadNml, + downloadAnnotation, getTasks, getTaskType, } from "admin/admin_rest_api"; @@ -409,7 +409,7 @@ class ProjectListView extends React.PureComponent { href="#" onClick={async () => { this.maybeShowNoFallbackDataInfo(project.id); - await downloadNml(project.id, "CompoundProject"); + await downloadAnnotation(project.id, "CompoundProject"); }} title="Download all Finished Annotations" icon={} diff --git a/frontend/javascripts/admin/task/task_annotation_view.tsx b/frontend/javascripts/admin/task/task_annotation_view.tsx index 4aa08f14d4e..8b0f473410c 100644 --- a/frontend/javascripts/admin/task/task_annotation_view.tsx +++ b/frontend/javascripts/admin/task/task_annotation_view.tsx @@ -23,7 +23,7 @@ import { finishAnnotation, resetAnnotation, deleteAnnotation, - downloadNml, + downloadAnnotation, } from "admin/admin_rest_api"; import FormattedDate from "components/formatted_date"; import Toast from "libs/toast"; @@ -143,7 +143,7 @@ class TaskAnnotationView extends React.PureComponent { href="#" onClick={() => { const isVolumeIncluded = getVolumeDescriptors(annotation).length > 0; - return downloadNml(annotation.id, "Task", isVolumeIncluded); + return downloadAnnotation(annotation.id, "Task", isVolumeIncluded); }} icon={} > diff --git a/frontend/javascripts/admin/task/task_list_view.tsx b/frontend/javascripts/admin/task/task_list_view.tsx index 0e7c8b9fefa..65b0276e3f8 100644 --- a/frontend/javascripts/admin/task/task_list_view.tsx +++ b/frontend/javascripts/admin/task/task_list_view.tsx @@ -19,7 +19,7 @@ import _ from "lodash"; import features from "features"; import { AsyncLink } from "components/async_clickables"; import type { APITask, APITaskType } from "types/api_flow_types"; -import { deleteTask, getTasks, downloadNml } from "admin/admin_rest_api"; +import { deleteTask, getTasks, downloadAnnotation } from "admin/admin_rest_api"; import { formatTuple, formatSeconds } from "libs/format_utils"; import { handleGenericError } from "libs/error_handling"; import FormattedDate from "components/formatted_date"; @@ -418,7 +418,7 @@ class TaskListView extends React.PureComponent { href="#" onClick={() => { const includesVolumeData = task.type.tracingType !== "skeleton"; - return downloadNml(task.id, "CompoundTask", includesVolumeData); + return downloadAnnotation(task.id, "CompoundTask", includesVolumeData); }} title="Download all Finished Annotations" icon={} diff --git a/frontend/javascripts/admin/tasktype/task_type_list_view.tsx b/frontend/javascripts/admin/tasktype/task_type_list_view.tsx index 0c0ca56d897..f443058c918 100644 --- a/frontend/javascripts/admin/tasktype/task_type_list_view.tsx +++ b/frontend/javascripts/admin/tasktype/task_type_list_view.tsx @@ -9,7 +9,7 @@ import * as React from "react"; import _ from "lodash"; import { AsyncLink } from "components/async_clickables"; import type { APITaskType } from "types/api_flow_types"; -import { getTaskTypes, deleteTaskType, downloadNml } from "admin/admin_rest_api"; +import { getTaskTypes, deleteTaskType, downloadAnnotation } from "admin/admin_rest_api"; import { handleGenericError } from "libs/error_handling"; import LinkButton from "components/link_button"; import Persistence from "libs/persistence"; @@ -287,7 +287,11 @@ class TaskTypeListView extends React.PureComponent { href="#" onClick={() => { const includesVolumeData = taskType.tracingType !== "skeleton"; - return downloadNml(taskType.id, "CompoundTaskType", includesVolumeData); + return downloadAnnotation( + taskType.id, + "CompoundTaskType", + includesVolumeData, + ); }} title="Download all Finished Annotations" icon={} diff --git a/frontend/javascripts/dashboard/dashboard_task_list_view.tsx b/frontend/javascripts/dashboard/dashboard_task_list_view.tsx index 0f95b94ddd0..82dce3a9d18 100644 --- a/frontend/javascripts/dashboard/dashboard_task_list_view.tsx +++ b/frontend/javascripts/dashboard/dashboard_task_list_view.tsx @@ -28,7 +28,7 @@ import { finishTask, requestTask, peekNextTasks, - downloadNml, + downloadAnnotation, } from "admin/admin_rest_api"; import { enforceActiveUser } from "oxalis/model/accessors/user_accessor"; import { getSkeletonDescriptor } from "oxalis/model/accessors/skeletontracing_accessor"; @@ -272,7 +272,7 @@ class DashboardTaskListView extends React.PureComponent href="#" onClick={() => { const isVolumeIncluded = getVolumeDescriptors(annotation).length > 0; - return downloadNml(annotation.id, "Task", isVolumeIncluded); + return downloadAnnotation(annotation.id, "Task", isVolumeIncluded); }} icon={} > diff --git a/frontend/javascripts/dashboard/explorative_annotations_view.tsx b/frontend/javascripts/dashboard/explorative_annotations_view.tsx index abb5f654287..1067ff27f1d 100644 --- a/frontend/javascripts/dashboard/explorative_annotations_view.tsx +++ b/frontend/javascripts/dashboard/explorative_annotations_view.tsx @@ -23,7 +23,7 @@ import { finishAnnotation, reOpenAnnotation, getCompactAnnotations, - downloadNml, + downloadAnnotation, getCompactAnnotationsForUser, } from "admin/admin_rest_api"; import { formatHash } from "libs/format_utils"; @@ -240,7 +240,7 @@ class ExplorativeAnnotationsView extends React.PureComponent {
downloadNml(id, typ, hasVolumeTracing)} + onClick={() => downloadAnnotation(id, typ, hasVolumeTracing)} icon={} > Download diff --git a/frontend/javascripts/messages.ts b/frontend/javascripts/messages.ts index d8f966011e4..13e32ff44cd 100644 --- a/frontend/javascripts/messages.ts +++ b/frontend/javascripts/messages.ts @@ -105,7 +105,7 @@ Editing should be done in a single window only. In order to restore the current window, a reload is necessary.`, "react.rendering_error": - "Unfortunately, we encountered an error during rendering. We cannot guarantee that your work is persisted. Please reload the page and try again.", + "Unfortunately, webKnossos encountered an error during rendering. Your latest changes may not have been saved. Please reload the page to try again.", "save.leave_page_unfinished": "WARNING: You have unsaved progress that may be lost when hitting OK. Please click cancel, wait until the progress is saved and the save button displays a checkmark before leaving the page..", "save.failed": "Failed to save annotation. Retrying.", @@ -293,6 +293,13 @@ instead. Only enable this option if you understand its effect. All layers will n "annotation.delete": "Do you really want to reset and cancel this annotation?", "annotation.was_edited": "Successfully updated annotation", "annotation.shared_teams_edited": "Successfully updated the sharing options for the annotation", + "annotation.download": "The following annotation data is available for download immediately.", + "annotation.export": + "Exporting this annotation as TIFF images will trigger a background job to prepare data for download. This may take a while depending on the size of your dataset as well as bounding box and layer selection. You can monitor the progress and start the download from the ", + "annotation.export_no_worker": + "This webKnossos instance is not configured to run TIFF export jobs on a dedicated background worker. To learn more about this feature please contact us at ", + "annotation.python_do_not_share": + "These snippets are pre-configured and contain your personal access token and annotation meta data. Do not share this information with anyone you do not trust!", "project.delete": "Do you really want to delete this project?", "project.increase_instances": "Do you really want to add one additional instance to all tasks of this project?", diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index d1e76d260af..a91d9a4b16b 100644 --- a/frontend/javascripts/oxalis/default_state.ts +++ b/frontend/javascripts/oxalis/default_state.ts @@ -204,6 +204,7 @@ const defaultState: OxalisState = { activeTool: "MOVE", showDropzoneModal: false, showVersionRestore: false, + showDownloadModal: false, showShareModal: false, storedLayouts: {}, isImportingMesh: false, diff --git a/frontend/javascripts/oxalis/model/actions/ui_actions.ts b/frontend/javascripts/oxalis/model/actions/ui_actions.ts index 5bf32c53455..ba98baea7d0 100644 --- a/frontend/javascripts/oxalis/model/actions/ui_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/ui_actions.ts @@ -46,6 +46,12 @@ type SetThemeAction = { type: "SET_THEME"; value: Theme; }; + +type SetDownloadModalVisibilityAction = { + type: "SET_DOWNLOAD_MODAL_VISIBILITY"; + visible: boolean; +}; + type SetShareModalVisibilityAction = { type: "SET_SHARE_MODAL_VISIBILITY"; visible: boolean; @@ -61,6 +67,7 @@ export type UiAction = | SetToolAction | CycleToolAction | SetThemeAction + | SetDownloadModalVisibilityAction | SetShareModalVisibilityAction | SetBusyBlockingInfoAction; export const setDropzoneModalVisibilityAction = ( @@ -110,6 +117,14 @@ export const setThemeAction = (value: Theme): SetThemeAction => ({ type: "SET_THEME", value, }); + +export const setDownloadModalVisibilityAction = ( + visible: boolean, +): SetDownloadModalVisibilityAction => ({ + type: "SET_DOWNLOAD_MODAL_VISIBILITY", + visible, +}); + export const setShareModalVisibilityAction = (visible: boolean): SetShareModalVisibilityAction => ({ type: "SET_SHARE_MODAL_VISIBILITY", visible, diff --git a/frontend/javascripts/oxalis/model/reducers/ui_reducer.ts b/frontend/javascripts/oxalis/model/reducers/ui_reducer.ts index 5e8d323a3c0..47d90f3d544 100644 --- a/frontend/javascripts/oxalis/model/reducers/ui_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/ui_reducer.ts @@ -78,6 +78,10 @@ function UiReducer(state: OxalisState, action: Action): OxalisState { }); } + case "SET_DOWNLOAD_MODAL_VISIBILITY": { + return updateKey(state, "uiInformation", { showDownloadModal: action.visible }); + } + case "SET_SHARE_MODAL_VISIBILITY": { return updateKey(state, "uiInformation", { showShareModal: action.visible, diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 6edaba29f9c..f098bf843f7 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -438,6 +438,7 @@ export type BusyBlockingInfo = { type UiInformation = { readonly showDropzoneModal: boolean; readonly showVersionRestore: boolean; + readonly showDownloadModal: boolean; readonly showShareModal: boolean; readonly activeTool: AnnotationTool; readonly storedLayouts: Record; diff --git a/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx b/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx new file mode 100644 index 00000000000..8a2a81ce632 --- /dev/null +++ b/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx @@ -0,0 +1,496 @@ +import { Divider, Modal, Checkbox, Row, Col, Tabs, Typography, Button } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; +import React, { useState } from "react"; +import { useFetch } from "libs/react_helpers"; +import type { APIAnnotationType } from "types/api_flow_types"; +import Toast from "libs/toast"; +import messages from "messages"; +import Model from "oxalis/model"; +import features from "features"; +import { downloadAnnotation, getAuthToken } from "admin/admin_rest_api"; +import { CheckboxValueType } from "antd/lib/checkbox/Group"; +import { + LayerSelection, + BoundingBoxSelection, +} from "oxalis/view/right-border-tabs/starting_job_modals"; +import { getUserBoundingBoxesFromState } from "oxalis/model/accessors/tracing_accessor"; +import { hasVolumeTracings } from "oxalis/model/accessors/volumetracing_accessor"; +import { getDataLayers, getLayerByName } from "oxalis/model/accessors/dataset_accessor"; +import { useSelector } from "react-redux"; +import type { OxalisState } from "oxalis/store"; +import { + handleStartExport, + getLayerInfos, + isBoundingBoxExportable, +} from "../right-border-tabs/export_bounding_box_modal"; +const CheckboxGroup = Checkbox.Group; +const { TabPane } = Tabs; +const { Paragraph, Text } = Typography; +type Props = { + isVisible: boolean; + onClose: () => void; + annotationType: APIAnnotationType; + annotationId: string; + hasVolumeFallback: boolean; +}; + +function Hint({ children, style }: { children: React.ReactNode; style: React.CSSProperties }) { + return ( +
{children}
+ ); +} + +export async function copyToClipboard(code: string) { + await navigator.clipboard.writeText(code); + Toast.success("Snippet copied to clipboard."); +} + +function MoreInfoHint() { + return ( + + For more information on how to work with annotation files visit the{" "} + + user documentation + + . + + ); +} + +function CopyableCodeSnippet({ code, onCopy }: { code: string; onCopy?: () => void }) { + return ( +
+      
+ ); +} + +const okTextForTab = new Map([ + ["download", "Download"], + ["export", "Start Export Job"], + ["python", null], +]); + +function Footer({ + tabKey, + onClick, + boundingBoxCompatible, +}: { + tabKey: string; + onClick: () => void; + boundingBoxCompatible: boolean; +}) { + const okText = okTextForTab.get(tabKey); + return okText != null ? ( + + ) : null; +} + +export default function DownloadModalView(props: Props): JSX.Element { + const { isVisible, onClose, annotationType, annotationId, hasVolumeFallback } = props; + + const [activeTabKey, setActiveTabKey] = useState("download"); + const [includeVolumeData, setIncludeVolumeData] = useState(true); + const [keepWindowOpen, setKeepWindowOpen] = useState(false); + const [startedExports, setStartedExports] = useState([]); + const [selectedLayerName, setSelectedLayerName] = useState(null); + const [selectedBoundingBoxID, setSelectedBoundingBoxId] = useState(-1); + + const tracing = useSelector((state: OxalisState) => state.tracing); + const dataset = useSelector((state: OxalisState) => state.dataset); + const userBoundingBoxes = useSelector((state: OxalisState) => + getUserBoundingBoxesFromState(state), + ); + const isMergerModeEnabled = useSelector( + (state: OxalisState) => state.temporaryConfiguration.isMergerModeEnabled, + ); + const activeMappingInfos = useSelector( + (state: OxalisState) => state.temporaryConfiguration.activeMappingByLayer, + ); + + const layers = getDataLayers(dataset); + + const selectedBoundingBox = userBoundingBoxes.find((bbox) => bbox.id === selectedBoundingBoxID); + let boundingBoxCompatibleInfo = null; + if (selectedBoundingBox != null) { + boundingBoxCompatibleInfo = isBoundingBoxExportable(selectedBoundingBox.boundingBox); + } + + const handleOk = async () => { + if (activeTabKey === "download") { + await Model.ensureSavedState(); + downloadAnnotation(annotationId, annotationType, hasVolumeFallback, {}, includeVolumeData); + onClose(); + } else if (activeTabKey === "export") { + const missingSelection = selectedLayerName == null || selectedBoundingBoxID === -1; + const basicWarning = "Starting an export job with the chosen parameters was not possible."; + const missingSelectionWarning = " Please choose a layer and a bounding box for export."; + + if (selectedLayerName == null || selectedBoundingBoxID === -1) { + Toast.warning(basicWarning + missingSelectionWarning); + } else { + const selectedLayer = getLayerByName(dataset, selectedLayerName); + if (selectedLayer != null && selectedBoundingBox != null) { + const layerInfos = getLayerInfos( + selectedLayer, + tracing, + activeMappingInfos, + isMergerModeEnabled, + ); + await handleStartExport( + dataset, + layerInfos, + selectedBoundingBox.boundingBox, + startedExports, + setStartedExports, + ); + Toast.success("A new export job was started successfully."); + } else { + Toast.warning(basicWarning); + } + } + if (!keepWindowOpen && !missingSelection) { + onClose(); + } + } + }; + + const maybeShowWarning = () => { + if (activeTabKey === "download" && hasVolumeFallback) { + return ( + + + {messages["annotation.no_fallback_data_included"]} + + + ); + } else if (activeTabKey === "python") { + return ( + + + {messages["annotation.python_do_not_share"]} + + + ); + } + return null; + }; + + const handleTabChange = (key: string) => { + setActiveTabKey(key); + }; + + const handleCheckboxChange = (checkedValues: CheckboxValueType[]) => { + setIncludeVolumeData(checkedValues.includes("Volume")); + }; + + const handleKeepWindowOpenChecked = (e: any) => { + setKeepWindowOpen(e.target.checked); + }; + + const workerInfo = ( + + + + {messages["annotation.export_no_worker"]} + hello@webknossos.com. + + + ); + + const checkboxStyle = { + height: "30px", + lineHeight: "30px", + }; + + const authToken = useFetch(getAuthToken, "loading...", []); + const wkInitSnippet = `import webknossos as wk + +with wk.webknossos_context( + token="${authToken}", + url="${window.location.origin}" +): + annotation = wk.Annotation.download( + "${annotationId}", + annotation_type="${annotationType}", + ) +`; + + const alertTokenIsPrivate = () => { + Toast.warning( + "The clipboard contains private data. Do not share this information with anyone you do not trust!", + ); + }; + + const hasVolumes = hasVolumeTracings(tracing); + const hasSkeleton = tracing.skeleton != null; + + return ( + , + ]} + onCancel={onClose} + style={{ overflow: "visible" }} + > + + + + {maybeShowWarning()} + + {!hasVolumes ? "This is a Skeleton-only annotation. " : ""} + {!hasSkeleton ? "This is a Volume-only annotation. " : ""} + {messages["annotation.download"]} + + + + Options + + + + Select the data you would like to download. + + + + {hasVolumes ? ( +
+ + Volume annotations as WKW + + + Download a zip folder containing WKW files. + +
+ ) : null} + + + {hasSkeleton ? "Skeleton annotations" : "Meta data"} as NML + + + An NML file will always be included with any download. + +
+ +
+ + +
+ + + + + {messages["annotation.export"]} + + Jobs Overview Page + + . + + + {activeTabKey === "export" && !features().jobsEnabled ? ( + workerInfo + ) : ( +
+ + Layer + + + + Select the layer you would like to prepare for export. + + + + + + + Bounding Box + + + + Select a bounding box to constrain the data for export. + + + + + {boundingBoxCompatibleInfo?.alerts} + +
+ )} + + + + Keep window open + +
+ + + + + The following code snippets are suggestions to get you started quickly with the{" "} + + webKnossos Python API + + . To download and use this annotation in your Python project, simply copy and paste + the code snippets to your script. + + + + Code Snippets + + {maybeShowWarning()} + + + + + + + +
+
+ ); +} diff --git a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx index ebfe28955ef..49fcdc019e5 100644 --- a/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/share_modal_view.tsx @@ -209,7 +209,7 @@ export default function ShareModalView(props: Props) { const url = getUrl(sharingToken, includeToken); return ( void; addNewLayout: () => void; }; -export const LayoutMenu = (props: LayoutMenuProps) => { +export function LayoutMenu(props: LayoutMenuProps) { const { storedLayoutNamesForView, layoutKey, @@ -237,7 +239,7 @@ export const LayoutMenu = (props: LayoutMenuProps) => { ); -}; +} class TracingActionsView extends React.PureComponent { state: State = { @@ -377,10 +379,12 @@ class TracingActionsView extends React.PureComponent { Store.dispatch(setShareModalVisibilityAction(false)); }; - handleDownload = async () => { - await Model.ensureSavedState(); - const { annotationId, annotationType, hasVolumeFallback } = this.props; - downloadNml(annotationId, annotationType, hasVolumeFallback); + handleDownloadOpen = () => { + Store.dispatch(setDownloadModalVisibilityAction(true)); + }; + + handleDownloadClose = () => { + Store.dispatch(setDownloadModalVisibilityAction(false)); }; handleFinishAndGetNextTask = async () => { @@ -438,6 +442,7 @@ class TracingActionsView extends React.PureComponent { hasTracing, restrictions, task, + hasVolumeFallback, annotationType, annotationId, activeUser, @@ -553,11 +558,21 @@ class TracingActionsView extends React.PureComponent { if (restrictions.allowDownload) { elements.push( - + Download , ); + modals.push( + , + ); } elements.push( @@ -653,6 +668,7 @@ function mapStateToProps(state: OxalisState): StateProps { task: state.task, activeUser: state.activeUser, hasTracing: state.tracing.skeleton != null || state.tracing.volumes.length > 0, + isDownloadModalOpen: state.uiInformation.showDownloadModal, isShareModalOpen: state.uiInformation.showShareModal, busyBlockingInfo: state.uiInformation.busyBlockingInfo, }; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/export_bounding_box_modal.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/export_bounding_box_modal.tsx index e147d6dd878..9e9330ff47b 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/export_bounding_box_modal.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/export_bounding_box_modal.tsx @@ -4,13 +4,14 @@ import React, { useState } from "react"; import type { APIDataset, APIDataLayer } from "types/api_flow_types"; import type { BoundingBoxType } from "oxalis/constants"; import { MappingStatusEnum } from "oxalis/constants"; -import type { Tracing, AnnotationType } from "oxalis/store"; +import type { OxalisState, Tracing, AnnotationType, HybridTracing } from "oxalis/store"; import { getResolutionInfo, getMappingInfo } from "oxalis/model/accessors/dataset_accessor"; import { getVolumeTracingById } from "oxalis/model/accessors/volumetracing_accessor"; import { startExportTiffJob } from "admin/admin_rest_api"; import Model from "oxalis/model"; import * as Utils from "libs/utils"; import features from "features"; +import _ from "lodash"; type Props = { handleClose: () => void; tracing: Tracing | null | undefined; @@ -31,113 +32,156 @@ type LayerInfos = { isColorLayer: boolean | null | undefined; }; -const ExportBoundingBoxModal = ({ handleClose, dataset, boundingBox, tracing }: Props) => { - const [startedExports, setStartedExports] = useState([]); +const exportKey = (layerInfos: LayerInfos) => + (layerInfos.layerName || "") + (layerInfos.tracingId || ""); + +export function getLayerInfos( + layer: APIDataLayer, + tracing: HybridTracing | null | undefined, + activeMappingInfos: any, + isMergerModeEnabled: boolean, +) { const annotationId = tracing != null ? tracing.annotationId : null; const annotationType = tracing != null ? tracing.annotationType : null; - const activeMappingInfos = useSelector( - // @ts-expect-error ts-migrate(2339) FIXME: Property 'temporaryConfiguration' does not exist o... Remove this comment to see the full error message - (state) => state.temporaryConfiguration.activeMappingByLayer, - ); - const isMergerModeEnabled = useSelector( - // @ts-expect-error ts-migrate(2339) FIXME: Property 'temporaryConfiguration' does not exist o... Remove this comment to see the full error message - (state) => state.temporaryConfiguration.isMergerModeEnabled, - ); - const exportKey = (layerInfos: LayerInfos) => - (layerInfos.layerName || "") + (layerInfos.tracingId || ""); + const hasMag1 = (dataLayer: APIDataLayer) => getResolutionInfo(dataLayer.resolutions).hasIndex(0); + const { mappingStatus, hideUnmappedIds, mappingName, mappingType } = getMappingInfo( + activeMappingInfos, + layer.name, + ); + const existsActivePersistentMapping = + mappingStatus === MappingStatusEnum.ENABLED && !isMergerModeEnabled; + const isColorLayer = layer.category === "color"; - const handleStartExport = async (layerInfos: LayerInfos) => { - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. - setStartedExports(startedExports.concat(exportKey(layerInfos))); + if (layer.category === "color" || !layer.tracingId) { + return { + displayName: layer.name, + layerName: layer.name, + tracingId: null, + annotationId: null, + annotationType: null, + tracingVersion: null, + hasMag1: hasMag1(layer), + hideUnmappedIds: !isColorLayer && existsActivePersistentMapping ? hideUnmappedIds : null, + mappingName: !isColorLayer && existsActivePersistentMapping ? mappingName : null, + mappingType: !isColorLayer && existsActivePersistentMapping ? mappingType : null, + isColorLayer, + }; + } - if (layerInfos.tracingId) { - await Model.ensureSavedState(); - } - - await startExportTiffJob( - dataset.name, - dataset.owningOrganization, - Utils.computeArrayFromBoundingBox(boundingBox), - layerInfos.layerName, - layerInfos.tracingId, - layerInfos.annotationId, - layerInfos.annotationType, - layerInfos.mappingName, - layerInfos.mappingType, - layerInfos.hideUnmappedIds, - ); - }; + // The layer is a volume tracing layer, since tracingId exists. Therefore, a tracing + // must exist. + if (tracing == null) { + // Satisfy flow. + throw new Error("Tracing is null, but layer.tracingId is defined."); + } - const hasMag1 = (layer: APIDataLayer) => getResolutionInfo(layer.resolutions).hasIndex(0); - - const allLayerInfos = dataset.dataSource.dataLayers.map((layer) => { - const { mappingStatus, hideUnmappedIds, mappingName, mappingType } = getMappingInfo( - activeMappingInfos, - layer.name, - ); - const existsActivePersistentMapping = - mappingStatus === MappingStatusEnum.ENABLED && !isMergerModeEnabled; - const isColorLayer = layer.category === "color"; - - if (layer.category === "color" || !layer.tracingId) { - return { - displayName: layer.name, - layerName: layer.name, - tracingId: null, - annotationId: null, - annotationType: null, - tracingVersion: null, - hasMag1: hasMag1(layer), - hideUnmappedIds: !isColorLayer && existsActivePersistentMapping ? hideUnmappedIds : null, - mappingName: !isColorLayer && existsActivePersistentMapping ? mappingName : null, - mappingType: !isColorLayer && existsActivePersistentMapping ? mappingType : null, - isColorLayer, - }; - } - - // The layer is a volume tracing layer, since tracingId exists. Therefore, a tracing - // must exist. - if (tracing == null) { - // Satisfy typescript. - throw new Error("Tracing is null, but layer.tracingId is defined."); - } - - const volumeTracing = getVolumeTracingById(tracing, layer.tracingId); - - if (layer.fallbackLayerInfo != null) { - return { - displayName: "Volume annotation with fallback segmentation", - layerName: layer.fallbackLayerInfo.name, - tracingId: volumeTracing.tracingId, - annotationId, - annotationType, - tracingVersion: volumeTracing.version, - hasMag1: hasMag1(layer), - hideUnmappedIds: existsActivePersistentMapping ? hideUnmappedIds : null, - mappingName: existsActivePersistentMapping ? mappingName : null, - mappingType: existsActivePersistentMapping ? mappingType : null, - isColorLayer: false, - }; - } + const volumeTracing = getVolumeTracingById(tracing, layer.tracingId); + if (layer.fallbackLayerInfo != null) { return { - displayName: "Volume annotation", - layerName: null, + displayName: "Volume annotation with fallback segmentation", + layerName: layer.fallbackLayerInfo.name, tracingId: volumeTracing.tracingId, annotationId, annotationType, tracingVersion: volumeTracing.version, hasMag1: hasMag1(layer), - hideUnmappedIds: null, - mappingName: null, - mappingType: null, + hideUnmappedIds: existsActivePersistentMapping ? hideUnmappedIds : null, + mappingName: existsActivePersistentMapping ? mappingName : null, + mappingType: existsActivePersistentMapping ? mappingType : null, isColorLayer: false, }; - }); + } + + return { + displayName: "Volume annotation", + layerName: null, + tracingId: volumeTracing.tracingId, + annotationId, + annotationType, + tracingVersion: volumeTracing.version, + hasMag1: hasMag1(layer), + hideUnmappedIds: null, + mappingName: null, + mappingType: null, + isColorLayer: false, + }; +} + +export async function handleStartExport( + dataset: APIDataset, + layerInfos: LayerInfos, + boundingBox: BoundingBoxType, + startedExports: string[], + setStartedExports?: React.Dispatch>, +) { + if (setStartedExports) { + setStartedExports(startedExports.concat(exportKey(layerInfos))); + } + + if (layerInfos.tracingId) { + await Model.ensureSavedState(); + } + + await startExportTiffJob( + dataset.name, + dataset.owningOrganization, + Utils.computeArrayFromBoundingBox(boundingBox), + layerInfos.layerName, + layerInfos.tracingId, + layerInfos.annotationId, + layerInfos.annotationType, + layerInfos.mappingName, + layerInfos.mappingType, + layerInfos.hideUnmappedIds, + ); +} + +export function isBoundingBoxExportable(boundingBox: BoundingBoxType) { + const dimensions = boundingBox.max.map((maxItem, index) => maxItem - boundingBox.min[index]); + const volume = dimensions[0] * dimensions[1] * dimensions[2]; + const volumeExceeded = volume > features().exportTiffMaxVolumeMVx * 1024 * 1024; + const edgeLengthExceeded = dimensions.some( + (length) => length > features().exportTiffMaxEdgeLengthVx, + ); + + const dimensionString = dimensions.join(", "); + + const volumeExceededMessage = volumeExceeded + ? `The volume of the selected bounding box (${volume} vx) is too large. Tiff export is only supported for up to ${ + features().exportTiffMaxVolumeMVx + } Megavoxels.` + : null; + const edgeLengthExceededMessage = edgeLengthExceeded + ? `An edge length of the selected bounding box (${dimensionString}) is too large. Tiff export is only supported for boxes with no edge length over ${ + features().exportTiffMaxEdgeLengthVx + } vx.` + : null; + + const alertMessage = _.compact([volumeExceededMessage, edgeLengthExceededMessage]).join("\n"); + const alerts = alertMessage.length > 0 ? : null; + + return { + isExportable: !volumeExceeded && !edgeLengthExceeded, + alerts, + }; +} + +function ExportBoundingBoxModal({ handleClose, dataset, boundingBox, tracing }: Props) { + const [startedExports, setStartedExports] = useState([]); + const isMergerModeEnabled = useSelector( + (state: OxalisState) => state.temporaryConfiguration.isMergerModeEnabled, + ); + const activeMappingInfos = useSelector( + (state: OxalisState) => state.temporaryConfiguration.activeMappingByLayer, + ); + + const allLayerInfos = dataset.dataSource.dataLayers.map((layer: APIDataLayer) => + getLayerInfos(layer, tracing, activeMappingInfos, isMergerModeEnabled), + ); const exportButtonsList = allLayerInfos.map((layerInfos) => { const parenthesesInfos = [ - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string' is not assignable to par... Remove this comment to see the full error message startedExports.includes(exportKey(layerInfos)) ? "started" : null, layerInfos.mappingName != null ? `using mapping "${layerInfos.mappingName}"` : null, !layerInfos.hasMag1 ? "resolution 1 missing" : null, @@ -147,10 +191,11 @@ const ExportBoundingBoxModal = ({ handleClose, dataset, boundingBox, tracing }: return layerInfos ? (