ngsidekick 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ngsidekick/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ neuroglancer-related utility functions
3
+ """
4
+ from importlib.metadata import version, PackageNotFoundError
5
+
6
+ try:
7
+ __version__ = version("ngsidekick")
8
+ except PackageNotFoundError:
9
+ # Package is not installed
10
+ __version__ = "unknown"
11
+
12
+ from .storage import download_ngstate, upload_ngstate, upload_ngstates, upload_json, upload_to_bucket, make_bucket_public
13
+ from .util import parse_nglink, format_nglink, layer_dict, layer_state
14
+ from .annotations.local import (
15
+ local_annotation_json, extract_local_annotations,
16
+
17
+ # deprecated names
18
+ extract_annotations, annotation_layer_json, point_annotation_layer_json
19
+ )
20
+ from .annotations.precomputed import write_precomputed_annotations
21
+ from .segmentprops import segment_properties_json, segment_properties_to_dataframe
22
+ from .segmentcolors import hex_string_from_segment_id
File without changes
@@ -0,0 +1,405 @@
1
+ import copy
2
+ from textwrap import indent, dedent
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from ..util import parse_nglink
8
+ from .util import annotation_property_specs
9
+
10
+ def extract_local_annotations(link, *, link_index=None, user=None, visible_only=False):
11
+ """
12
+ Extract local://annotations data from a neuroglancer link.
13
+ The annotation coordinates (point, pointA, pointB, radii) are extracted
14
+ into separate columns, named x,y,z/xa,ya,za/xb,yb,zb/rx,ry,rz
15
+ (consistent with local_annotation_json() in this file).
16
+
17
+ Args:
18
+ link:
19
+ Either a neuroglancer JSON state (dict), or a neuroglancer link with embedded state,
20
+ or a neuroglancer link that references a JSON state file, such as
21
+ https://neuroglancer-demo.appspot.com/#!gs://flyem-views/hemibrain/v1.2/base.json
22
+
23
+ link_index:
24
+ Deprecated.
25
+ Adds a column named 'link_index' populated with the provided value in all rows.
26
+
27
+ user:
28
+ Deprecated.
29
+ Adds a column named 'user' populated with the provided value in all rows.
30
+
31
+ visible_only:
32
+ If True, do not extract annotations from layers that are not currently
33
+ visible in the given link state.
34
+
35
+ Returns:
36
+ DataFrame
37
+ """
38
+ if isinstance(link, str):
39
+ link = parse_nglink(link)
40
+ annotation_layers = [layer for layer in link['layers'] if layer['type'] == "annotation"]
41
+
42
+ dfs = []
43
+ for layer in annotation_layers:
44
+ if visible_only and (layer.get('archived', False) or not layer.get('visible', True)):
45
+ continue
46
+
47
+ try:
48
+ _df = pd.DataFrame(layer['annotations'])
49
+ except KeyError as e:
50
+ continue
51
+ _df['layer'] = layer['name']
52
+ dfs.append(_df)
53
+
54
+ df = pd.concat(dfs, ignore_index=True)
55
+ if 'point' in df.columns:
56
+ rows = df['point'].notnull()
57
+ df.loc[rows, [*'xyz']] = df.loc[rows, 'point'].tolist()
58
+ if 'pointA' in df.columns:
59
+ rows = df['pointA'].notnull()
60
+ df.loc[rows, ['xa', 'ya', 'za']] = df.loc[rows, 'pointA'].tolist()
61
+ if 'pointB' in df.columns:
62
+ rows = df['pointB'].notnull()
63
+ df.loc[rows, ['xb', 'yb', 'zb']] = df.loc[rows, 'pointB'].tolist()
64
+ if 'radii' in df.columns:
65
+ rows = df['radii'].notnull()
66
+ df.loc[rows, ['rx', 'ry', 'rz']] = df.loc[rows, 'radii'].tolist()
67
+
68
+ # Convert to int if possible.
69
+ for col in [*'xyz', 'xa', 'ya', 'za', 'xb', 'yb', 'zb', 'rx', 'ry', 'rz', 'radii']:
70
+ if col in df and df[col].notnull().all() and not (df[col] % 1).any():
71
+ df[col] = df[col].astype(np.int64)
72
+
73
+ if link_index is not None:
74
+ df['link_index'] = link_index
75
+ if user is not None:
76
+ df['user'] = user
77
+
78
+ df = df.drop(columns=['point', 'pointA', 'pointB', 'radii'], errors='ignore')
79
+ cols = ['link_index', 'user', 'layer', 'type', *'xyz', 'rx', 'ry', 'rz', 'xa', 'ya', 'za', 'xb', 'yb', 'zb', 'id', 'description']
80
+ df = df[[c for c in cols if c in df.columns]]
81
+ return df
82
+
83
+
84
+ # legacy name
85
+ extract_annotations = extract_local_annotations
86
+
87
+ # Tip: Here's a nice repo with lots of colormaps implemented in GLSL.
88
+ # https://github.com/kbinani/colormap-shaders
89
+ SHADER_FMT = dedent("""\
90
+ void main() {{
91
+ setColor(defaultColor());
92
+ setPointMarkerSize({size:.1f});
93
+ }}
94
+ """)
95
+
96
+ LOCAL_ANNOTATION_JSON = {
97
+ "name": "annotations",
98
+ "type": "annotation",
99
+ "source": {
100
+ "url": "local://annotations",
101
+ "transform": {
102
+ "outputDimensions": {
103
+ "x": [
104
+ 8e-09,
105
+ "m"
106
+ ],
107
+ "y": [
108
+ 8e-09,
109
+ "m"
110
+ ],
111
+ "z": [
112
+ 8e-09,
113
+ "m"
114
+ ]
115
+ }
116
+ }
117
+ },
118
+ "tool": "annotatePoint",
119
+ "shader": "\nvoid main() {\n setColor(defaultColor());\n setPointMarkerSize(8.0);\n}\n",
120
+ "panels": [
121
+ {
122
+ "row": 1,
123
+ "flex": 1.22,
124
+ "tab": "annotations"
125
+ }
126
+ ],
127
+ "annotations": [
128
+ # {
129
+ # "point": [23367, 35249, 68171],
130
+ # "type": "point",
131
+ # "id": "149909688276769607",
132
+ # "description": "soma"
133
+ # },
134
+ ]
135
+ }
136
+
137
+
138
+ def local_annotation_json(df, name="annotations", color="#ffff00", size=8.0, linkedSegmentationLayer=None,
139
+ show_panel=False, properties=[], shader=None, res_nm_xyz=(8,8,8)):
140
+ """
141
+ Construct the JSON data for a neuroglancer local annotations layer.
142
+ This does not result in a complete neuroglancer link; it results in something
143
+ that can be added to the layers list in the neuroglancer viewer JSON state.
144
+
145
+
146
+ Args:
147
+ df:
148
+ DataFrame containing the annotation data.
149
+ Which columns you must provide depends on which annotation type(s) you want to display.
150
+
151
+ - For point annotations, provide ['x', 'y', 'z']
152
+ - For line annotations or axis_aligned_bounding_box annotations,
153
+ provide ['xa', 'ya', 'za', 'xb', 'yb', 'zb']
154
+ - For ellipsoid annotations, provide ['x', 'y', 'z', 'rx', 'ry', 'rz']
155
+ for the center point and radii.
156
+
157
+ You may also provide a column 'type' to explicitly set the annotation type.
158
+ In some cases, 'type' isn't needed since annotation type can be inferred from the
159
+ columns you provided. But in the case of line and box annotations, the input
160
+ columns are the same, so you must provide a 'type' column.
161
+
162
+ You may also provide additional columns to use as annotation properties,
163
+ in which case they should be listed in the 'properties' argument. (See below.)
164
+
165
+ name:
166
+ The name of the annotation layer
167
+
168
+ color:
169
+ The default color for annotations, which can be overridden by the annotation shader.
170
+
171
+ size:
172
+ The annotation size to hard-code into the default annotation shader used by this function.
173
+ (Only used for points and line endpoints.)
174
+
175
+ linkedSegmentationLayer:
176
+ If the annotations should be associated with another layer in the view,
177
+ this specifies the name of that layer.
178
+ This function sets the 'filterBySegmentation' key to hide annotations from non-selected segments.
179
+ If you are providing a linkedSegmentationLayer, your dataframe should contain
180
+ a 'segments' column to indicate which segments are associated with each annotation.
181
+
182
+ show_panel:
183
+ If True, the selection panel will be visible in the side bar by default.
184
+
185
+ properties:
186
+ The list column names to use as annotation properties.
187
+ Properties are visible in the selection panel when an annotation is selected,
188
+ and they can also be used in annotation shaders via special functions neuroglancer
189
+ defines for each property. For example, for a property named 'confidence',
190
+ you could write setPointMarkerSize(prop_confidence()) in your annotation shader.
191
+
192
+ This function supports annotation color proprties via strings (e.g. '#ffffff') and
193
+ also annotation 'enum' properties if you pass them via pandas categorical columns.
194
+
195
+ By default, the annotation IDs are the same as the column names and the annotation types are inferred.
196
+ You can override the property 'spec' by supplying a dict-of-dicts here instead of a list of columns:
197
+
198
+ properties={
199
+ "my_column": {
200
+ "id": "my property",
201
+ "description": "This is my annotation property.",
202
+ "type": "float32",
203
+ "enum_values": [0.0, 0.5, 1.0],
204
+ "enum_labels": ["nothing", "something", "everything"],
205
+ },
206
+ "another_column: {...}
207
+ }
208
+
209
+ Returns:
210
+ dict (JSON data)
211
+ """
212
+ df = _standardize_annotation_dataframe(df)
213
+ if isinstance(properties, str):
214
+ properties = [properties]
215
+
216
+ data = copy.deepcopy(LOCAL_ANNOTATION_JSON)
217
+ res_m = (np.array(res_nm_xyz) * 1e-9).tolist()
218
+ output_dim = {k: [r, 'm'] for k,r in zip('xyz', res_m)}
219
+ data['source']['transform']['outputDimensions'] = output_dim
220
+ data['name'] = name
221
+ data['annotationColor'] = color
222
+
223
+ if shader:
224
+ data['shader'] = shader
225
+ else:
226
+ data['shader'] = _default_shader(df['type'].unique(), size)
227
+
228
+ if linkedSegmentationLayer:
229
+ data['linkedSegmentationLayer'] = linkedSegmentationLayer
230
+ data['filterBySegmentation'] = ['segments']
231
+
232
+ if not show_panel:
233
+ del data['panels']
234
+
235
+ prop_specs = annotation_property_specs(df, properties)
236
+ if prop_specs:
237
+ data['annotationProperties'] = prop_specs
238
+ properties = [p['id'] for p in prop_specs]
239
+
240
+ data['annotations'].clear()
241
+ data['annotations'] = _annotation_list_json(
242
+ df, linkedSegmentationLayer, properties
243
+ )
244
+ return data
245
+
246
+
247
+ # Deprecated name (now supports more than just points)
248
+ point_annotation_layer_json = local_annotation_json
249
+
250
+ # Deprecated name
251
+ annotation_layer_json = local_annotation_json
252
+
253
+
254
+ def _annotation_list_json(df, linkedSegmentationLayer, properties):
255
+ """
256
+ Helper for local_annotation_json().
257
+
258
+ Generate the list of annotations for an annotation layer,
259
+ assuming the input dataframe has already been pre-conditioned.
260
+ """
261
+ # Replace categoricals with their integer codes.
262
+ # The corresponding enum_labels are already stored in the property specs
263
+ for col in properties:
264
+ if df[col].dtype == "category":
265
+ df[col] = df[col].cat.codes
266
+
267
+ annotations = []
268
+ for row in df.itertuples():
269
+ entry = {}
270
+ entry['type'] = row.type
271
+ entry['id'] = row.id
272
+ if 'description' in df.columns:
273
+ entry['description'] = row.description
274
+
275
+ if row.type == 'point':
276
+ entry['point'] = [row.x, row.y, row.z]
277
+ elif row.type in ('line', 'axis_aligned_bounding_box'):
278
+ entry['pointA'] = [row.xa, row.ya, row.za]
279
+ entry['pointB'] = [row.xb, row.yb, row.zb]
280
+ elif row.type == 'ellipsoid':
281
+ entry['point'] = [row.x, row.y, row.z]
282
+ entry['radii'] = [row.rx, row.ry, row.rz]
283
+ else:
284
+ raise RuntimeError(f'Invalid annotation type: {row.type}')
285
+
286
+ if linkedSegmentationLayer and 'segments' in df.columns:
287
+ segments = row.segments
288
+ if not hasattr(segments, '__len__'):
289
+ segments = [segments]
290
+ segments = [str(int(s)) for s in segments]
291
+ entry['segments'] = segments
292
+
293
+ if properties:
294
+ entry['props'] = [getattr(row, prop) for prop in properties]
295
+
296
+ annotations.append(entry)
297
+ return annotations
298
+
299
+
300
+ def _standardize_annotation_dataframe(df):
301
+ """
302
+ Helper for local_annotation_json().
303
+ Add empty columns as needed until the dataframe
304
+ has all possible annotation columns.
305
+
306
+ Also populate the 'type' column with inferred
307
+ annotation types based on the columns the user DID provide.
308
+ """
309
+ df = df.copy()
310
+ id_cols = [*'xyz', 'xa', 'ya', 'za', 'xb', 'yb', 'zb', 'rx', 'ry', 'rz', 'type']
311
+ for col in id_cols:
312
+ if col not in df.columns:
313
+ df[col] = np.nan
314
+
315
+ if 'id' in df.columns:
316
+ df['id'] = df['id'].astype(str)
317
+ else:
318
+ df['id'] = [
319
+ str(hex(abs(hash(tuple(x)))))
320
+ for x in df[id_cols].values.tolist()
321
+ ]
322
+
323
+ is_point_or_ellipsoid = df[[*'xyz']].notnull().all(axis=1)
324
+ is_line_or_box = df[['xa', 'ya', 'za', 'xb', 'yb', 'zb']].notnull().all(axis=1)
325
+ assert (is_point_or_ellipsoid ^ is_line_or_box).all(), \
326
+ "You must supply either [x,y,z] or [xa,ya,za,xb,yb,zb] for every row (and not both)."
327
+
328
+ df['type'] = df['type'].fillna(
329
+ df['rx'].isnull().map({
330
+ True: None,
331
+ False: 'ellipsoid'
332
+ })
333
+ )
334
+
335
+ # We have no way of choosing between 'line' and 'axis_aligned_bounding_box'
336
+ # unless the user provides the 'type' explicitly.
337
+ # We default to 'axis_aligned_bounding_box' because it's harder to type than 'line' :-)
338
+ df['type'] = df['type'].fillna(
339
+ df['x'].isnull().map({
340
+ True: 'axis_aligned_bounding_box',
341
+ False: 'point'
342
+ })
343
+ )
344
+ return df
345
+
346
+
347
+ def _default_shader(annotation_types, default_size):
348
+ """
349
+ Create a default annotation shader that is pre-populated with
350
+ the annotation API functions so you don't have to look them up.
351
+ """
352
+ shader_body = ""
353
+ if 'point' in annotation_types:
354
+ # Note:
355
+ # In older versions of neuroglancer,
356
+ # setPointMarkerColor(vec3) doesn't exist yet,
357
+ # so we must use vec4.
358
+ # https://github.com/google/neuroglancer/pull/475
359
+ shader_body += dedent(f"""\
360
+ //
361
+ // Point Marker API
362
+ //
363
+ setPointMarkerSize({default_size});
364
+ setPointMarkerColor(vec4(defaultColor(), 1.0));
365
+ setPointMarkerBorderWidth(1.0);
366
+ setPointMarkerBorderColor(defaultColor());
367
+ """)
368
+
369
+ if 'line' in annotation_types:
370
+ shader_body += dedent(f"""\
371
+ //
372
+ // Line API
373
+ //
374
+ setLineColor(defaultColor());
375
+ setEndpointMarkerSize({default_size}, {default_size});
376
+ setEndpointMarkerColor(defaultColor(), defaultColor());
377
+ setEndpointMarkerBorderWidth(1.0, 1.0);
378
+ setEndpointMarkerBorderColor(defaultColor(), defaultColor());
379
+ """)
380
+
381
+ if 'axis_aligned_bounding_box' in annotation_types:
382
+ shader_body += dedent("""\
383
+ //
384
+ // Bounding Box API
385
+ //
386
+ setBoundingBoxBorderWidth(1.0);
387
+ setBoundingBoxBorderColor(defaultColor());
388
+ setBoundingBoxFillColor(vec4(defaultColor(), 0.5));
389
+ """)
390
+
391
+ if 'ellipsoid' in annotation_types:
392
+ shader_body += dedent("""\
393
+ //
394
+ // Ellipsoid API
395
+ //
396
+ setEllipsoidFillColor(defaultColor());
397
+ """)
398
+
399
+ shader_main = dedent(f"""\
400
+ void main() {{
401
+ {indent(shader_body, ' '*12)[12:]}\
402
+ }}
403
+ """)
404
+
405
+ return shader_main
@@ -0,0 +1 @@
1
+ from .precomputed import write_precomputed_annotations, TableHandle
@@ -0,0 +1,34 @@
1
+ import logging
2
+ from ._write_buffers import _write_buffers
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ def _write_annotations_by_id(df, output_dir, write_sharded):
8
+ """
9
+ Write the annotations to the "Annotation ID Index", a subdirectory of output_dir.
10
+
11
+ Args:
12
+ df:
13
+ DataFrame with columns ['id_buf', 'ann_buf'].
14
+
15
+ output_dir:
16
+ Directory to write the annotations to.
17
+ A single subdirectory named 'by_id' will be created in output_dir.
18
+
19
+ write_sharded:
20
+ Whether to write the annotations in sharded format.
21
+
22
+ Returns:
23
+ JSON metadata to be written under the 'by_id' key in the top-level 'info' file.
24
+ Currently, this is always {"key": "by_id"}
25
+ """
26
+ if 'rel_buf' in df.columns:
27
+ ann_bufs = df['ann_buf'] + df['rel_buf']
28
+ else:
29
+ ann_bufs = df['ann_buf']
30
+
31
+ logger.info("Writing annotations to 'by_id' index")
32
+ metadata = _write_buffers(ann_bufs, output_dir, "by_id", write_sharded)
33
+ return metadata
34
+
@@ -0,0 +1,161 @@
1
+ import logging
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ from ._util import _encode_uint64_series, TableHandle
6
+ from ._write_buffers import _write_buffers
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def _encode_relationships(df, relationships):
12
+ """
13
+ For each annotation in the given dataframe, encode the related IDs
14
+ for all relationships into a buffer according to the neuroglancer spec.
15
+
16
+ Returns:
17
+ pd.Series of dtype=object, containing one buffer for each annotation.
18
+ """
19
+ if not relationships:
20
+ return None
21
+
22
+ encoded_relationships = {}
23
+ for rel_col in relationships:
24
+ encoded_relationships[rel_col] = _encode_related_ids(df[rel_col])
25
+
26
+ # Concatenate buffers on each row.
27
+ # Note:
28
+ # Using sum() is O(R^2) in the number of relationships R, but we generally
29
+ # expect few relationships, so this is faster than df.apply(b''.join, axis=1),
30
+ # since .sum() uses a single C call whereas using b''.join() would
31
+ # use many Python calls.
32
+ rel_bufs = pd.DataFrame(encoded_relationships, index=df.index).sum(axis=1)
33
+ return rel_bufs
34
+
35
+
36
+ def _encode_related_ids(related_ids):
37
+ """
38
+ Given a Series containing lists of IDs, encode each list of IDs
39
+ in the format neuroglancer expects for each relationship in an
40
+ annotation.
41
+
42
+ Each item in related_ids is a list, which gets encoded as
43
+ <count><id_1><id_2><id_3>..., where <count> is uint32 and
44
+ <id_1><id_2><id_3>... are each uint64.
45
+
46
+ Args:
47
+ related_ids:
48
+ A Series of length N and dtype=object, containing lists of IDs.
49
+ As a special convenience in the case where every row contains
50
+ exactly one ID, you may pass a series with dtype=auint64,
51
+ which will be interpreted as if each entry were a list of length 1.
52
+ (In this case, the implementation is slightly faster than in the general case.)
53
+
54
+ Returns:
55
+ A numpy array with N entries, where each entry is a buffer as shown above.
56
+ """
57
+ # Special case if the relationship contains only a single ID for each annotation.
58
+ if np.issubdtype(related_ids.dtype, np.integer):
59
+ buf = (
60
+ pd.DataFrame({'count': np.uint32(1), 'id': related_ids})
61
+ .astype({'count': np.uint32, 'id': np.uint64}, copy=False)
62
+ .to_records(index=False)
63
+ .tobytes()
64
+ )
65
+
66
+ encoded_ids = [
67
+ buf[i*12:(i+1)*12]
68
+ for i in range(len(related_ids))
69
+ ]
70
+ return np.array(encoded_ids, dtype=object)
71
+
72
+ # Otherwise, the relationship contains lists.
73
+ else:
74
+ assert related_ids.dtype == object
75
+ counts = related_ids.map(len).to_numpy(np.uint32)
76
+ offsets = 8 * np.cumulative_sum(counts, include_initial=True)
77
+
78
+ ids_buf = np.concatenate(related_ids, dtype=np.uint64).tobytes()
79
+ counts_buf = counts.tobytes()
80
+
81
+ encoded_ids = [
82
+ counts_buf[i*4:(i+1)*4] + ids_buf[start:end]
83
+ for i, (start, end) in enumerate(zip(offsets[:-1], offsets[1:]))
84
+ ]
85
+ return np.array(encoded_ids, dtype=object)
86
+
87
+
88
+ def _write_annotations_by_relationships(df_handle: TableHandle, relationships, output_dir, write_sharded):
89
+ """
90
+ Write the annotations to a "Related Object ID Index" for each relationship.
91
+ Each relationship is written to a separate subdirectory of output_dir.
92
+
93
+ Args:
94
+ df_handle:
95
+ TableHandle holding a DataFrame with columns ['id_buf', 'ann_buf', *relationships].
96
+ The handle's reference will be unset before this function returns.
97
+
98
+ relationships:
99
+ List of relationship column names.
100
+
101
+ output_dir:
102
+ Directory to write the annotations to.
103
+ Each relationship is written to a separate subdirectory of output_dir.
104
+
105
+ write_sharded:
106
+ Whether to write the annotations in sharded format.
107
+
108
+ Returns:
109
+ JSON metadata to be written under the 'relationships' key in the top-level 'info' file,
110
+ consisting of a list of JSON objects (one for each relationship).
111
+ """
112
+ handles = {
113
+ r: TableHandle(df_handle.df) for r in relationships
114
+ }
115
+ df_handle.df = None
116
+
117
+ by_rel_metadata = []
118
+ for relationship, df_handle in handles.items():
119
+ metadata = _write_annotations_by_relationship(
120
+ df_handle,
121
+ relationship,
122
+ output_dir,
123
+ write_sharded
124
+ )
125
+ by_rel_metadata.append(metadata)
126
+
127
+ return by_rel_metadata
128
+
129
+
130
+ def _write_annotations_by_relationship(df_handle: TableHandle, relationship, output_dir, write_sharded):
131
+ """
132
+ Write the annotations to a "Related Object ID Index" for a single relationship.
133
+
134
+ Returns:
135
+ JSON metadata for the relationship, including the key and sharding spec if applicable.
136
+ """
137
+ logger.info(f"Grouping annotations by relationship {relationship}")
138
+ bufs_by_segment = (
139
+ df_handle.df[['id_buf', 'ann_buf', relationship]]
140
+ .dropna(subset=relationship)
141
+ .explode(relationship)
142
+ .groupby(relationship, sort=False)
143
+ # Use b''.join() instead of 'sum' to avoid O(N^2) performance for large groups.
144
+ .agg({'id_buf': ['count', b''.join], 'ann_buf': b''.join})
145
+ )
146
+ df_handle.df = None
147
+
148
+ logger.info(f"Combining annotation and ID buffers for relationship '{relationship}'")
149
+ bufs_by_segment.columns = ['count', 'id_buf', 'ann_buf']
150
+ bufs_by_segment['count_buf'] = _encode_uint64_series(bufs_by_segment['count'])
151
+ bufs_by_segment['combined_buf'] = bufs_by_segment[['count_buf', 'ann_buf', 'id_buf']].sum(axis=1)
152
+
153
+ logger.info(f"Writing annotations to 'by_rel_{relationship}' index")
154
+ metadata = _write_buffers(
155
+ bufs_by_segment['combined_buf'],
156
+ output_dir,
157
+ f"by_rel_{relationship}",
158
+ write_sharded
159
+ )
160
+ metadata['id'] = relationship
161
+ return metadata