Skip to content

Commit 4ea0239

Browse files
authored
Reupload exported editable mapping annotation zip (#8969)
### URL of deployed dev instance (used for testing): - https://uploadeditablemapping.webknossos.xyz ### Steps to test: - Proofread some - Download annotation, reupload - Should see the same proofread state, history should be inaccessible. - ~Test with multiple proofreading layers in one annotation~ ← unsupported case - Try uploading multiple annotations, assertions should block if one has proofreading. ### TODOs: - [x] Handle history version numbers correctly - [x] Do we need to find the initial largestAgglomerateId? how does this work in create;duplicate? - [x] Add assertions when uploading multiple annotations (or fully build it?) - [x] Unzip the zarr arrays - [x] Create update actions from them - [x] Apply update actions? - [x] Make sure the result is a consistent state. - [x] tracingstore now needs chunkContentsCache - [x] propagate correct boolean fill_value - [x] double-check update actions have correct ordering - [x] test with multiple proofreading layers, are versions dense? - [x] test with multiple layers only one of which has proofreading, do versions look right? - [x] clean up ### Issues: - fixes #8826 ------ - [x] Added changelog entry (create a `$PR_NUMBER.md` file in `unreleased_changes` or use `./tools/create-changelog-entry.py`) - [x] Removed dev-only changes like prints and application.conf edits - [x] Considered [common edge cases](../blob/master/.github/common_edge_cases.md) - [x] Needs datastore update after deployment
1 parent e46b107 commit 4ea0239

32 files changed

+411
-121
lines changed

app/Startup.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class Startup @Inject()(actorSystem: ActorSystem,
9898
}
9999

100100
private def ensurePostgresSchema(): Unit = {
101-
logger.info("Checking database schema")
101+
logger.info("Checking database schema...")
102102

103103
val errorMessageBuilder = mutable.ListBuffer[String]()
104104
val capturingProcessLogger =
@@ -115,7 +115,7 @@ class Startup @Inject()(actorSystem: ActorSystem,
115115
}
116116

117117
private def ensurePostgresDatabase(): Unit = {
118-
logger.info(s"Ensuring Postgres database")
118+
logger.info(s"Ensuring Postgres database...")
119119
val processLogger =
120120
ProcessLogger((o: String) => logger.info(s"dbtool: $o"), (e: String) => logger.error(s"dbtool: $e"))
121121

app/controllers/AnnotationIOController.scala

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -132,25 +132,25 @@ class AnnotationIOController @Inject()(
132132
volumeLayersGrouped <- adaptVolumeTracingsToFallbackLayer(volumeLayersGroupedRaw, dataset, usableDataSource)
133133
tracingStoreClient <- tracingStoreService.clientFor(dataset)
134134
newAnnotationId = ObjectId.generate
135-
mergedVolumeLayers <- mergeAndSaveVolumeLayers(newAnnotationId,
136-
volumeLayersGrouped,
137-
tracingStoreClient,
138-
parsedFiles.otherFiles,
139-
usableDataSource,
140-
dataset._id)
135+
(mergedVolumeLayers, earliestAccessibleVersion) <- mergeAndSaveVolumeLayers(newAnnotationId,
136+
volumeLayersGrouped,
137+
tracingStoreClient,
138+
parsedFiles.otherFiles,
139+
usableDataSource,
140+
dataset._id)
141141
mergedSkeletonLayers <- mergeAndSaveSkeletonLayers(skeletonTracings, tracingStoreClient)
142142
annotation <- annotationService.createFrom(request.identity,
143143
dataset,
144144
mergedSkeletonLayers ::: mergedVolumeLayers,
145145
AnnotationType.Explorational,
146146
name,
147147
description,
148-
ObjectId.generate)
148+
newAnnotationId)
149149
annotationProto = AnnotationProto(
150150
description = annotation.description,
151151
version = 0L,
152152
annotationLayers = annotation.annotationLayers.map(_.toProto),
153-
earliestAccessibleVersion = 0L
153+
earliestAccessibleVersion = earliestAccessibleVersion
154154
)
155155
_ <- tracingStoreClient.saveAnnotationProto(annotation._id, annotationProto)
156156
_ <- annotationDAO.insertOne(annotation)
@@ -163,37 +163,64 @@ class AnnotationIOController @Inject()(
163163
}
164164
}
165165

166+
private def layersHaveDuplicateFallbackLayer(annotationLayers: Seq[UploadedVolumeLayer]) = {
167+
val withFallbackLayer = annotationLayers.filter(_.tracing.fallbackLayer.isDefined)
168+
withFallbackLayer.length > withFallbackLayer.distinctBy(_.tracing.fallbackLayer).length
169+
}
170+
166171
private def mergeAndSaveVolumeLayers(newAnnotationId: ObjectId,
167172
volumeLayersGrouped: Seq[List[UploadedVolumeLayer]],
168173
client: WKRemoteTracingStoreClient,
169174
otherFiles: Map[String, File],
170175
dataSource: UsableDataSource,
171-
datasetId: ObjectId): Fox[List[AnnotationLayer]] =
176+
datasetId: ObjectId): Fox[(List[AnnotationLayer], Long)] =
172177
if (volumeLayersGrouped.isEmpty)
173-
Fox.successful(List())
178+
Fox.successful(List(), 0L)
179+
else if (volumeLayersGrouped.exists(layersHaveDuplicateFallbackLayer(_)))
180+
Fox.failure("Cannot save annotation with multiple volume layers that have the same fallback segmentation layer.")
174181
else if (volumeLayersGrouped.length > 1 && volumeLayersGrouped.exists(_.length > 1))
175182
Fox.failure("Cannot merge multiple annotations that each have multiple volume layers.")
176-
else if (volumeLayersGrouped.length == 1) { // Just one annotation was uploaded, keep its layers separate
177-
Fox.serialCombined(volumeLayersGrouped.toList.flatten.zipWithIndex) { volumeLayerWithIndex =>
178-
val uploadedVolumeLayer = volumeLayerWithIndex._1
179-
val idx = volumeLayerWithIndex._2
180-
val newTracingId = TracingId.generate
181-
for {
182-
_ <- client.saveVolumeTracing(newAnnotationId,
183-
newTracingId,
184-
uploadedVolumeLayer.tracing,
185-
uploadedVolumeLayer.getDataZipFrom(otherFiles),
186-
dataSource = dataSource,
187-
datasetId = datasetId)
188-
} yield
189-
AnnotationLayer(
190-
newTracingId,
191-
AnnotationLayerType.Volume,
192-
uploadedVolumeLayer.name.getOrElse(AnnotationLayer.defaultVolumeLayerName + idx.toString),
193-
AnnotationLayerStatistics.unknown
194-
)
195-
}
196-
} else { // Multiple annotations with volume layers (but at most one each) was uploaded merge those volume layers into one
183+
else if (volumeLayersGrouped.length > 1 && volumeLayersGrouped.exists(
184+
_.exists(_.editedMappingEdgesLocation.isDefined))) {
185+
Fox.failure("Cannot merge multiple annotations with editable mapping (proofreading) edges.")
186+
} else if (volumeLayersGrouped.length == 1) { // Just one annotation was uploaded, keep its layers separate
187+
var layerUpdatesStartVersionMutable = 1L
188+
for {
189+
annotationLayers <- Fox.serialCombined(volumeLayersGrouped.toList.flatten.zipWithIndex) {
190+
volumeLayerWithIndex =>
191+
val uploadedVolumeLayer = volumeLayerWithIndex._1
192+
val idx = volumeLayerWithIndex._2
193+
val newTracingId = TracingId.generate
194+
for {
195+
numberOfSavedVersions <- client.saveEditableMappingIfPresent(
196+
newAnnotationId,
197+
newTracingId,
198+
uploadedVolumeLayer.getEditableMappingEdgesZipFrom(otherFiles),
199+
uploadedVolumeLayer.editedMappingBaseMappingName,
200+
startVersion = layerUpdatesStartVersionMutable
201+
)
202+
// The next layer’s update actions then need to start after this one
203+
_ = layerUpdatesStartVersionMutable = layerUpdatesStartVersionMutable + numberOfSavedVersions
204+
mappingName = if (uploadedVolumeLayer.editedMappingEdgesLocation.isDefined) Some(newTracingId)
205+
else uploadedVolumeLayer.tracing.mappingName
206+
_ <- client.saveVolumeTracing(
207+
newAnnotationId,
208+
newTracingId,
209+
uploadedVolumeLayer.tracing.copy(mappingName = mappingName),
210+
uploadedVolumeLayer.getDataZipFrom(otherFiles),
211+
dataSource = dataSource,
212+
datasetId = datasetId
213+
)
214+
} yield
215+
AnnotationLayer(
216+
newTracingId,
217+
AnnotationLayerType.Volume,
218+
uploadedVolumeLayer.name.getOrElse(AnnotationLayer.defaultVolumeLayerName + idx.toString),
219+
AnnotationLayerStatistics.unknown
220+
)
221+
}
222+
} yield (annotationLayers, layerUpdatesStartVersionMutable)
223+
} else { // Multiple annotations with volume layers (but at most one each) were uploaded, they have no editable mappings. Merge those volume layers into one
197224
val uploadedVolumeLayersFlat = volumeLayersGrouped.toList.flatten
198225
val newTracingId = TracingId.generate
199226
for {
@@ -206,13 +233,14 @@ class AnnotationIOController @Inject()(
206233
uploadedVolumeLayersFlat.map(v => v.getDataZipFrom(otherFiles))
207234
)
208235
} yield
209-
List(
210-
AnnotationLayer(
211-
newTracingId,
212-
AnnotationLayerType.Volume,
213-
AnnotationLayer.defaultVolumeLayerName,
214-
AnnotationLayerStatistics.unknown
215-
))
236+
(List(
237+
AnnotationLayer(
238+
newTracingId,
239+
AnnotationLayerType.Volume,
240+
AnnotationLayer.defaultVolumeLayerName,
241+
AnnotationLayerStatistics.unknown
242+
)),
243+
0L)
216244
}
217245

218246
private def mergeAndSaveSkeletonLayers(skeletonTracings: List[SkeletonTracing],

app/models/annotation/AnnotationUploadService.scala

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,17 @@ import play.api.i18n.MessagesProvider
2121

2222
import scala.concurrent.{ExecutionContext, Future}
2323

24-
case class UploadedVolumeLayer(tracing: VolumeTracing, dataZipLocation: String, name: Option[String]) {
24+
case class UploadedVolumeLayer(tracing: VolumeTracing,
25+
dataZipLocation: String,
26+
name: Option[String],
27+
editedMappingEdgesLocation: Option[String],
28+
editedMappingBaseMappingName: Option[String]) {
2529
def getDataZipFrom(otherFiles: Map[String, File]): Option[File] =
2630
otherFiles.get(dataZipLocation)
31+
32+
def getEditableMappingEdgesZipFrom(otherFiles: Map[String, File]): Option[File] =
33+
editedMappingEdgesLocation.flatMap(otherFiles.get)
34+
2735
}
2836

2937
case class SharedParsingParameters(useZipName: Boolean,

app/models/annotation/WKRemoteTracingStoreClient.scala

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,23 @@ class WKRemoteTracingStoreClient(
249249
} yield ()
250250
}
251251

252+
def saveEditableMappingIfPresent(annotationId: ObjectId,
253+
newTracingId: String,
254+
editedMappingEdgesZip: Option[File],
255+
editedMappingBaseMappingName: Option[String],
256+
startVersion: Long): Fox[Long] =
257+
(editedMappingEdgesZip, editedMappingBaseMappingName) match {
258+
case (Some(zipfile), Some(baseMappingName)) =>
259+
rpc(s"${tracingStore.url}/tracings/mapping/$newTracingId/save").withLongTimeout
260+
.addQueryParam("token", RpcTokenHolder.webknossosToken)
261+
.addQueryParam("annotationId", annotationId)
262+
.addQueryParam("baseMappingName", baseMappingName)
263+
.addQueryParam("startVersion", startVersion)
264+
.postFileWithJsonResponse[Long](zipfile)
265+
case (None, None) => Fox.successful(0L)
266+
case _ => Fox.failure("annotation.upload.editableMappingIncompleteInformation")
267+
}
268+
252269
def getVolumeTracing(annotationId: ObjectId,
253270
annotationLayer: AnnotationLayer,
254271
version: Option[Long],

app/models/annotation/nml/NmlParser.scala

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ class NmlParser @Inject()(datasetDAO: DatasetDAO)
9191
segmentGroups = v.segmentGroups,
9292
hasSegmentIndex = None, // Note: this property may be adapted later in adaptPropertiesToFallbackLayer
9393
editPositionAdditionalCoordinates = nmlParams.editPositionAdditionalCoordinates,
94-
additionalAxes = nmlParams.additionalAxisProtos
94+
additionalAxes = nmlParams.additionalAxisProtos,
95+
hasEditableMapping = if (v.editedMappingEdgesLocation.isDefined) Some(true) else None
9596
),
9697
basePath.getOrElse("") + v.dataZipPath,
9798
v.name,
99+
v.editedMappingEdgesLocation.map(location => basePath.getOrElse("") + location),
100+
v.editedMappingBaseMappingName
98101
)
99102
}
100103
skeletonTracing: SkeletonTracing = SkeletonTracing(
@@ -220,7 +223,9 @@ class NmlParser @Inject()(datasetDAO: DatasetDAO)
220223
getSingleAttributeOpt(node, "name"),
221224
parseVolumeSegmentMetadata(node \ "segments" \ "segment"),
222225
getSingleAttributeOpt(node, "largestSegmentId").flatMap(_.toLongOpt),
223-
extractSegmentGroups(node \ "groups").getOrElse(List())
226+
extractSegmentGroups(node \ "groups").getOrElse(List()),
227+
getSingleAttributeOpt(node, "editedMappingEdgesLocation"),
228+
getSingleAttributeOpt(node, "editedMappingBaseMappingName")
224229
)
225230
}
226231
)

