Skip to content

Commit fe11316

Browse files
committed
Painting layer: enable (very) basic write support
Drawing on a painting layer will now actually store the changes in a server-side N5 file.
1 parent 9e219d3 commit fe11316

File tree

7 files changed

+144
-54
lines changed

7 files changed

+144
-54
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ Data sources:
4848
- Neuroglancer Precomputed URLs that start with `gs://` are rewritten on the fly
4949
to use Google Cloud storage get API requess, improving CORS support.
5050

51+
Painting tool:
52+
53+
- A new tool allows to create per-user N5 files by painting on the dataset. This
54+
tool needs to be enabled for users that should have it available (admin
55+
interface).
56+
5157
Cropping tool:
5258

5359
- Neuroglancer Precomputed datasets (sharded and unsharded) are now supported

django/applications/catmaid/control/cropping.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import os
1010
import os.path
1111
import math
12+
import msgpack
1213
import numpy as np
1314
from PIL import Image as PILImage, TiffImagePlugin
1415
import requests
@@ -969,37 +970,60 @@ def write_block(request:HttpRequest, project_id=None, writable_stack_id=None) ->
969970

970971
dataset_size = writable_stack.metadata.get('dataset_size')
971972
if not dataset_size:
972-
raise ValueError('Need dataset_size parameter in writable stack metadata')
973+
raise ValueError('Need parameter "dataset_size" in writable stack metadata')
973974

974-
data = request.POST.get('data')
975-
if not data:
976-
raise ValueError('Need data')
977-
data = np.array(json.loads(data)).transpose([2, 1, 0])
975+
compressed_data = request.POST.get('data')
976+
if not compressed_data:
977+
raise ValueError('Need parameter "data"')
978978

979-
data_bounds_json = request.POST.get('data_bounds')
980-
if not data_bounds_json:
979+
scale_level = request.POST.get('scale_level')
980+
if scale_level is None:
981+
raise ValueError('Need parameter "scale_level"')
982+
983+
data_bounds = get_request_list(request.POST, "data_bounds", map_fn=int)
984+
if not data_bounds:
981985
raise ValueError('Need data_bounds paramaeter')
982-
data_bounds = json.loads(data_bounds_json)
983986
if not isinstance(data_bounds, list) or len(data_bounds) != 2:
984987
raise ValueError('The data_bounds parameter needs to be a list of two lists')
985988
if not isinstance(data_bounds[0], list) or len(data_bounds[0]) != len(dataset_size):
986-
raise ValueError('The first data_bounds list needs to be a list with the correct dimensionality')
989+
raise ValueError('The first data_bounds list needs to be a list with the correct dimensionality')
987990
if not isinstance(data_bounds[1], list) or len(data_bounds[1]) != len(dataset_size):
988-
raise ValueError('The first data_bounds list needs to be a list with the correct dimensionality')
991+
raise ValueError('The first data_bounds list needs to be a list with the correct dimensionality')
989992

990-
dataset = writable_stack.metadata.get('dataset', 'volumes/main')
993+
dataset = writable_stack.metadata.get('dataset', '')
991994
dtype = writable_stack.metadata.get('dtype', 'float64')
992995
compression_name = writable_stack.metadata.get('compression', 'GZIP')
993996
compression = getattr(pyn5.CompressionType, compression_name)
994997
compression_opts = writable_stack.metadata.get('compression_opts', -1)
995998
block_size = writable_stack.metadata.get('block_size', [1, 1, 1])
996999

997-
# TODO: Try to create only if not yet present
998-
pyn5.create_dataset(writable_stack.path, dataset, dataset_size,
999-
block_size, dtype.upper())
1000+
compression = request.POST.get('compression')
1001+
if not compression or compression == 'raw':
1002+
block_data = json.loads(f'[{compressed_data}]')
1003+
elif compression == 'msgpack':
1004+
byte_list = bytes(json.loads(f'[{compressed_data}]'))
1005+
block_data = msgpack.unpackb(byte_list)
1006+
else:
1007+
raise ValueError(f'Unsupported compression: {compression}')
10001008

1001-
n5 = pyn5.open(writable_stack.path, dataset, dtype.upper(), False)
1002-
pyn5.write(n5, (np.array(data_bounds[0]), np.array(data_bounds[1])), data, dtype)
1009+
# We expect data to be a list of lists of lists, where X is the outer list,
1010+
# Y the second level and Z the last level.
1011+
data = np.array(block_data).reshape(block_size)
1012+
1013+
# TODO: Try to create only if not yet present
1014+
full_path = os.path.join(settings.MEDIA_ROOT,
1015+
settings.MEDIA_WRITABLE_STACK_SUBDIRECTORY,
1016+
writable_stack.path)
1017+
try:
1018+
pyn5.create_dataset(full_path, dataset, dataset_size,
1019+
block_size, dtype.upper())
1020+
except ValueError:
1021+
# Assume the dataset exists already
1022+
pass
1023+
1024+
dataset += f"s{scale_level}"
1025+
n5 = pyn5.open(full_path, dataset, dtype.lower(), False)
1026+
pyn5.write(n5, (np.array(data_bounds[0]), np.array(data_bounds[1]) + 1), data, dtype)
10031027

