fusion-tools 2.3.2__tar.gz → 2.4.0__tar.gz
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.
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/PKG-INFO +1 -1
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/setup.py +1 -1
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/maps.py +112 -43
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/tools.py +39 -9
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/feature_extraction.py +15 -15
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/tileserver.py +8 -5
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/shapes.py +1 -1
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/stats.py +0 -14
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/components.py +30 -5
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/PKG-INFO +1 -1
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/SOURCES.txt +2 -0
- fusion_tools-2.4.0/tests/test_extract_recursive.py +62 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_feature_extraction.py +4 -0
- fusion_tools-2.4.0/tests/test_process.py +395 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/LICENSE +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/README.md +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/pyproject.toml +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/setup.cfg +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/__init__.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/__init__.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/segmentation.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/dataset.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/handler.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/__init__.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/omics.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/__init__.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/vis_utils.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/dependency_links.txt +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/requires.txt +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/top_level.txt +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_classification_model.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_custom_component.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_dataset_classes.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_extract_nested_prop.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_feature_annotation.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_generate_property_dict.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_get_gene_info.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_hra_viewer.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_image_overlay.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_model_train.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_multiframe_rgb.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_multiframeslidemap.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_property_plotter.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_read_anndata.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_remote_slide_annotations.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_remote_tileserver.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_segmentation.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_slide_annotations.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_spatial_aggregation.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_tile_server.py +0 -0
- {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_visualization_component.py +0 -0
|
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
|
|
|
5
5
|
|
|
6
6
|
setuptools.setup(
|
|
7
7
|
name="fusion-tools",
|
|
8
|
-
version="2.
|
|
8
|
+
version="2.4.0",
|
|
9
9
|
author="Sam Border",
|
|
10
10
|
author_email="sam.border2256@gmail.com",
|
|
11
11
|
description="Modular visualization and analysis dashboard creation for high-resolution microscopy images",
|
|
@@ -252,7 +252,20 @@ class SlideMap(MapComponent):
|
|
|
252
252
|
),
|
|
253
253
|
dl.LayersControl(
|
|
254
254
|
id = {'type': 'map-layers-control','index': 0},
|
|
255
|
-
children = [
|
|
255
|
+
children = [
|
|
256
|
+
html.Div(
|
|
257
|
+
id = {'type': 'map-initial-annotations','index': 0},
|
|
258
|
+
children = []
|
|
259
|
+
),
|
|
260
|
+
html.Div(
|
|
261
|
+
id = {'type': 'map-manual-rois','index': 0},
|
|
262
|
+
children = []
|
|
263
|
+
),
|
|
264
|
+
html.Div(
|
|
265
|
+
id = {'type': 'map-generated-rois','index': 0},
|
|
266
|
+
children = []
|
|
267
|
+
)
|
|
268
|
+
]
|
|
256
269
|
),
|
|
257
270
|
dl.EasyButton(
|
|
258
271
|
icon = 'fa-solid fa-arrows-to-dot',
|
|
@@ -356,11 +369,14 @@ class SlideMap(MapComponent):
|
|
|
356
369
|
var overlaySubProps = overlayProp.name.split(" --> ");
|
|
357
370
|
var prop_dict = feature.properties;
|
|
358
371
|
for (let i = 0; i < overlaySubProps.length; i++) {
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
372
|
+
if (prop_dict==prop_dict && prop_dict!=null && typeof prop_dict === 'object') {
|
|
373
|
+
if (overlaySubProps[i] in prop_dict) {
|
|
374
|
+
var prop_dict = prop_dict[overlaySubProps[i]];
|
|
375
|
+
var overlayVal = prop_dict;
|
|
376
|
+
} else {
|
|
377
|
+
prop_dict = Number.Nan;
|
|
378
|
+
var overlayVal = Number.Nan;
|
|
379
|
+
}
|
|
364
380
|
}
|
|
365
381
|
}
|
|
366
382
|
} else {
|
|
@@ -410,16 +426,19 @@ class SlideMap(MapComponent):
|
|
|
410
426
|
var filterSubProps = filter.name.split(" --> ");
|
|
411
427
|
var prop_dict = feature.properties;
|
|
412
428
|
for (let j = 0; j < filterSubProps.length; j++) {
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
429
|
+
if (prop_dict==prop_dict && prop_dict!=null && typeof prop_dict==='object') {
|
|
430
|
+
if (filterSubProps[j] in prop_dict) {
|
|
431
|
+
var prop_dict = prop_dict[filterSubProps[j]];
|
|
432
|
+
var testVal = prop_dict;
|
|
433
|
+
} else {
|
|
434
|
+
prop_dict = Number.Nan;
|
|
435
|
+
returnFeature = returnFeature & false;
|
|
436
|
+
}
|
|
418
437
|
}
|
|
419
438
|
}
|
|
420
439
|
}
|
|
421
440
|
|
|
422
|
-
if (filter.range) {
|
|
441
|
+
if (filter.range && returnFeature) {
|
|
423
442
|
if (typeof filter.range[0]==='number') {
|
|
424
443
|
if (testVal < filter.range[0]) {
|
|
425
444
|
returnFeature = returnFeature & false;
|
|
@@ -471,7 +490,9 @@ class SlideMap(MapComponent):
|
|
|
471
490
|
Input({'type': 'slide-select-drop','index': ALL},'value')
|
|
472
491
|
],
|
|
473
492
|
[
|
|
474
|
-
Output({'type': 'map-
|
|
493
|
+
Output({'type': 'map-initial-annotations','index': MATCH},'children'),
|
|
494
|
+
Output({'type': 'map-manual-rois','index': MATCH},'children'),
|
|
495
|
+
Output({'type': 'map-generated-rois','index': MATCH},'children'),
|
|
475
496
|
Output({'type': 'map-annotations-store','index':MATCH},'data'),
|
|
476
497
|
Output({'type': 'map-tile-layer-holder','index': MATCH},'children'),
|
|
477
498
|
Output({'type': 'map-slide-information','index': MATCH},'data')
|
|
@@ -498,7 +519,7 @@ class SlideMap(MapComponent):
|
|
|
498
519
|
Input({'type':'upload-shape-data','index': ALL},'contents')
|
|
499
520
|
],
|
|
500
521
|
[
|
|
501
|
-
Output({'type': 'map-
|
|
522
|
+
Output({'type': 'map-manual-rois','index': MATCH},'children'),
|
|
502
523
|
Output({'type': 'map-annotations-store','index': MATCH},'data')
|
|
503
524
|
],
|
|
504
525
|
[
|
|
@@ -506,6 +527,8 @@ class SlideMap(MapComponent):
|
|
|
506
527
|
State({'type': 'base-layer','index': ALL},'children'),
|
|
507
528
|
State({'type': 'feature-lineColor','index': ALL},'value'),
|
|
508
529
|
State({'type': 'adv-overlay-colormap','index': ALL},'value'),
|
|
530
|
+
State({'type': 'manual-roi-separate-switch','index': ALL},'checked'),
|
|
531
|
+
State({'type': 'manual-roi-summarize-switch','index': ALL},'checked')
|
|
509
532
|
]
|
|
510
533
|
)(self.add_manual_roi)
|
|
511
534
|
|
|
@@ -734,7 +757,11 @@ class SlideMap(MapComponent):
|
|
|
734
757
|
geo_annotations = json.dumps(geo_annotations)
|
|
735
758
|
new_slide_info = json.dumps(new_slide_info)
|
|
736
759
|
|
|
737
|
-
|
|
760
|
+
# Updating manual and generated ROIs divs
|
|
761
|
+
manual_rois = []
|
|
762
|
+
gen_rois = []
|
|
763
|
+
|
|
764
|
+
return new_layer_children, manual_rois, gen_rois, geo_annotations, new_tile_layer, new_slide_info
|
|
738
765
|
|
|
739
766
|
def upload_shape(self, upload_clicked, is_open):
|
|
740
767
|
|
|
@@ -786,6 +813,45 @@ class SlideMap(MapComponent):
|
|
|
786
813
|
)
|
|
787
814
|
|
|
788
815
|
return return_table
|
|
816
|
+
|
|
817
|
+
def make_sub_accordion(input_data: Union[list,dict], main_list: list = [], sub_accordion_list:list = [],main_title:str=''):
|
|
818
|
+
"""Recursively generating sub-accordion objects for nested properties
|
|
819
|
+
|
|
820
|
+
:param input_dict: Input dictionary containing nested and non-nested key/value pairs
|
|
821
|
+
:type input_dict: dict
|
|
822
|
+
:param sub_accordion_list: List of sub-accordions, defaults to []
|
|
823
|
+
:type sub_accordion_list: list, optional
|
|
824
|
+
"""
|
|
825
|
+
for idx,in_data in enumerate(input_data):
|
|
826
|
+
title = list(in_data.keys())[0]
|
|
827
|
+
non_nested_data = [{'Sub-Property':key, 'Value': val} for key,val in in_data[title].items() if not type(val) in [list,dict]]
|
|
828
|
+
nested_data = [{key:val} for key,val in in_data[title].items() if type(val)==dict]
|
|
829
|
+
if len(non_nested_data)>0:
|
|
830
|
+
sub_accordion_list.append(
|
|
831
|
+
dbc.AccordionItem([
|
|
832
|
+
html.Div([
|
|
833
|
+
make_dash_table(pd.DataFrame.from_records(non_nested_data))
|
|
834
|
+
])
|
|
835
|
+
],title = title)
|
|
836
|
+
)
|
|
837
|
+
if len(nested_data)>0:
|
|
838
|
+
main_title = title
|
|
839
|
+
main_list, sub_accordion_list = make_sub_accordion(nested_data, main_list, sub_accordion_list,main_title)
|
|
840
|
+
print(f'len(sub_accordion_list): {len(sub_accordion_list)}')
|
|
841
|
+
|
|
842
|
+
if len(sub_accordion_list)>0:
|
|
843
|
+
main_list.append(
|
|
844
|
+
dbc.AccordionItem(
|
|
845
|
+
children = dbc.Accordion(sub_accordion_list),
|
|
846
|
+
title = main_title
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
sub_accordion_list = []
|
|
850
|
+
main_title = ''
|
|
851
|
+
|
|
852
|
+
return main_list, sub_accordion_list
|
|
853
|
+
|
|
854
|
+
|
|
789
855
|
|
|
790
856
|
accordion_children = []
|
|
791
857
|
all_properties = list(clicked['properties'].keys())
|
|
@@ -802,19 +868,10 @@ class SlideMap(MapComponent):
|
|
|
802
868
|
)
|
|
803
869
|
|
|
804
870
|
# Now loading the dict properties as sub-accordions
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
if len(sub_prop_record)>0:
|
|
811
|
-
accordion_children.append(
|
|
812
|
-
dbc.AccordionItem([
|
|
813
|
-
html.Div([
|
|
814
|
-
make_dash_table(pd.DataFrame.from_records(sub_prop_record))
|
|
815
|
-
])
|
|
816
|
-
],title = d)
|
|
817
|
-
)
|
|
871
|
+
sub_properties = [{i:clicked['properties'][i]} for i in clicked['properties'] if type(clicked['properties'][i])==dict]
|
|
872
|
+
test_sub_accordions, _ = make_sub_accordion(sub_properties)
|
|
873
|
+
if len(test_sub_accordions)>0:
|
|
874
|
+
accordion_children.extend(test_sub_accordions)
|
|
818
875
|
|
|
819
876
|
# If this is a manual ROI then add a download option:
|
|
820
877
|
if 'Manual' in clicked['properties']['name']:
|
|
@@ -838,7 +895,9 @@ class SlideMap(MapComponent):
|
|
|
838
895
|
|
|
839
896
|
popup_div = html.Div(
|
|
840
897
|
dbc.Accordion(
|
|
841
|
-
children = accordion_children
|
|
898
|
+
children = accordion_children,
|
|
899
|
+
start_collapsed=True,
|
|
900
|
+
style = {'maxHeight': '800px','overflow': 'scroll','width': '400px'}
|
|
842
901
|
)
|
|
843
902
|
)
|
|
844
903
|
|
|
@@ -916,7 +975,7 @@ class SlideMap(MapComponent):
|
|
|
916
975
|
|
|
917
976
|
return annotation_components
|
|
918
977
|
|
|
919
|
-
def add_manual_roi(self,new_geojson:list, uploaded_shape: list, current_annotations:list, frame_layers:list, line_colors:list, colormap:list) -> list:
|
|
978
|
+
def add_manual_roi(self,new_geojson:list, uploaded_shape: list, current_annotations:list, frame_layers:list, line_colors:list, colormap:list, separate_switch: list, summarize_switch: list) -> list:
|
|
920
979
|
"""Adding a manual region of interest (ROI) to the SlideMap using dl.EditControl() tools including polygon, rectangle, and markers.
|
|
921
980
|
|
|
922
981
|
:param new_geojson: Incoming GeoJSON object that is emitted by dl.EditControl() following annotation on SlideMap
|
|
@@ -941,19 +1000,29 @@ class SlideMap(MapComponent):
|
|
|
941
1000
|
current_annotations = json.loads(get_pattern_matching_value(current_annotations))
|
|
942
1001
|
|
|
943
1002
|
colormap = get_pattern_matching_value(colormap)
|
|
1003
|
+
separate_switch = get_pattern_matching_value(separate_switch)
|
|
1004
|
+
summarize_switch = get_pattern_matching_value(summarize_switch)
|
|
1005
|
+
separate_switch = separate_switch if not separate_switch is None else False
|
|
1006
|
+
summarize_switch = summarize_switch if not summarize_switch is None else False
|
|
944
1007
|
|
|
945
1008
|
if colormap is None or len(colormap)==0:
|
|
946
1009
|
colormap = 'blue->red'
|
|
947
1010
|
|
|
948
1011
|
# All but the last one which holds the manual ROIs
|
|
949
|
-
initial_annotations = [i for i in current_annotations if not
|
|
1012
|
+
initial_annotations = [i for i in current_annotations if not any([j in i['properties']['name'] for j in ['Manual','Filtered','Upload']])]
|
|
950
1013
|
# Current manual rois (used for determining if a new ROI has been created/edited)
|
|
951
1014
|
annotation_names = [i['properties']['name'] for i in current_annotations]
|
|
952
1015
|
manual_roi_idxes = [0]+[int(i.split(' ')[-1]) for i in annotation_names if 'Manual' in i]
|
|
1016
|
+
|
|
953
1017
|
manual_rois = [i for i in current_annotations if 'Manual' in i['properties']['name']]
|
|
954
1018
|
|
|
1019
|
+
# The three different types of ROIs present on maps
|
|
1020
|
+
manual_roi_idx = [idx for idx,i in enumerate(current_annotations) if 'Manual' in i['properties']['name']]
|
|
1021
|
+
#filtered_rois = [idx for idx,i in enumerate(current_annotations) if 'Filtered' in i['properties']['name']]
|
|
1022
|
+
#uploaded_rois = [idx for idx,i in enumerate(current_annotations) if 'Upload' in i['properties']['name']]
|
|
1023
|
+
|
|
955
1024
|
# Initializing layers with partial update object
|
|
956
|
-
|
|
1025
|
+
new_manual_rois = Patch()
|
|
957
1026
|
added_rois = []
|
|
958
1027
|
added_roi_names = []
|
|
959
1028
|
deleted_rois = []
|
|
@@ -986,7 +1055,7 @@ class SlideMap(MapComponent):
|
|
|
986
1055
|
# Aggregate if any initial annotations are present
|
|
987
1056
|
if len(initial_annotations)>0:
|
|
988
1057
|
# Spatial aggregation performed just between individual manual ROIs and initial annotations (no manual ROI to manual ROI aggregation)
|
|
989
|
-
new_roi = spatially_aggregate(new_roi, initial_annotations)
|
|
1058
|
+
new_roi = spatially_aggregate(new_roi, initial_annotations,separate=separate_switch,summarize=summarize_switch)
|
|
990
1059
|
|
|
991
1060
|
added_rois.append(new_roi)
|
|
992
1061
|
added_roi_names.append(new_roi_name)
|
|
@@ -1014,17 +1083,17 @@ class SlideMap(MapComponent):
|
|
|
1014
1083
|
new_roi_name = uploaded_roi['properties']['name']
|
|
1015
1084
|
if len(initial_annotations)>0:
|
|
1016
1085
|
# Spatial aggregation performed just between individual manual ROIs and initial annotations (no manual ROI to manual ROI aggregation)
|
|
1017
|
-
new_roi = spatially_aggregate(uploaded_roi, initial_annotations)
|
|
1086
|
+
new_roi = spatially_aggregate(uploaded_roi, initial_annotations,separate=separate_switch,summarize=summarize_switch)
|
|
1018
1087
|
|
|
1019
1088
|
added_rois.append(new_roi)
|
|
1020
1089
|
added_roi_names.append(new_roi_name)
|
|
1021
1090
|
manual_roi_idxes.append(max(manual_roi_idxes)+1)
|
|
1022
1091
|
|
|
1023
|
-
|
|
1024
1092
|
# Checking for deleted manual ROIs
|
|
1025
|
-
for m_idx,m in enumerate(manual_rois):
|
|
1093
|
+
for m_idx,(m,man_idx) in enumerate(zip(manual_rois,manual_roi_idx)):
|
|
1026
1094
|
if not m['features'][0]['geometry'] in [j['geometry'] for j in new_geojson['features']]:
|
|
1027
|
-
|
|
1095
|
+
# Adding both the index in the manual-rois layer and the index in the current annotations layer
|
|
1096
|
+
deleted_rois.append((m_idx,man_idx))
|
|
1028
1097
|
|
|
1029
1098
|
operation = False
|
|
1030
1099
|
if len(added_rois)>0:
|
|
@@ -1034,22 +1103,22 @@ class SlideMap(MapComponent):
|
|
|
1034
1103
|
new_layers = self.make_geojson_layers(
|
|
1035
1104
|
added_rois,added_roi_names,[len(initial_annotations)+max(manual_roi_idxes)],line_colors,colormap
|
|
1036
1105
|
)
|
|
1037
|
-
|
|
1106
|
+
new_manual_rois.extend(new_layers)
|
|
1038
1107
|
|
|
1039
1108
|
current_annotations.extend(added_rois)
|
|
1040
1109
|
|
|
1041
1110
|
if len(deleted_rois)>0:
|
|
1042
1111
|
operation = True
|
|
1043
|
-
for d_idx,
|
|
1044
|
-
del
|
|
1045
|
-
del current_annotations[
|
|
1112
|
+
for d_idx,(man_d,current_d) in enumerate(deleted_rois):
|
|
1113
|
+
del new_manual_rois[man_d-d_idx]
|
|
1114
|
+
del current_annotations[current_d-d_idx]
|
|
1046
1115
|
|
|
1047
1116
|
annotations_data = json.dumps(current_annotations)
|
|
1048
1117
|
|
|
1049
1118
|
if not operation:
|
|
1050
|
-
|
|
1119
|
+
new_manual_rois = no_update
|
|
1051
1120
|
|
|
1052
|
-
return
|
|
1121
|
+
return new_manual_rois, annotations_data
|
|
1053
1122
|
|
|
1054
1123
|
def update_image_overlay_transparency(self, new_opacity: float):
|
|
1055
1124
|
"""Update transparency of image overlay component
|
|
@@ -55,7 +55,7 @@ class OverlayOptions(Tool):
|
|
|
55
55
|
:type Tool: None
|
|
56
56
|
"""
|
|
57
57
|
def __init__(self,
|
|
58
|
-
ignore_list: list = [],
|
|
58
|
+
ignore_list: list = ["_id", "_index"],
|
|
59
59
|
property_depth: int = 4
|
|
60
60
|
):
|
|
61
61
|
"""Constructor method
|
|
@@ -85,7 +85,7 @@ class OverlayOptions(Tool):
|
|
|
85
85
|
]
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
self.js_namespace = Namespace("fusionTools","
|
|
88
|
+
self.js_namespace = Namespace("fusionTools","slideMap")
|
|
89
89
|
|
|
90
90
|
# Add callbacks here
|
|
91
91
|
self.get_callbacks()
|
|
@@ -280,6 +280,36 @@ class OverlayOptions(Tool):
|
|
|
280
280
|
])
|
|
281
281
|
]
|
|
282
282
|
),
|
|
283
|
+
dbc.AccordionItem(
|
|
284
|
+
title = dcc.Markdown('*Manual ROI Options*'),
|
|
285
|
+
children = [
|
|
286
|
+
dbc.Card([
|
|
287
|
+
dbc.CardBody([
|
|
288
|
+
# Whether to separate structures or not
|
|
289
|
+
# Whether to summarize or not
|
|
290
|
+
dmc.Switch(
|
|
291
|
+
size = 'lg',
|
|
292
|
+
radius = 'lg',
|
|
293
|
+
label = 'Separate Intersecting Structures',
|
|
294
|
+
onLabel="ON",
|
|
295
|
+
offLabel="OFF",
|
|
296
|
+
description = "Separates aggregated properties by which structure they are derived from",
|
|
297
|
+
id = {'type': 'manual-roi-separate-switch','index':0}
|
|
298
|
+
),
|
|
299
|
+
html.Hr(),
|
|
300
|
+
dmc.Switch(
|
|
301
|
+
size = 'lg',
|
|
302
|
+
radius = 'lg',
|
|
303
|
+
label = "Summarize Aggregated Properties",
|
|
304
|
+
onLabel = "ON",
|
|
305
|
+
offLabel = "OFF",
|
|
306
|
+
description = "Whether to report summaries of aggregated properties or just the MEAN",
|
|
307
|
+
id = {'type': 'manual-roi-summarize-switch','index': 0}
|
|
308
|
+
)
|
|
309
|
+
])
|
|
310
|
+
])
|
|
311
|
+
]
|
|
312
|
+
),
|
|
283
313
|
dbc.AccordionItem(
|
|
284
314
|
title = dcc.Markdown('*Export Layers*'),
|
|
285
315
|
children = [
|
|
@@ -460,7 +490,7 @@ class OverlayOptions(Tool):
|
|
|
460
490
|
dmc.ColorPicker(
|
|
461
491
|
id = {'type': f'{self.component_prefix}-feature-lineColor','index': f_idx},
|
|
462
492
|
format = 'hex',
|
|
463
|
-
value = '
|
|
493
|
+
value = '#%02x%02x%02x' % (np.random.randint(0,255),np.random.randint(0,255),np.random.randint(0,255)),
|
|
464
494
|
fullWidth=True
|
|
465
495
|
),
|
|
466
496
|
dbc.Button(
|
|
@@ -981,7 +1011,7 @@ class PropertyViewer(Tool):
|
|
|
981
1011
|
"""
|
|
982
1012
|
def __init__(self,
|
|
983
1013
|
ignore_list: list = [],
|
|
984
|
-
property_depth: int =
|
|
1014
|
+
property_depth: int = 6
|
|
985
1015
|
):
|
|
986
1016
|
"""Constructor method
|
|
987
1017
|
|
|
@@ -1584,7 +1614,7 @@ class PropertyPlotter(Tool):
|
|
|
1584
1614
|
"""
|
|
1585
1615
|
def __init__(self,
|
|
1586
1616
|
ignore_list: list = [],
|
|
1587
|
-
property_depth: int =
|
|
1617
|
+
property_depth: int = 6
|
|
1588
1618
|
):
|
|
1589
1619
|
"""Constructor method
|
|
1590
1620
|
|
|
@@ -2139,7 +2169,7 @@ class PropertyPlotter(Tool):
|
|
|
2139
2169
|
text = '<br>'.join(
|
|
2140
2170
|
textwrap.wrap(
|
|
2141
2171
|
f'{property_column}',
|
|
2142
|
-
width=
|
|
2172
|
+
width=80
|
|
2143
2173
|
)
|
|
2144
2174
|
),
|
|
2145
2175
|
font = dict(size = 10)
|
|
@@ -2148,7 +2178,7 @@ class PropertyPlotter(Tool):
|
|
|
2148
2178
|
text = '<br>'.join(
|
|
2149
2179
|
textwrap.wrap(
|
|
2150
2180
|
label_col,
|
|
2151
|
-
width=
|
|
2181
|
+
width=80
|
|
2152
2182
|
)
|
|
2153
2183
|
) if not label_col is None else 'Group',
|
|
2154
2184
|
font = dict(size = 10)
|
|
@@ -2184,7 +2214,7 @@ class PropertyPlotter(Tool):
|
|
|
2184
2214
|
title = '<br>'.join(
|
|
2185
2215
|
textwrap.wrap(
|
|
2186
2216
|
f'Scatter plot of {plot_cols[0]} and {plot_cols[1]} labeled by {label_col}',
|
|
2187
|
-
width =
|
|
2217
|
+
width = 60
|
|
2188
2218
|
)
|
|
2189
2219
|
)
|
|
2190
2220
|
)
|
|
@@ -2233,7 +2263,7 @@ class PropertyPlotter(Tool):
|
|
|
2233
2263
|
title = '<br>'.join(
|
|
2234
2264
|
textwrap.wrap(
|
|
2235
2265
|
f'Scatter plot of {plot_cols[0]} and {plot_cols[1]}',
|
|
2236
|
-
width =
|
|
2266
|
+
width = 60
|
|
2237
2267
|
)
|
|
2238
2268
|
)
|
|
2239
2269
|
)
|
|
@@ -97,7 +97,7 @@ class ParallelFeatureExtractor:
|
|
|
97
97
|
if not self.mask_names is None:
|
|
98
98
|
convert_key = {f'Mask {float(idx+1)}': i for idx,i in enumerate(self.mask_names)}
|
|
99
99
|
if not self.channel_names is None:
|
|
100
|
-
convert_key = convert_key | {f'Channel {
|
|
100
|
+
convert_key = convert_key | {f'Channel {idx}': i for idx,i in enumerate(self.channel_names)}
|
|
101
101
|
|
|
102
102
|
def convert_dict_key(pre_convert):
|
|
103
103
|
if pre_convert in convert_key:
|
|
@@ -308,10 +308,10 @@ def color_features(image:np.ndarray, mask: np.ndarray, coords:list)->dict:
|
|
|
308
308
|
mask_regions = (mask==m)
|
|
309
309
|
masked_channels = image[mask_regions>0]
|
|
310
310
|
|
|
311
|
-
mean_vals = np.nanmean(masked_channels,axis=0).tolist()
|
|
312
|
-
median_vals = np.nanmedian(masked_channels,axis=0).tolist()
|
|
313
|
-
max_vals = np.nanmedian(masked_channels,axis=0).tolist()
|
|
314
|
-
std_vals = np.nanstd(masked_channels,axis=0).tolist()
|
|
311
|
+
mean_vals = np.nanmean(masked_channels,axis=0).astype(float).tolist()
|
|
312
|
+
median_vals = np.nanmedian(masked_channels,axis=0).astype(float).tolist()
|
|
313
|
+
max_vals = np.nanmedian(masked_channels,axis=0).astype(float).tolist()
|
|
314
|
+
std_vals = np.nanstd(masked_channels,axis=0).astype(float).tolist()
|
|
315
315
|
|
|
316
316
|
feature_values[f'Mask {m}'] = {}
|
|
317
317
|
for c_idx,(m1,m2,m3,s) in enumerate(zip(mean_vals, median_vals, max_vals, std_vals)):
|
|
@@ -350,7 +350,7 @@ def texture_features(image:np.ndarray, mask:np.ndarray, coords: list)->dict:
|
|
|
350
350
|
for t in texture_features:
|
|
351
351
|
t_value = graycoprops(texture_matrix,t.lower())[0][0]
|
|
352
352
|
|
|
353
|
-
feature_values[f'Mask {m}'][f'Channel {c}'][t] = t_value
|
|
353
|
+
feature_values[f'Mask {m}'][f'Channel {c}'][t] = float(t_value)
|
|
354
354
|
|
|
355
355
|
return feature_values
|
|
356
356
|
|
|
@@ -383,10 +383,10 @@ def morphological_features(image:np.ndarray,mask:np.ndarray,coords:list)->dict:
|
|
|
383
383
|
feature_values[f'Mask {m}'] = {'Count': props.shape[0]}
|
|
384
384
|
for p in props.columns.tolist():
|
|
385
385
|
feature_values[f'Mask {m}'][p] = {
|
|
386
|
-
'Mean': props[p].mean(),
|
|
387
|
-
'Median': props[p].median(),
|
|
388
|
-
'Max': props[p].max(),
|
|
389
|
-
'Min': props[p].min()
|
|
386
|
+
'Mean': float(props[p].mean()),
|
|
387
|
+
'Median': float(props[p].median()),
|
|
388
|
+
'Max': float(props[p].max()),
|
|
389
|
+
'Min': float(props[p].min())
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
return feature_values
|
|
@@ -413,11 +413,11 @@ def relative_distance(input_shapes:dict, other_shapes:Union[dict,list])->list:
|
|
|
413
413
|
all_dist.extend(other_fc.distance(shape(f['geometry'])))
|
|
414
414
|
|
|
415
415
|
distance_stats.append({
|
|
416
|
-
'Min': np.min(all_dist),
|
|
417
|
-
'Max': np.max(all_dist),
|
|
418
|
-
'Mean': np.mean(all_dist),
|
|
419
|
-
'Median': np.median(all_dist),
|
|
420
|
-
'Std': np.std(all_dist)
|
|
416
|
+
'Min': float(np.min(all_dist)),
|
|
417
|
+
'Max': float(np.max(all_dist)),
|
|
418
|
+
'Mean': float(np.mean(all_dist)),
|
|
419
|
+
'Median': float(np.median(all_dist)),
|
|
420
|
+
'Std': float(np.std(all_dist))
|
|
421
421
|
})
|
|
422
422
|
|
|
423
423
|
return distance_stats
|
|
@@ -74,16 +74,16 @@ class LocalTileServer(TileServer):
|
|
|
74
74
|
def __len__(self):
|
|
75
75
|
return len(self.tile_sources)
|
|
76
76
|
|
|
77
|
-
def add_new_image(self,new_image_path:str, new_annotations:Union[str,list,dict,None]):
|
|
77
|
+
def add_new_image(self,new_image_path:str, new_annotations:Union[str,list,dict,None], new_metadata:Union[dict,None] = None):
|
|
78
78
|
|
|
79
79
|
self.local_image_paths.append(new_image_path)
|
|
80
80
|
self.names.append(new_image_path.split(os.sep)[-1])
|
|
81
81
|
new_tile_source = large_image.open(new_image_path)
|
|
82
|
-
|
|
82
|
+
new_tiles_metadata = new_tile_source.getMetadata()
|
|
83
83
|
|
|
84
84
|
# Treating 3-frame images as RGB by default
|
|
85
|
-
if 'frames' in
|
|
86
|
-
if len(
|
|
85
|
+
if 'frames' in new_tiles_metadata:
|
|
86
|
+
if len(new_tiles_metadata['frames'])==3:
|
|
87
87
|
new_tile_source = large_image.open(
|
|
88
88
|
new_image_path,
|
|
89
89
|
style = {
|
|
@@ -98,7 +98,10 @@ class LocalTileServer(TileServer):
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
self.tile_sources.append(new_tile_source)
|
|
101
|
-
|
|
101
|
+
if not new_metadata is None:
|
|
102
|
+
self.tiles_metadata.append(new_tiles_metadata | new_metadata)
|
|
103
|
+
else:
|
|
104
|
+
self.tiles_metadatas.append(new_tiles_metadata)
|
|
102
105
|
|
|
103
106
|
if not new_annotations is None:
|
|
104
107
|
if type(new_annotations)==str:
|
|
@@ -671,7 +671,7 @@ def find_intersecting(geo_source:Union[dict,str], geo_query:Polygon, return_prop
|
|
|
671
671
|
elif return_shapes:
|
|
672
672
|
return geo_intersect_geojson
|
|
673
673
|
|
|
674
|
-
def spatially_aggregate(child_geo:dict, parent_geos: list, separate: bool = True, summarize: bool = True, ignore_list: list = []):
|
|
674
|
+
def spatially_aggregate(child_geo:dict, parent_geos: list, separate: bool = True, summarize: bool = True, ignore_list: list = ["_id","_index"]):
|
|
675
675
|
"""Aggregate intersecting feature properties to a provided GeoJSON
|
|
676
676
|
|
|
677
677
|
:param child_geo: GeoJSON object that is receiving aggregated properties
|
|
@@ -46,6 +46,7 @@ class Visualization:
|
|
|
46
46
|
def __init__(self,
|
|
47
47
|
local_slides: Union[list,str,None] = None,
|
|
48
48
|
local_annotations: Union[list,dict,None] = None,
|
|
49
|
+
slide_metadata: Union[list,dict,None] = None,
|
|
49
50
|
tileservers: Union[list,TileServer,None] = None,
|
|
50
51
|
components: list = [],
|
|
51
52
|
app_options: dict = {},
|
|
@@ -53,13 +54,24 @@ class Visualization:
|
|
|
53
54
|
):
|
|
54
55
|
"""Constructor method
|
|
55
56
|
|
|
56
|
-
:param
|
|
57
|
-
:type
|
|
58
|
-
:param
|
|
57
|
+
:param local_slides: Filepath for individual slide or list of filepaths stored locally, defaults to None
|
|
58
|
+
:type local_slides: Union[list,str,None], optional
|
|
59
|
+
:param local_annotations: List of processed annotations or filepaths for annotations stored locally (aligns with local_slides), defaults to None
|
|
60
|
+
:type local_annotations: Union[list,dict,None], optional
|
|
61
|
+
:param slide_metadata: List or single dictionary containing slide-level metadata keys and values, defaults to None
|
|
62
|
+
:type slide_metadata: Union[list,dict,None], optional
|
|
63
|
+
:param tileservers: Single tileserver or multiple tileservers, defaults to None
|
|
64
|
+
:type tileservers: Union[list,TileServer,None], optional
|
|
65
|
+
:param components: List of components in layout format (rows-->columns-->tabs for nested lists), defaults to []
|
|
66
|
+
:type components: list, optional
|
|
67
|
+
:param app_options: Additional options for the running visualization session, defaults to {}
|
|
59
68
|
:type app_options: dict, optional
|
|
69
|
+
:param linkage: Which levels of components are linked through callbacks (can be 'row','col',or 'tab'), defaults to 'row'
|
|
70
|
+
:type linkage: str, optional
|
|
60
71
|
"""
|
|
61
72
|
|
|
62
73
|
self.local_slides = local_slides
|
|
74
|
+
self.slide_metadata = slide_metadata
|
|
63
75
|
self.tileservers = tileservers
|
|
64
76
|
self.local_annotations = local_annotations
|
|
65
77
|
self.components = components
|
|
@@ -128,19 +140,23 @@ class Visualization:
|
|
|
128
140
|
if not self.local_slides is None:
|
|
129
141
|
if self.local_annotations is None:
|
|
130
142
|
self.local_annotations = [None]*len(self.local_slides)
|
|
143
|
+
|
|
144
|
+
if self.slide_metadata is None:
|
|
145
|
+
self.slide_metadata = [None]*len(self.local_slides)
|
|
131
146
|
|
|
132
147
|
self.local_tile_server = LocalTileServer(
|
|
133
148
|
tile_server_port=self.app_options['port'] if not self.app_options['jupyter'] else self.app_options['port']+10,
|
|
134
149
|
host = self.app_options['host']
|
|
135
150
|
)
|
|
136
151
|
|
|
137
|
-
for s_idx,(s,anns) in enumerate(zip(self.local_slides,self.local_annotations)):
|
|
152
|
+
for s_idx,(s,anns,meta) in enumerate(zip(self.local_slides,self.local_annotations,self.slide_metadata)):
|
|
138
153
|
slide_dict = {}
|
|
139
154
|
if not s is None:
|
|
140
155
|
# Adding this slide to list of local slides
|
|
141
156
|
self.local_tile_server.add_new_image(
|
|
142
157
|
new_image_path = s,
|
|
143
|
-
new_annotations = anns
|
|
158
|
+
new_annotations = anns,
|
|
159
|
+
new_metadata = meta
|
|
144
160
|
)
|
|
145
161
|
|
|
146
162
|
slide_dict = {
|
|
@@ -183,6 +199,15 @@ class Visualization:
|
|
|
183
199
|
'metadata_url': t.metadata_url,
|
|
184
200
|
'annotations_url': t.annotations_url
|
|
185
201
|
})
|
|
202
|
+
elif type(t)==CustomTileServer:
|
|
203
|
+
slide_store.append({
|
|
204
|
+
'start_idx': (s_idx+t_idx+t),
|
|
205
|
+
'name': t.name,
|
|
206
|
+
'tiles_url': t.tiles_url,
|
|
207
|
+
'regions_url': t.regions_url if hasattr(t,'regions_url') else None,
|
|
208
|
+
'metadata_url': t.metadata_url if hasattr(t,'metadata_url') else None,
|
|
209
|
+
'annotations_url': t.annotations_url if hasattr(t,'annotations_url') else None
|
|
210
|
+
})
|
|
186
211
|
|
|
187
212
|
return slide_store
|
|
188
213
|
|
|
@@ -27,6 +27,7 @@ tests/test_classification_model.py
|
|
|
27
27
|
tests/test_custom_component.py
|
|
28
28
|
tests/test_dataset_classes.py
|
|
29
29
|
tests/test_extract_nested_prop.py
|
|
30
|
+
tests/test_extract_recursive.py
|
|
30
31
|
tests/test_feature_annotation.py
|
|
31
32
|
tests/test_feature_extraction.py
|
|
32
33
|
tests/test_generate_property_dict.py
|
|
@@ -36,6 +37,7 @@ tests/test_image_overlay.py
|
|
|
36
37
|
tests/test_model_train.py
|
|
37
38
|
tests/test_multiframe_rgb.py
|
|
38
39
|
tests/test_multiframeslidemap.py
|
|
40
|
+
tests/test_process.py
|
|
39
41
|
tests/test_property_plotter.py
|
|
40
42
|
tests/test_read_anndata.py
|
|
41
43
|
tests/test_remote_slide_annotations.py
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""test recursive property extraction
|
|
2
|
+
"""
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
def read_dict_list(dict_list,main_list = [],sub_list = None):
|
|
6
|
+
for d in dict_list:
|
|
7
|
+
if type(d)==dict:
|
|
8
|
+
title = list(d.keys())[0]
|
|
9
|
+
print(f'title: {title}')
|
|
10
|
+
|
|
11
|
+
nested = [{key:val} for key,val in d[title].items() if type(val)==dict]
|
|
12
|
+
non_nested = [{'SubProperty': key, 'Value': val} for key,val in d[title].items() if not type(val)==dict]
|
|
13
|
+
main_list.append(non_nested)
|
|
14
|
+
read_dict_list(nested,main_list,[])
|
|
15
|
+
return main_list
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
|
|
20
|
+
test_list = [
|
|
21
|
+
{
|
|
22
|
+
'main_prop': {
|
|
23
|
+
'sub_prop1': {},
|
|
24
|
+
'sub_prop2': {
|
|
25
|
+
'sub_subprop1': 2
|
|
26
|
+
},
|
|
27
|
+
'sub_prop3': {
|
|
28
|
+
'sub_subprop1': {
|
|
29
|
+
'sub_key': 'sub_val',
|
|
30
|
+
'sub_key2': 'sub_val2'
|
|
31
|
+
},
|
|
32
|
+
'sub_subprop2': {
|
|
33
|
+
'sub_sub_subprop1': 4
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
'main_prop2': {
|
|
40
|
+
'sub_prop2': {
|
|
41
|
+
'key1': 'val1',
|
|
42
|
+
'key2': 'val2',
|
|
43
|
+
'key3': 'val3',
|
|
44
|
+
'nested_key': {
|
|
45
|
+
'n_key1': 'n_val1',
|
|
46
|
+
'n_key2': 'n_val2'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
print(json.dumps(test_list,indent=4))
|
|
54
|
+
|
|
55
|
+
print(read_dict_list(test_list))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__=='__main__':
|
|
60
|
+
main()
|
|
61
|
+
|
|
62
|
+
|
|
@@ -151,6 +151,8 @@ def main():
|
|
|
151
151
|
],
|
|
152
152
|
preprocess = None,
|
|
153
153
|
sub_mask = lambda image,mask: stain_mask(image,mask),
|
|
154
|
+
mask_names = ['Nuclei','Eosinophilic','Luminal Space'],
|
|
155
|
+
channel_names = ['Red','Green','Blue'],
|
|
154
156
|
n_jobs = 4,
|
|
155
157
|
verbose = True
|
|
156
158
|
)
|
|
@@ -158,6 +160,8 @@ def main():
|
|
|
158
160
|
feature_df = feature_extractor.start(annotations[0]['features'])
|
|
159
161
|
|
|
160
162
|
print(feature_df)
|
|
163
|
+
import json
|
|
164
|
+
print(json.dumps(feature_df.to_dict('records'),indent=4))
|
|
161
165
|
feature_df.to_csv('.\\tests\\test_features.csv')
|
|
162
166
|
|
|
163
167
|
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# %% [markdown]
|
|
2
|
+
# ### Making annotations for *10x Visium* data
|
|
3
|
+
#
|
|
4
|
+
# #### Step 1: (*external*) Convert *.RDS* file (SpaceRanger output) to *.h5ad*
|
|
5
|
+
# - This can be done using Seurat and SeuratDisk to go from *SeuratObject* -> *.h5Seurat* -> *.h5ad* see [this page](https://mojaveazure.github.io/seurat-disk/articles/convert-anndata.html) although the spot coordinates have to be added to the `seuratObject` manually in order to be added to the *.h5ad* file.
|
|
6
|
+
# - Do this using:
|
|
7
|
+
#
|
|
8
|
+
# `seuratObject@meta.data[["spot_coordinates"]] <- cbind(seuratObject@meta.data, seuratObject@images[["slice1"]]@coordinates)`
|
|
9
|
+
#
|
|
10
|
+
# - **Important**:
|
|
11
|
+
# - `Convert` will only save the *DefaultAssay* to the *X* matrix and will ignore all other assays. You can get around this by resetting the *DefaultAssay*:
|
|
12
|
+
#
|
|
13
|
+
# `DefaultAssay(seuratObject) <- "predsubclassl2"`
|
|
14
|
+
#
|
|
15
|
+
|
|
16
|
+
# %% [markdown]
|
|
17
|
+
# #### Step 2: Create annotation files using *fusion-tools*
|
|
18
|
+
|
|
19
|
+
# %%
|
|
20
|
+
#%pip install fusion-tools[interactive]==2.3.3
|
|
21
|
+
|
|
22
|
+
# %%
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import json
|
|
26
|
+
sys.path.append('./src/')
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
import anndata as ad
|
|
32
|
+
from tqdm import tqdm
|
|
33
|
+
import uuid
|
|
34
|
+
|
|
35
|
+
from shapely.geometry import Point, shape, Polygon
|
|
36
|
+
from shapely.ops import unary_union
|
|
37
|
+
|
|
38
|
+
from fusion_tools.utils.shapes import load_visium, spatially_aggregate
|
|
39
|
+
from fusion_tools.utils.omics import group_subtypes, selective_aggregation
|
|
40
|
+
|
|
41
|
+
visium_path = "..\\..\\Test Upload\\new Visium\\V12U21-010_XY02_21-0069.h5ad"
|
|
42
|
+
|
|
43
|
+
# This function will be in the next released version
|
|
44
|
+
spot_annotations = load_visium(visium_path)
|
|
45
|
+
anndata_object = ad.read_h5ad(visium_path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# %% [markdown]
|
|
49
|
+
# ## Creating Cell Hierarchical labels
|
|
50
|
+
|
|
51
|
+
# %%
|
|
52
|
+
cell_groups_path = "..\\..\\Test Upload\\new Visium\\Supplementary Tables Version 1 05-2024.xlsx"
|
|
53
|
+
cell_groups = pd.read_excel(cell_groups_path,engine='openpyxl',sheet_name='Table S - sn annot',skiprows=range(4),index_col=0)
|
|
54
|
+
cell_groups = cell_groups[cell_groups["v2.subclass.l1"].notna()]
|
|
55
|
+
|
|
56
|
+
l1_types = cell_groups["v2.subclass.l1"].dropna(how='all').unique()
|
|
57
|
+
main_cell_grouping = {}
|
|
58
|
+
for l1 in l1_types:
|
|
59
|
+
l2_types = cell_groups[cell_groups["v2.subclass.l1"].str.contains(l1)]["v2.subclass.l2"].tolist()
|
|
60
|
+
main_cell_grouping[l1] = l2_types
|
|
61
|
+
|
|
62
|
+
print(json.dumps(main_cell_grouping,indent=4))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# %% [markdown]
|
|
66
|
+
# ## Adding Seurat Clusters as a sub-property for each spot
|
|
67
|
+
|
|
68
|
+
# %%
|
|
69
|
+
# Aligning annotations with cell subtype values and Seurat clusters (stored in obs):
|
|
70
|
+
pred_l2 = anndata_object.to_df().round(decimals = 5)
|
|
71
|
+
clusters = anndata_object.obs['seurat_clusters'].values
|
|
72
|
+
for row in range(pred_l2.shape[0]):
|
|
73
|
+
row_data = pred_l2.iloc[row,:].to_dict()
|
|
74
|
+
del row_data['max']
|
|
75
|
+
|
|
76
|
+
spot_annotations['features'][row]['properties'] = spot_annotations['features'][row]['properties'] | {'L2 Cell Types': row_data, 'Seurat Cluster': str(clusters[row])}
|
|
77
|
+
spot_annotations['features'][row]['properties'] = spot_annotations['features'][row]['properties'] | {"L1 Cell Types": group_subtypes({"L2": row_data},"L2",main_cell_grouping,keep_zeros=True)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# %% [markdown]
|
|
82
|
+
# ## Extracting simple morphometrics for spots
|
|
83
|
+
|
|
84
|
+
# %%
|
|
85
|
+
from PIL import Image
|
|
86
|
+
from io import BytesIO
|
|
87
|
+
import large_image
|
|
88
|
+
from skimage.filters import threshold_otsu
|
|
89
|
+
from skimage.segmentation import watershed
|
|
90
|
+
from skimage import exposure
|
|
91
|
+
from skimage.color import rgb2hsv
|
|
92
|
+
import scipy.ndimage as ndi
|
|
93
|
+
from skimage.morphology import remove_small_holes, remove_small_objects
|
|
94
|
+
from skimage.measure import label, find_contours
|
|
95
|
+
from skimage.feature import peak_local_max
|
|
96
|
+
import matplotlib.pyplot as plt
|
|
97
|
+
|
|
98
|
+
from fusion_tools.feature_extraction import (
|
|
99
|
+
ParallelFeatureExtractor, distance_transform_features, color_features,
|
|
100
|
+
texture_features, morphological_features
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if False:
|
|
104
|
+
# define sub-compartment segmentation function
|
|
105
|
+
def stain_mask(image,mask):
|
|
106
|
+
|
|
107
|
+
seg_params = [
|
|
108
|
+
{
|
|
109
|
+
'name': 'Nuclei',
|
|
110
|
+
'threshold': 150,
|
|
111
|
+
'min_size': 40
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
'name': 'Eosinophilic',
|
|
115
|
+
'threshold': 30,
|
|
116
|
+
'min_size': 20
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
'name': 'Luminal Space',
|
|
120
|
+
'threshold': 0,
|
|
121
|
+
'min_size': 0
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
image_shape = np.shape(image)
|
|
126
|
+
|
|
127
|
+
sub_comp_image = np.zeros((image_shape[0],image_shape[1],3))
|
|
128
|
+
remainder_mask = np.ones((image_shape[0],image_shape[1]))
|
|
129
|
+
|
|
130
|
+
hsv_image = np.uint8(255*rgb2hsv(image))
|
|
131
|
+
hsv_image = hsv_image[:,:,1]
|
|
132
|
+
|
|
133
|
+
for idx,param in enumerate(seg_params):
|
|
134
|
+
|
|
135
|
+
# Check for if the current sub-compartment is nuclei
|
|
136
|
+
if param['name'].lower()=='nuclei':
|
|
137
|
+
# Using the inverse of the value channel for nuclei
|
|
138
|
+
h_image = 255-np.uint8(255*rgb2hsv(image)[:,:,2])
|
|
139
|
+
h_image = np.uint8(255*exposure.equalize_hist(h_image))
|
|
140
|
+
|
|
141
|
+
remaining_pixels = np.multiply(h_image,remainder_mask)
|
|
142
|
+
masked_remaining_pixels = np.multiply(remaining_pixels,mask)
|
|
143
|
+
#masked_remaining_pixels = remaining_pixels
|
|
144
|
+
|
|
145
|
+
# Applying manual threshold
|
|
146
|
+
masked_remaining_pixels[masked_remaining_pixels<=param['threshold']] = 0
|
|
147
|
+
masked_remaining_pixels[masked_remaining_pixels>0] = 1
|
|
148
|
+
|
|
149
|
+
# Area threshold for holes is controllable for this
|
|
150
|
+
sub_mask = remove_small_holes(masked_remaining_pixels>0,area_threshold=10)
|
|
151
|
+
sub_mask = sub_mask>0
|
|
152
|
+
# Watershed implementation from: https://scikit-image.org/docs/stable/auto_examples/segmentation/plot_watershed.html
|
|
153
|
+
distance = ndi.distance_transform_edt(sub_mask)
|
|
154
|
+
labeled_mask, _ = ndi.label(sub_mask)
|
|
155
|
+
coords = peak_local_max(distance,footprint=np.ones((3,3)),labels = labeled_mask)
|
|
156
|
+
watershed_mask = np.zeros(distance.shape,dtype=bool)
|
|
157
|
+
watershed_mask[tuple(coords.T)] = True
|
|
158
|
+
markers, _ = ndi.label(watershed_mask)
|
|
159
|
+
sub_mask = watershed(-distance,markers,mask=sub_mask)
|
|
160
|
+
sub_mask = sub_mask>0
|
|
161
|
+
|
|
162
|
+
# Filtering out small objects again
|
|
163
|
+
sub_mask = remove_small_objects(sub_mask,param['min_size'])
|
|
164
|
+
|
|
165
|
+
else:
|
|
166
|
+
|
|
167
|
+
remaining_pixels = np.multiply(hsv_image,remainder_mask)
|
|
168
|
+
masked_remaining_pixels = np.multiply(remaining_pixels,mask)
|
|
169
|
+
#masked_remaining_pixels = remaining_pixels
|
|
170
|
+
|
|
171
|
+
# Applying manual threshold
|
|
172
|
+
masked_remaining_pixels[masked_remaining_pixels<=param['threshold']] = 0
|
|
173
|
+
masked_remaining_pixels[masked_remaining_pixels>0] = 1
|
|
174
|
+
|
|
175
|
+
# Filtering by minimum size
|
|
176
|
+
small_object_filtered = (1/255)*np.uint8(remove_small_objects(masked_remaining_pixels>0,param['min_size']))
|
|
177
|
+
|
|
178
|
+
sub_mask = small_object_filtered
|
|
179
|
+
|
|
180
|
+
sub_comp_image[sub_mask>0,idx] = 1
|
|
181
|
+
remainder_mask -= sub_mask>0
|
|
182
|
+
|
|
183
|
+
# Assigning remaining pixels within the boundary mask to the last sub-compartment
|
|
184
|
+
#remaining_pixels = np.multiply(mask,remainder_mask)
|
|
185
|
+
remaining_pixels = remainder_mask
|
|
186
|
+
sub_comp_image[remaining_pixels>0,idx] = 1
|
|
187
|
+
|
|
188
|
+
final_mask = np.zeros_like(remainder_mask)
|
|
189
|
+
final_mask += sub_comp_image[:,:,0]
|
|
190
|
+
final_mask += 2*sub_comp_image[:,:,1]
|
|
191
|
+
final_mask += 3*sub_comp_image[:,:,2]
|
|
192
|
+
|
|
193
|
+
return final_mask
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Define feature extractor class
|
|
197
|
+
feature_extractor = ParallelFeatureExtractor(
|
|
198
|
+
image_source = "..\\..\\Test Upload\\new Visium\\V12U21-010_XY02_21-0069.tif",
|
|
199
|
+
feature_list = [
|
|
200
|
+
lambda image,mask,coords: distance_transform_features(image,mask,coords),
|
|
201
|
+
lambda image,mask,coords: color_features(image,mask,coords),
|
|
202
|
+
lambda image,mask,coords: texture_features(image,mask,coords),
|
|
203
|
+
lambda image,mask,coords: morphological_features(image,mask,coords)
|
|
204
|
+
],
|
|
205
|
+
preprocess = None,
|
|
206
|
+
sub_mask = lambda image,mask: stain_mask(image,mask),
|
|
207
|
+
mask_names = ['Nuclei','Eosinophilic','Luminal Space'],
|
|
208
|
+
channel_names = ['Red','Green','Blue'],
|
|
209
|
+
n_jobs = 4,
|
|
210
|
+
verbose = True
|
|
211
|
+
)
|
|
212
|
+
# Execute feature extraction
|
|
213
|
+
feature_df = feature_extractor.start(spot_annotations['features'])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# %%
|
|
219
|
+
# Add properties to spot annotations
|
|
220
|
+
spot_features = feature_df.to_dict('records')
|
|
221
|
+
for f,s in zip(spot_features,spot_annotations['features']):
|
|
222
|
+
s['properties'] = s['properties'] | {"Pathomics": f}
|
|
223
|
+
|
|
224
|
+
print(json.dumps(spot_annotations['features'][0]['properties'],indent=4))
|
|
225
|
+
|
|
226
|
+
# %% [markdown]
|
|
227
|
+
# ## Merging adjacent spots from the same cluster
|
|
228
|
+
|
|
229
|
+
# %%
|
|
230
|
+
# Merging adjacent spots based on Seurat cluster
|
|
231
|
+
poly_list = []
|
|
232
|
+
for f in spot_annotations['features']:
|
|
233
|
+
poly_list.append(Point(shape(f['geometry']).centroid))
|
|
234
|
+
|
|
235
|
+
# Making the tissue mask
|
|
236
|
+
def find_tissue(slide_tile_source):
|
|
237
|
+
|
|
238
|
+
# Grabbing the thumbnail of the image (RGB)
|
|
239
|
+
slide_metadata = slide_tile_source.getMetadata()
|
|
240
|
+
thumbnail_img,_ = slide_tile_source.getThumbnail()
|
|
241
|
+
thumb_array = np.array(Image.open(BytesIO(thumbnail_img)))
|
|
242
|
+
|
|
243
|
+
# Getting scale factors for thumbnail image to full-size image
|
|
244
|
+
thumbX, thumbY = np.shape(thumb_array)[1],np.shape(thumb_array)[0]
|
|
245
|
+
scale_x = slide_metadata['sizeX']/thumbX
|
|
246
|
+
scale_y = slide_metadata['sizeY']/thumbY
|
|
247
|
+
|
|
248
|
+
# Mean of all channels/frames to make grayscale mask
|
|
249
|
+
gray_mask = np.squeeze(np.mean(thumb_array,axis=-1))
|
|
250
|
+
|
|
251
|
+
threshold_val = 1.15*threshold_otsu(gray_mask)
|
|
252
|
+
tissue_mask = gray_mask <= threshold_val
|
|
253
|
+
|
|
254
|
+
tissue_mask = remove_small_holes(tissue_mask,area_threshold=150)
|
|
255
|
+
tissue_mask = remove_small_objects(tissue_mask,min_size=64)
|
|
256
|
+
|
|
257
|
+
#plt.imshow(tissue_mask)
|
|
258
|
+
#plt.show()
|
|
259
|
+
labeled_mask = label(tissue_mask)
|
|
260
|
+
tissue_pieces = np.unique(labeled_mask).tolist()
|
|
261
|
+
tissue_areas = [np.sum(labeled_mask==j) for j in tissue_pieces[1:]]
|
|
262
|
+
largest_2 = [tissue_pieces[1:][tissue_areas.index(m)] for m in tissue_areas if m in sorted(tissue_areas)[-2:]]
|
|
263
|
+
|
|
264
|
+
tissue_shape_list = []
|
|
265
|
+
for piece in largest_2:
|
|
266
|
+
if np.sum(labeled_mask==piece)>100:
|
|
267
|
+
tissue_contours = find_contours(labeled_mask==piece)
|
|
268
|
+
for contour in tissue_contours:
|
|
269
|
+
poly_list = [(i[1]*scale_x,i[0]*scale_y) for i in contour]
|
|
270
|
+
if len(poly_list)>2:
|
|
271
|
+
obj_polygon = Polygon(poly_list)
|
|
272
|
+
if obj_polygon.is_valid:
|
|
273
|
+
tissue_shape_list.append(obj_polygon)
|
|
274
|
+
|
|
275
|
+
# Merging shapes together to remove holes
|
|
276
|
+
merged_tissue = unary_union(tissue_shape_list)
|
|
277
|
+
if merged_tissue.geom_type=='Polygon':
|
|
278
|
+
merged_tissue = [merged_tissue]
|
|
279
|
+
elif merged_tissue.geom_type in ['MultiPolygon','GeometryCollection']:
|
|
280
|
+
merged_tissue = merged_tissue.geoms
|
|
281
|
+
|
|
282
|
+
thumbnail_geojson = {
|
|
283
|
+
'type': 'FeatureCollection',
|
|
284
|
+
'features': [
|
|
285
|
+
{
|
|
286
|
+
'type':'Feature',
|
|
287
|
+
'geometry': {
|
|
288
|
+
'type': 'Polygon',
|
|
289
|
+
'coordinates': [list(i.exterior.coords)]
|
|
290
|
+
},
|
|
291
|
+
'properties': {
|
|
292
|
+
'name': 'Tissue Mask',
|
|
293
|
+
'_id': uuid.uuid4().hex[:24],
|
|
294
|
+
'_index': idx
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for idx,i in enumerate(merged_tissue) if i.geom_type=='Polygon'
|
|
298
|
+
],
|
|
299
|
+
'properties': {
|
|
300
|
+
'name': 'Tissue Mask',
|
|
301
|
+
'_id': uuid.uuid4().hex[:24]
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return thumbnail_geojson
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
tissue_mask = find_tissue(
|
|
309
|
+
large_image.open('..\\..\\Test Upload\\new Visium\\V12U21-010_XY02_21-0069.tif')
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
unique_clusters = np.unique(clusters).tolist()
|
|
313
|
+
merged_geojson = []
|
|
314
|
+
for c in unique_clusters:
|
|
315
|
+
c_idx = np.where(clusters==c)[0].tolist()
|
|
316
|
+
|
|
317
|
+
cluster_spots = [poly_list[i] for i in c_idx]
|
|
318
|
+
cluster_merges = {
|
|
319
|
+
'type': 'FeatureCollection',
|
|
320
|
+
'features': [],
|
|
321
|
+
'properties': {
|
|
322
|
+
'name': f'Cluster {c}',
|
|
323
|
+
'_id': uuid.uuid4().hex[:24]
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for t in tissue_mask['features']:
|
|
327
|
+
tissue_shape = shape(t['geometry'])
|
|
328
|
+
within_tissue_spots = [i for i in cluster_spots if i.within(tissue_shape)]
|
|
329
|
+
if len(within_tissue_spots)>0:
|
|
330
|
+
# Buffering spots to a size that they start to intersect
|
|
331
|
+
# (for Visium spots this is a little easier to define since all of the spots are separated
|
|
332
|
+
# by the same distance, for other structures this might have to be some kind of Voronoi diagram)
|
|
333
|
+
buffered_spots = [i.buffer(100) for i in within_tissue_spots]
|
|
334
|
+
|
|
335
|
+
# Merging buffered spots
|
|
336
|
+
merged_spots = unary_union(buffered_spots)
|
|
337
|
+
cluster_geojson = [
|
|
338
|
+
{
|
|
339
|
+
'type': 'Feature',
|
|
340
|
+
'geometry': {
|
|
341
|
+
'type': 'Polygon',
|
|
342
|
+
'coordinates': [list(g.exterior.coords)]
|
|
343
|
+
},
|
|
344
|
+
'properties': {
|
|
345
|
+
'name': f'Cluster {c}',
|
|
346
|
+
'_id': uuid.uuid4().hex[:24],
|
|
347
|
+
'_index': g_idx
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for g_idx,g in enumerate(merged_spots.geoms)
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
cluster_merges['features'].extend(cluster_geojson)
|
|
354
|
+
|
|
355
|
+
merged_geojson.append(cluster_merges)
|
|
356
|
+
|
|
357
|
+
spatially_agged = []
|
|
358
|
+
for m in merged_geojson:
|
|
359
|
+
spatially_agged.append(spatially_aggregate(m,[spot_annotations]))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# %% [markdown]
|
|
363
|
+
# ## Visualization in *fusion-tools*
|
|
364
|
+
|
|
365
|
+
# %%
|
|
366
|
+
# Visualizing spots in fusion-tools
|
|
367
|
+
from fusion_tools.components import SlideMap,OverlayOptions,PropertyPlotter,HRAViewer, BulkLabels
|
|
368
|
+
from fusion_tools import Visualization
|
|
369
|
+
|
|
370
|
+
local_image_path = "..\\..\\Test Upload\\new Visium\\V12U21-010_XY02_21-0069.tif"
|
|
371
|
+
|
|
372
|
+
vis_session = Visualization(
|
|
373
|
+
local_slides = [local_image_path],
|
|
374
|
+
local_annotations = [spot_annotations],
|
|
375
|
+
components = [
|
|
376
|
+
[
|
|
377
|
+
SlideMap(),
|
|
378
|
+
[
|
|
379
|
+
OverlayOptions(),
|
|
380
|
+
PropertyPlotter(),
|
|
381
|
+
#BulkLabels(),
|
|
382
|
+
HRAViewer()
|
|
383
|
+
]
|
|
384
|
+
]
|
|
385
|
+
],
|
|
386
|
+
app_options = {
|
|
387
|
+
'jupyter': False,
|
|
388
|
+
'port': 8080
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
vis_session.start()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|