app/models/annotation/nml/NmlVolumeTag.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ case class NmlVolumeTag(dataZipPath: String,
99
name: Option[String],
1010
segments: Seq[Segment],
1111
largestSegmentId: Option[Long],
12-
segmentGroups: Seq[SegmentGroup]) {}
12+
segmentGroups: Seq[SegmentGroup],
13+
editedMappingEdgesLocation: Option[String],
14+
editedMappingBaseMappingName: Option[String])

conf/application.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ tracingstore {
200200
address = "localhost"
201201
port = 6379
202202
}
203+
cache.chunkCacheMaxSizeBytes = 20000000 # 20 MB
203204
}
204205

205206
# Serve image data. Only active if the corresponding play module is enabled

conf/messages

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ annotation.idForTracing.failed=Could not find the annotation id for this tracing
267267
annotation.editableMapping.getAgglomerateGraph.failed=Could not look up an agglomerate graph for requested agglomerate.
268268
annotation.editableMapping.getAgglomerateIdsForSegments.failed=Could not look up agglomerate ids for requested segments.
269269
annotation.duplicate.failed=Failed to duplicate annotation
270+
annotation.upload.editableMappingIncompleteInformation=Could not store editable mapping, either file or baseMappingName is missing.
270271

271272
mesh.file.listChunks.failed=Failed to load chunk list for segment {0} from mesh file “{1}”
272273
mesh.file.loadChunk.failed=Failed to load mesh chunk for segment