10041028
writable_stack.metadata['last_update_time'] = datetime.datetime.now(tz=datetime.timezone.utc).isoformat()
10051029
writable_stack.metadata['last_update_bounds'] = data_bounds

django/applications/catmaid/control/stack.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,20 @@ def post(self, request:HttpRequest, project_id) -> JsonResponse:
281281
# Create N5 skeleton manually, because the cloud-volume files don't seem
282282
# to be compatible.
283283
root_attributes = {
284-
"n5": "2.1.3"
284+
"n5": "2.1.3",
285+
"pixelResolution": {
286+
"unit": "um",
287+
"dimensions": [
288+
metadata['resolution'][0],
289+
metadata['resolution'][1],
290+
metadata['resolution'][2],
291+
]
292+
},
293+
"downsamplingFactors": [[
294+
scale_level.x,
295+
scale_level.y,
296+
scale_level.z,
297+
] for scale_level in stack.downsample_factors]
285298
}
286299
scale_attributes = []
287300
for n, scale_level in enumerate(stack.downsample_factors):

django/applications/catmaid/static/js/image-block.js

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -124,29 +124,6 @@
124124
{zoomLevel, x, y, z, block});
125125
}
126126

127-
writeBlock(projectId, writableStackId, zoomLevel, x, y, z, block, format='n5') {
128-
if (format !== 'n5') {
129-
return Promise.reject(new CATMAID.Error('Not implemented'));
130-
}
131-
132-
// First, write to cache to update UI
133-
this.setBlock(zoomLevel, x, y, z, block);
134-
135-
// Second, write to back-end asynchronously
136-
// TODO: This should be done with the help of rate limitng to only send
137-
// changes every 3 seconds or so.
138-
/*
139-
CATMAID.fetch(`${projectId}/writable-stacks/${writableStackId}/write-block`, 'POST', {
140-
// TODO: zip/lzw block, esp. useful for new blocks
141-
data: block.tolist(),
142-
data_bounds: [[x, y, z], [x, y, z]],
143-
})
144-
.then(response => {
145-
return Promise.reject('Not yet implemented');
146-
});
147-
*/
148-
}
149-
150127
evictAll() {
151128
this._cache.evictAll();
152129
}

django/applications/catmaid/static/js/layers/painting-layer.js

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// The stack viewer is needed by the PixiLayer constructor
1515
this.stackViewer = stackViewer;
1616
this.dataLayer = dataLayer;
17+
this.paintingTool = tool;
1718
//CATMAID.PixiLayer.call(this);
1819

1920
this.view = document.createElement('canvas');
@@ -186,8 +187,9 @@
186187

187188
// Draw a circle into the data layer's cache
188189
if (!(this.dataLayer instanceof CATMAID.PixiImageBlockLayer)) {
189-
CATMAID.warn('Painting data layer has wrong layer type');
190-
return;
190+
const msg = 'Painting data layer has wrong layer type';
191+
CATMAID.warn(msg);
192+
return Promise.reject(CATMAID.ValueError(msg));
191193
}
192194

193195
// Convert screen coordinates to voxel coordinates. Convert voxel
@@ -244,13 +246,16 @@
244246
Math.floor(voxelPosZ / blockSize[2]),
245247
];
246248

