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.
Files changed (51) hide show
  1. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/PKG-INFO +1 -1
  2. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/setup.py +1 -1
  3. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/maps.py +112 -43
  4. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/tools.py +39 -9
  5. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/feature_extraction.py +15 -15
  6. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/tileserver.py +8 -5
  7. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/shapes.py +1 -1
  8. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/stats.py +0 -14
  9. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/components.py +30 -5
  10. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/PKG-INFO +1 -1
  11. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/SOURCES.txt +2 -0
  12. fusion_tools-2.4.0/tests/test_extract_recursive.py +62 -0
  13. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_feature_extraction.py +4 -0
  14. fusion_tools-2.4.0/tests/test_process.py +395 -0
  15. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/LICENSE +0 -0
  16. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/README.md +0 -0
  17. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/pyproject.toml +0 -0
  18. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/setup.cfg +0 -0
  19. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/__init__.py +0 -0
  20. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/__init__.py +0 -0
  21. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/components/segmentation.py +0 -0
  22. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/dataset.py +0 -0
  23. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/handler.py +0 -0
  24. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/__init__.py +0 -0
  25. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/utils/omics.py +0 -0
  26. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/__init__.py +0 -0
  27. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools/visualization/vis_utils.py +0 -0
  28. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/dependency_links.txt +0 -0
  29. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/requires.txt +0 -0
  30. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/src/fusion_tools.egg-info/top_level.txt +0 -0
  31. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_classification_model.py +0 -0
  32. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_custom_component.py +0 -0
  33. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_dataset_classes.py +0 -0
  34. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_extract_nested_prop.py +0 -0
  35. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_feature_annotation.py +0 -0
  36. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_generate_property_dict.py +0 -0
  37. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_get_gene_info.py +0 -0
  38. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_hra_viewer.py +0 -0
  39. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_image_overlay.py +0 -0
  40. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_model_train.py +0 -0
  41. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_multiframe_rgb.py +0 -0
  42. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_multiframeslidemap.py +0 -0
  43. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_property_plotter.py +0 -0
  44. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_read_anndata.py +0 -0
  45. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_remote_slide_annotations.py +0 -0
  46. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_remote_tileserver.py +0 -0
  47. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_segmentation.py +0 -0
  48. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_slide_annotations.py +0 -0
  49. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_spatial_aggregation.py +0 -0
  50. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_tile_server.py +0 -0
  51. {fusion_tools-2.3.2 → fusion_tools-2.4.0}/tests/test_visualization_component.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fusion-tools
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Modular visualization and analysis dashboard creation for high-resolution microscopy images
5
5
  Home-page: https://github.com/spborder/fusion-tools
6
6
  Author: Sam Border
@@ -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.3.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 (overlaySubProps[i] in prop_dict) {
360
- var prop_dict = prop_dict[overlaySubProps[i]];
361
- var overlayVal = prop_dict;
362
- } else {
363
- var overlayVal = Number.Nan;
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 (filterSubProps[j] in prop_dict) {
414
- var prop_dict = prop_dict[filterSubProps[j]];
415
- var testVal = prop_dict;
416
- } else {
417
- returnFeature = returnFeature & false;
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-layers-control','index': MATCH},'children'),
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-layers-control','index': MATCH},'children'),
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
- return new_layer_children, geo_annotations, new_tile_layer, new_slide_info
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
- dict_properties = [i for i in all_properties if type(clicked['properties'][i])==dict]
806
- for d in dict_properties:
807
- sub_properties = clicked['properties'][d]
808
- sub_prop_record = [{'SubProperty': i, 'Value': j} for i,j in sub_properties.items() if not type(j) in [list,dict]]
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 'Manual' in i['properties']['name']]
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
- new_children = Patch()
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
- deleted_rois.append(m_idx)
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
- new_children.extend(new_layers)
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,d in enumerate(deleted_rois):
1044
- del new_children[(d-d_idx)+len(frame_layers)+len(initial_annotations)]
1045
- del current_annotations[(d-d_idx)+len(initial_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
- new_children = no_update
1119
+ new_manual_rois = no_update
1051
1120
 
1052
- return new_children, annotations_data
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","default")
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 = '#FFFFFF',
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 = 4
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 = 4
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=15
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=15
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 = 30
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 = 30
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 {float(idx+1)}': i for idx,i in enumerate(self.channel_names)}
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
- new_metadata = new_tile_source.getMetadata()
82
+ new_tiles_metadata = new_tile_source.getMetadata()
83
83
 
84
84
  # Treating 3-frame images as RGB by default
85
- if 'frames' in new_metadata:
86
- if len(new_metadata['frames'])==3:
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
- self.tiles_metadatas.append(new_tile_source.getMetadata())
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
@@ -196,17 +196,3 @@ def run_wilcox_rank_sum(data_df: pd.DataFrame, label_col:str, p_val_thresh: floa
196
196
  return results_list
197
197
 
198
198
 
199
-
200
-
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
212
-
@@ -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 components: List of rows, columns, and tabs to include current visualization session
57
- :type components: list
58
- :param app_options: Additional application options, defaults to {}
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fusion-tools
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Modular visualization and analysis dashboard creation for high-resolution microscopy images
5
5
  Home-page: https://github.com/spborder/fusion-tools
6
6
  Author: Sam Border
@@ -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