frontend/javascripts/viewer/view/version_entry.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const descriptionFns: Record<
167167
? `at position ${action.value.segmentPosition1}`
168168
: (action.value.segmentId1 ?? "unknown");
169169
const segment2Description =
170-
action.value.segmentPosition2 ?? action.value.segmentId1 ?? "unknown";
170+
action.value.segmentPosition2 ?? action.value.segmentId2 ?? "unknown";
171171
const description = `Split agglomerate ${action.value.agglomerateId} by separating the segments ${segment1Description} and ${segment2Description}.`;
172172
return {
173173
description,
@@ -180,7 +180,7 @@ const descriptionFns: Record<
180180
? `at position ${action.value.segmentPosition1}`
181181
: (action.value.segmentId1 ?? "unknown");
182182
const segment2Description =
183-
action.value.segmentPosition2 ?? action.value.segmentId1 ?? "unknown";
183+
action.value.segmentPosition2 ?? action.value.segmentId2 ?? "unknown";
184184
const description = `Merged agglomerates ${action.value.agglomerateId1} and ${action.value.agglomerateId2} by combining the segments ${segment1Description} and ${segment2Description}.`;
185185
return {
186186
description,

unreleased_changes/8969.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Added
2+
- Editable mapping (aka proofreading) annotations can now be downloaded as zipfile and re-uploaded.

0 commit comments

Comments
 (0)