249+
// FIXME: This should be handled more nicely
250+
if (blockCoord[0] < 0 || blockCoord[1] < 0) {
251+
return Promise.resolve();
252+
}
253+
247254
// Assume block is in cache
248-
this.dataLayer._readBlock(this.stackViewer.s, ...blockCoord)
255+
return this.dataLayer._readBlock(this.stackViewer.s, ...blockCoord)
249256
.then((block) => {
250257
if (!block) {
251-
// No block found in cache and on server
252-
CATMAID.msg('success', 'A new block is created, because no existing block is found');
253-
// TODO: Init new block
258+
// No block found in cache and on server, create new block
254259
const backgroundValue = 0;
255260
block = nj.zeros(blockSize, dataType);
256261
block.assign(backgroundValue, false);
@@ -272,17 +277,70 @@
272277
for (let x = -halfBrushSize; x <= halfBrushSize; x++) {
273278
for (let y = -halfBrushSize; y <= halfBrushSize; y++) {
274279
const sqDist = x * x + y * y;
275-
if (sqDist <= sqBrushSize) {
280+
if (sqDist <= sqBrushSize && (relVoxelPos[0] + x) >= 0 && (relVoxelPos[1] + y) >= 0) {
276281
block.set(relVoxelPos[0] + x, relVoxelPos[1] + y, relVoxelPos[2], this.value);
277282
}
278283
}
279284
}
280285

281-
// Write block back to server. This is done asynchronously in regular
282-
// intervals (if changes happen).
283-
return this.dataLayer.writeBlock(project.id, this.stackViewer.s, zoom, ...blockCoord, block);
284-
})
285-
.catch(CATMAID.handleError);
286+
const projectId = project.id;
287+
288+
// Write block to data layer cache, to display it quickly.
289+
// TODO: Pin unsaved changed blocks in cache, because cache is used
290+
// below to get latest actual block data.
291+
this.dataLayer.writeBlock(projectId, zoom, ...blockCoord, block);
292+
293+
const activeWritableStackId = this.paintingTool.getActiveWritableStack();
294+
295+
if (!activeWritableStackId) {
296+
CATMAID.warn('No active writable stack selected');
297+
return;
298+
}
299+
300+
// Second, write to back-end asynchronously. De-duplicate request to write
301+
// data back and queue the write request. For this, always use latest
302+
// block data available. This is done instead of regular write
303+
// operations (like every minute). # TODO: Test if this is reasonable.
304+
const url = `${projectId}/writable-stacks/${activeWritableStackId}/write-block`;
305+
return this.paintingTool.writeDeduper.dedup(
306+
`${url}-${blockCoord.join('-')}`,
307+
() => {
308+
const getMostRecentBlock = this.dataLayer._readBlock(this.stackViewer.s, ...blockCoord);
309+
310+
return getMostRecentBlock.then(mostRecentBlock => {
311+
return CATMAID.fetch({
312+
url: url,
313+
method: 'POST',
314+
data: {
315+
scale_level: 0,
316+
//compression: 'raw',
317+
//data: mostRecentBlock.tolist().join(','),
318+
compression: 'msgpack',
319+
// msgpack data is sent as string for now.
320+
// TODO: Send all parameters as single JSON.
321+
data: msgpack.encode(mostRecentBlock.tolist()).join(','),
322+
data_bounds: [
323+
[
324+
blockCoord[0] * blockSize[0],
325+
blockCoord[1] * blockSize[1],
326+
blockCoord[2] * blockSize[2],
327+
],
328+
[
329+
(blockCoord[0] + 1) * blockSize[0] - 1,
330+
(blockCoord[1] + 1) * blockSize[1] - 1,
331+
(blockCoord[2] + 1) * blockSize[2] - 1,
332+
]
333+
],
334+
},
335+
api: this.api,
336+
})
337+
.then(response => {
338+
console.log(response);
339+
});
340+
});
341+
})
342+
.catch(CATMAID.handleError);
343+
});
286344
};
287345

288346
// Export layer into CATMAID namespace

django/applications/catmaid/static/js/layers/pixi-image-block-layer.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,12 @@
385385
});
386386
}
387387

388-
writeBlock(projectId, writableStackId, zoomLevel, x, y, z, block, format='n5') {
388+
writeBlock(projectId, zoomLevel, x, y, z, block) {
389389
let blockCoord = CATMAID.tools.permute([x, y, z], this.recipDimPerm);
390-
return this._blockCache.writeBlock(projectId, writableStackId, zoomLevel, ...blockCoord, block, format);
390+
// First, write to cache to update UI
391+
// TODO: Introduce pinning so that this block isn't removed until it is
392+
// written to server?
393+
this._blockCache.setBlock(zoomLevel, ...blockCoord, block);
391394
}
392395

393396
_sliceBlock(block, blockZ) {

django/applications/catmaid/static/js/tools/painting-tool.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
let isDrawing = false;
8383
// All currently available writable stacks for the active stack viewer.
8484
let writableStacks = new Map();
85+
// A central deduplicator is used for writing operations
86+
this.writeDeduper = new CATMAID.CoalescingPromiseDeduplicator();
8587

8688
const actions = [];
8789

@@ -338,6 +340,13 @@
338340
return activeStackViewer;
339341
};
340342

343+
/**
344+
* Return the presently active writable stack (if any).
345+
*/
346+
this.getActiveWritableStack = function() {
347+
return activeWritableStack;
348+
};
349+
341350
/**
342351
* Display both project and stack space center coordinates in the status
343352
* bar. In case no active stack is available, only project coordinates are

0 commit comments

Comments
 (0)