/
tiles.py
1826 lines (1545 loc) · 72.7 KB
/
tiles.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ------------------------------------------------------------------------
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# ------------------------------------------------------------------------
#https://stackoverflow.com/questions/46641078/how-to-avoid-circular-dependency-caused-by-type-hinting-of-pointer-attributes-in
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
from .shared.tile import Tile
# To get around renderer issue on macOS going from Matplotlib image to NumPy image.
import matplotlib
matplotlib.use('Agg')
import PIL
import pathlib
from pathlib import Path
import colorsys
import math
import matplotlib.pyplot as plt
import multiprocessing
import numpy
import numpy as np
import os
import PIL
from PIL import Image, ImageDraw, ImageFont, PngImagePlugin
from enum import Enum
import openslide
from typing import List, Callable, Union, Dict, Tuple, Union
from tqdm import tqdm
import pandas
import pandas as pd
import warnings
from enum import Enum
import shapely
import copy
from functools import partial
import pathos
import tile_extraction
from tile_extraction import util
from util import adjust_level, safe_dict_access, pil_to_np_rgb, show_wsi_with_rois
import filter, slide, openslide_overwrite
import shared
from shared import roi
#from shared.tile import Tile ## see future import
from shared.roi import *
from shared.enums import DatasetType, TissueQuantity
#TISSUE_HIGH_THRESH = 80
#TISSUE_LOW_THRESH = 10
HSV_PURPLE = 270
HSV_PINK = 330
############################# classes #########################################
class Vertex:
def __init__(self, x:float, y:float):
self.x = x
self.y = y
def __repr__(self):
return f'(x:{self.x}, y:{self.y})'
def __str__(self):
return f'(x:{self.x}, y:{self.y})'
def __add__(self, o)->Vertex:
#print(type(o))
if(type(o) is np.ndarray and (o.shape == (2,) or o.shape == (2,1))):
self.x += o[0]
self.y += o[1]
elif(type(o) is Vertex or type(o) is __main__.Vertex):
self.x += o.x
self.y += o.y
else:
raise TypeError(f'Vertex class does not support addition with type: {type(o)}')
return self
def __sub__(self, o):
return self.__add__(copy.deepcopy(o)*(-1))
def __mul__(self, o):
if(type(o) is int or type(o) is float):
self.x *= o
self.y *= o
else:
raise TypeError(f'Vertex class does not support multiplication with type: {type(o)}')
return self
def __rmatmul__(self, o):
if(type(o) is np.ndarray):
return o@np.array([self.x, self.y])
else:
raise TypeError(f'Vertex class does not support (right sided) \
matrix multiplication with type: {type(o)}')
return self
def __call__(self):
return np.array([self.x, self.y])
def rotate_around_pivot(self, angle:float, pivot = np.array([0, 0])):
"""
Rotates itself clockwise around the specified pivot with the specified angle.
"""
self.__add__(-pivot)
radians = math.radians(angle)
rotation_matrix = np.array([[math.cos(radians), -math.sin(radians)],\
[math.sin(radians), math.cos(radians)]])
new_coordinates = rotation_matrix@self.__call__()
self.x = new_coordinates[0]
self.y = new_coordinates[1]
self.__add__(pivot)
return self
def deepcopy(self):
return Vertex(x=self.x, y=self.y)
def change_level(self, current_level:int, new_level:int):
"""
Arguments:
Return:
"""
self.x = adjust_level(value_to_adjust=self.x, from_level=current_level, to_level=new_level)
self.y = adjust_level(value_to_adjust=self.y, from_level=current_level, to_level=new_level)
return self
class Rectangle:
def __init__(self,
upper_left:Vertex,
upper_right:Vertex,
lower_right:Vertex,
lower_left:Vertex):
self.ul = upper_left
self.ur = upper_right
self.lr = lower_right
self.ll = lower_left
def __repr__(self):
return self.__str__()
def __str__(self):
return f'(ul: {self.ul}, ur: {self.ur}, lr: {self.lr}, ll: {self.ll})'
def __call__(self):
return np.array([self.ul(), self.ur(), self.lr(), self.ll()])
def __add__(self, o:Union[np.ndarray, Vertex]):
self.ul + o
self.ur + o
self.lr + o
self.ll + o
return self
def __sub__(self, o:Union[np.ndarray, Vertex]):
return self.__add__(copy.deepcopy(o)*(-1))
def __mul__(self, o:int):
self.ul * o
self.ur * o
self.lr * o
self.ll * o
return self
def __rotate_all_vertices(self, angle:float, pivot:np.ndarray):
self.ul.rotate_around_pivot(angle=angle, pivot=pivot)
self.ur.rotate_around_pivot(angle=angle, pivot=pivot)
self.lr.rotate_around_pivot(angle=angle, pivot=pivot)
self.ll.rotate_around_pivot(angle=angle, pivot=pivot)
return self
def deepcopy(self):
ul_dc = copy.deepcopy(self.ul)
ur_dc = copy.deepcopy(self.ur)
lr_dc = copy.deepcopy(self.lr)
ll_dc = copy.deepcopy(self.ll)
return Rectangle(ul_dc,ur_dc,lr_dc,ll_dc)
def polygon(self)->shapely.geometry.Polygon:
return shapely.geometry.Polygon(np.array([self.ul(), self.ur(), self.lr(), self.ll()]))
def rotate_around_itself(self, angle:float):
"""
Rotates itself around its centroid.
Arguments:
angle: degrees clockwise
"""
centroid = np.array([self.polygon().centroid.x, self.polygon().centroid.y])
#rotate all four vertices around this new pivot
self.__rotate_all_vertices(angle=angle, pivot=centroid)
return self
def rotate_around_pivot_without_orientation_change(self,
angle:float,
pivot = np.array([0,0])):
"""
Rotates the rectangle's centroid around the specified pivot.
The orientations of the edges do not change.
Arguments:
angle: degrees clockwise
"""
self.__add__(-pivot)
centroid_old = Vertex(x=self.polygon().centroid.x, y=self.polygon().centroid.y)
centroid_new = copy.deepcopy(centroid_old)
centroid_new.rotate_around_pivot(angle=angle, pivot=np.array([0,0]))
self.__add__(-centroid_old())
self.__add__(centroid_new())
self.__add__(pivot)
return self
def rotate(self, angle:float, pivot = np.array([0,0])):
"""
Combines rotate_around_itself and rotate_around_pivot
Arguments:
angle: degrees clockwise
"""
self.rotate_around_itself(angle=angle)
self.rotate_around_pivot_without_orientation_change(angle=angle, pivot=pivot)
return self
def as_roi(self, level:int, labels:List[str]=[])->shared.roi.RegionOfInterestPolygon:
"""
Creates and returns a RegionOfInterestPolygon from its values.
Arguments:
level: WSI level
"""
return shared.roi.RegionOfInterestPolygon(roi_id=self.__str__(),
vertices=self.__call__(),
level = level,
labels=labels)
def as_shapely_polygon(self)->shapely.geometry.Polygon:
ul = (self.ul.x, self.ul.y)
ur = (self.ur.x, self.ur.y)
lr = (self.lr.x, self.lr.y)
ll = (self.ll.x, self.ll.y)
return shapely.geometry.Polygon(shell=[ul, ur, lr, ll])
def get_outer_bounds(self):
"""
returns a new Rectangle which contains this Rectangle but its edges are parallel to the
WSI. So if this Rectangle is not rotated, the new Rectangle will have the same
spatial information. But if this Rectangle is rotated the new Rectangle will be larger.
"""
p = self.as_shapely_polygon()
b_ul = Vertex(x=p.bounds[0], y=p.bounds[1])
b_ur = Vertex(x=p.bounds[2], y=p.bounds[1])
b_lr = Vertex(x=p.bounds[2], y=p.bounds[3])
b_ll = Vertex(x=p.bounds[0], y=p.bounds[3])
return Rectangle(upper_left=b_ul,
upper_right=b_ur,
lower_right=b_lr,
lower_left=b_ll)
def is_rotated(self)->bool:
return self.ul.y != self.ur.y
def width(self)->float:
if(self.is_rotated()):
a = self.ur.x - self.ul.x
b = self.ur.y - self.ul.y
return math.sqrt(a**2 + b**2)
else:
return self.ur.x - self.ul.x
def height(self)->float:
if(self.is_rotated()):
a = self.ll.y - self.ul.y
b = self.ul.x - self.ll.x
return math.sqrt(a**2 + b**2)
else:
return self.ll.y - self.ul.y
def change_level(self, current_level:int, new_level:int):
"""
Arguments:
Return:
"""
self.ul.change_level(current_level=current_level, new_level=new_level)
self.ur.change_level(current_level=current_level, new_level=new_level)
self.lr.change_level(current_level=current_level, new_level=new_level)
self.ll.change_level(current_level=current_level, new_level=new_level)
return self
class Grid:
def __init__(self,
min_width:int,
min_height:int,
tile_width:int,
tile_height:int,
level:int,
coordinate_origin_x:int = 0,
coordinate_origin_y:int = 0,
angle:int = 0):
"""
level: WSI level
"""
##
#init object attributes
##
self.min_width = min_width
self.min_height = min_height
self.tile_width = tile_width
self.tile_height = tile_height
self.level = level
self.coordinate_origin_x = coordinate_origin_x
self.coordinate_origin_y = coordinate_origin_y
self.grid_centroid_float = np.array([coordinate_origin_x+min_width/2,\
coordinate_origin_y+min_height/2])
self.grid_centroid_int = np.array([int(coordinate_origin_x+min_width/2),\
int(coordinate_origin_y+min_height/2)])
self.init_angle = angle
self.current_angle = 0 #init with 0 and a value != 0 will be set in the rotate method
#at the end of the constructor
##
#calculate grid of rectangles
##
self.__init_grid(angle=self.init_angle)
def __init_grid(self, angle):
n_rows = math.ceil(self.min_height/self.tile_height)+1
n_columns = math.ceil(self.min_width/self.tile_width)+1
# the grid needs to be larger, to still cover the image after rotation
wsi_diagonal = math.sqrt(self.min_width**2 + self.min_height**2)
n_extra_rows = self.__round_up_to_nearest_even((wsi_diagonal-self.min_height)/self.tile_height)
n_extra_columns = self.__round_up_to_nearest_even((wsi_diagonal-self.min_width)/self.tile_width)
# init grid with None
self.grid = np.zeros(shape=(n_rows+n_extra_rows, n_columns+n_extra_columns), dtype=Rectangle)
for c in range(-int(n_extra_columns/2), n_columns + int(n_extra_columns/2)):
for r in range(-int(n_extra_rows/2), n_rows + int(n_extra_rows/2)):
#upper left vertex
ul_x = self.coordinate_origin_x + self.tile_width*c
ul_y = self.coordinate_origin_y + self.tile_height*r
ul = Vertex(x=ul_x, y=ul_y)
#upper right vertex
ur_x = self.coordinate_origin_x + self.tile_width*(c+1)
ur_y = self.coordinate_origin_y + self.tile_width*r
ur = Vertex(x=ur_x, y=ur_y)
#lower right vertex
lr_x = self.coordinate_origin_x + self.tile_width*(c+1)
lr_y = self.coordinate_origin_y + self.tile_width*(r+1)
lr = Vertex(x=lr_x, y=lr_y)
#lower left vertex
ll_x = self.coordinate_origin_x + self.tile_width*c
ll_y = self.coordinate_origin_y + self.tile_width*(r+1)
ll = Vertex(x=ll_x, y=ll_y)
self.grid[r + int(n_extra_rows/2)][c + int(n_extra_columns/2)] = Rectangle(upper_left=ul,
upper_right=ur,
lower_right=lr,
lower_left=ll)
if(angle%360 != 0):
self.rotate(angle=angle)
def __round_up_to_nearest_even(self, n:float)->int:
n = math.ceil(n)
if(n%2 == 0):
return n
else:
return n+1
def reset_grid(self):
self.current_angle = 0
self.__init_grid(angle=self.init_angle)
def get_rectangles(self)->List[Rectangle]:
return [rect for rect in self.grid.flatten() if type(rect) is Rectangle]
def get_number_of_tiles(self)->int:
def __predicate(elem):
if(elem is None):
return False
return True
return np.count_nonzero(np.where(__predicate, self.grid, 1))
def as_rois(self)->List[shared.roi.RegionOfInterestPolygon]:
def __func(rect):
if(type(rect) is Rectangle):
return rect.as_roi(level=self.level)
return [__func(rect) for rect in self.grid.flatten() if type(rect) is Rectangle]
def rotate(self, angle:float):
"""
Rotates the grid around the center of the wsi.
Arguments:
angle: angle in degrees, clockwise
"""
if(angle%360 != 0):
def __func(rect):
if(type(rect) is Rectangle):
rect.rotate(angle=angle, pivot=self.grid_centroid_float)
f = np.vectorize(__func)
f(self.grid)
#update current angle
self.current_angle = (self.current_angle+angle)%360
def filter_grid(self,
roi:shared.roi.RegionOfInterestPolygon,
minimal_intersection_quotient:float):
"""
Arguments:
roi: a region of interest inside the WSI; it will be checked, if the tiles lay inside of it.
minimal_intersection_quotient: in the range of (0.0, 1.0], only tiles with a relative
intersection with the roi equal or above this threshold
will be kept
"""
if(minimal_intersection_quotient <= 0.0 or minimal_intersection_quotient > 1.0):
raise ValueError("minimal_intersection_quotient must be in range (0.0, 1.0]")
for row in range(self.grid.shape[0]):
for col in range (self.grid.shape[1]):
elem = self.grid[row][col]
if(type(elem) is Rectangle):
rect_as_roi = elem.as_roi(level=self.level)
try:
intersection_area = roi.polygon.intersection(rect_as_roi.polygon).area
except shapely.geos.TopologicalError as e:
#possible temporary fix could be "roi.polygon.buffer(0).intersection(rect_as_roi.polygon).area"
#as described here: https://github.com/gboeing/osmnx/issues/278
#but buffer(0) migth change the roi in an unexpected way
#intersection_area = roi.polygon.buffer(0).intersection(rect_as_roi.polygon).area
#print(f'method tiles.Grid.filter_grid: {e}')
self.grid[row][col] = None
continue
tile_area = rect_as_roi.polygon.area
intersection_quotient = intersection_area/tile_area
#remove tile from grid, if the intersection with the roi is too small
if(intersection_quotient < minimal_intersection_quotient):
self.grid[row][col] = None
class GridManager:
def __init__(self,
wsi_path:pathlib.Path,
tile_width:int,
tile_height:int,
rois:List[shared.roi.RegionOfInterestPolygon],
grids_per_roi:int = 1,
level:int = 0):
"""
Arguments:
wsi_path:
tile_width:
tile_height:
grids_per_roi: Use multiple grids per roi to enhance the number of tiles.
The grids will be shifted depending on the number of grids
and the tile_width, tile_height.
e.g.: grids_per_roi == 3 and tile_width == tile_height == 1024
First grid starts at (0,0).
Second grid starts at (1024*1/3 , 1024*1/3)
Third grid starts at (1024*2/3 , 1024*2/3)
rois: If no roi is specified, the complete WSI is implicitly considered as one roi.
level: WSI level; 0 means highest resolution and magnification.
The GridManager checks each roi, if its level matches the given and if that's not
the case changes it. It works on deep copies of the rois.
Tile_width and tile_height are handled independently
from the given level.
"""
if(rois is not None):
rois_dc = [copy.deepcopy(r) for r in rois]
shared.roi.merge_overlapping_rois(rois=rois_dc)
[r.change_level_in_place(new_level=level) for r in rois_dc if r.level != level]
self.rois = rois_dc
self.wsi_path = wsi_path
self.tile_width = tile_width
self.tile_height = tile_height
self.grids_per_roi = grids_per_roi
self.level = level
self.grids = []
self.roi_to_grids = {}
self.grid_to_roi = {}
# if there is no roi given, one roi that spans the complete
# WSI is created
if(rois is None or len(rois) == 0):
w = slide.open_slide(path=wsi_path)
wsi_width = w.level_dimensions[level][0]
wsi_height = w.level_dimensions[level][1]
ul = np.array([0,0])
ur = np.array([wsi_width, 0])
lr = np.array([wsi_width, wsi_height])
ll = np.array([0, wsi_height])
vertices = np.array([ul, ur, lr, ll])
r = RegionOfInterestPolygon(roi_id=f'{wsi_path.stem} - dummy_roi',
vertices=vertices, level=level)
self.rois = [r]
for i in range(grids_per_roi):
shift_origin_x = tile_width*i/grids_per_roi
shift_origin_y = tile_height*i/grids_per_roi
g = Grid(min_width=w.level_dimensions[level][0],
min_height=w.dimensions[1],
tile_width=tile_width,
tile_height=tile_height,
level=level,
coordinate_origin_x=shift_origin_x,
coordinate_origin_y=shift_origin_y)
self.__add_grid(g=g, r=r)
else:
for r in self.rois:
b_ul = Vertex(x=r.polygon.bounds[0], y=r.polygon.bounds[1])
b_ur = Vertex(x=r.polygon.bounds[2], y=r.polygon.bounds[1])
b_lr = Vertex(x=r.polygon.bounds[2], y=r.polygon.bounds[3])
b_ll = Vertex(x=r.polygon.bounds[0], y=r.polygon.bounds[3])
b_width = r.polygon.bounds[2] - r.polygon.bounds[0]
b_height = r.polygon.bounds[3] - r.polygon.bounds[1]
for i in range(grids_per_roi):
shift_origin_x = tile_width*i/grids_per_roi
shift_origin_y = tile_height*i/grids_per_roi
g = Grid(min_width=b_width,
min_height=b_height,
tile_width=tile_width,
tile_height=tile_height,
level=level,
angle=0,
coordinate_origin_x= r.polygon.bounds[0]+shift_origin_x,
coordinate_origin_y= r.polygon.bounds[1]+shift_origin_y)
self.__add_grid(g=g, r=r)
def __add_grid(self, g:Grid, r:RegionOfInterestPolygon):
self.grids.append(g)
if(r not in self.roi_to_grids.keys()):
self.roi_to_grids[r] = []
self.roi_to_grids[r].append(g)
self.grid_to_roi[g] = r
def show_wsi_with_rois_and_grids(self,
figsize: Tuple[int] = (10, 10),
scale_factor: int = 32,
axis_off: bool = False):
for i in range(self.grids_per_roi):
tiles_as_rois = []
for r in self.roi_to_grids.keys():
tiles_as_rois += self.roi_to_grids[r][i].as_rois()
show_wsi_with_rois(wsi_path=self.wsi_path,
rois=self.rois + tiles_as_rois,
figsize=figsize,
scale_factor=scale_factor,
axis_off=axis_off)
def filter_grids(self, minimal_intersection_quotient:float):
"""
Filters out all tiles, which have not a minimum relative intersection
of minimal_intersection_quotient with a roi.
"""
for g in self.grids:
g.filter_grid(roi=self.grid_to_roi[g],
minimal_intersection_quotient=minimal_intersection_quotient)
def reset_grids(self):
for g in self.grids:
g.reset_grid()
def __iteration(self, g:Grid, stepsize:float, minimal_intersection_quotient:float):
best_angle = 0
max_num_tls = 0
current_angle = 0
while(current_angle <= 90):
g.reset_grid()
g.rotate(angle=current_angle)
g.filter_grid(roi=self.grid_to_roi[g],
minimal_intersection_quotient=minimal_intersection_quotient)
current_number_of_tiles = g.get_number_of_tiles()
if(current_number_of_tiles > max_num_tls):
max_num_tls = current_number_of_tiles
best_angle = current_angle
current_angle += stepsize
#to not forget a rotation of exactly 90°
if(g.tile_width != g.tile_height and current_angle > 90):
g.reset_grid()
g.rotate(angle=90)
g.filter_grid(roi=self.grid_to_roi[g],
minimal_intersection_quotient=minimal_intersection_quotient)
current_number_of_tiles = g.get_number_of_tiles()
if(current_number_of_tiles > max_num_tls):
max_num_tls = current_number_of_tiles
best_angle = current_angle
current_angle += stepsize
g.reset_grid()
g.rotate(best_angle)
g.filter_grid(roi=self.grid_to_roi[g],
minimal_intersection_quotient=minimal_intersection_quotient)
def optimize_grid_angles(self,
stepsize:float = 5,
minimal_intersection_quotient:float=1,
num_workers:int = pathos.util.os.cpu_count()):
"""
Trys different angles from 0° - 90° with intervals of the size "stepsize" to find the best angle,
to fit in the most tiles.
It also filters out tiles which are not inside rois.
Arguments:
stepsize: stepsizes of angles that are evaluated
minimal_intersection_quotient: in the range of (0.0, 1.0], only tiles with a relative
intersection with the roi equal or above this threshold
will be kept
"""
if(stepsize <= 0.0 or stepsize > 90.0):
raise ValueError('stepsize must be in range (0, 90]')
if(minimal_intersection_quotient <= 0.0 or minimal_intersection_quotient > 1.0):
raise ValueError('minimal_intersection_quotient must be in range (0, 1]')
__foo = partial(self.__iteration,
stepsize=stepsize,
minimal_intersection_quotient=minimal_intersection_quotient)
pool = pathos.pools.ThreadPool(num_workers)
pool.map(__foo, self.grids)
def get_all_rectangles(self)->List[Rectangle]:
return [r for g in self.grids for r in g.get_rectangles()]
def get_rois_the_given_tile_is_in(self,
rect_tile:Rectangle,
rect_level:int,
minimal_tile_roi_intersection_ratio:float)\
->List[shared.roi.RegionOfInterestPolygon]:
"""
rect_level: wsi level
"""
containing_rois = []
for roi in self.rois:
if(roi.level != rect_level):
rect_tile = rect_tile.deepcopy().change_level(current_level=rect_level,
new_level=roi.level)
rect_level = roi.level
try:
if((roi.polygon.intersection(rect_tile.polygon()).area/rect_tile.polygon().area)\
>= minimal_tile_roi_intersection_ratio):
containing_rois.append(roi)
except Exception as e:
print('Excpetion in Method "get_rois_the_given_tile_is_in"')
pass
return containing_rois
#from pythonlangutil.overload import Overload, signature
import cv2
def pil_to_open_cv(pil_image):
return cv2.cvtColor(numpy.array(pil_image), cv2.COLOR_RGB2BGR)
def open_cv_to_pil(open_cv_image):
return PIL.Image.fromarray(cv2.cvtColor(open_cv_image, cv2.COLOR_BGR2RGB))
class WsiHandler:
def __init__(self, wsi_path:Union[str, pathlib.Path]):
self.wsi_path = pathlib.Path(wsi_path)
self.slide = slide.open_slide(str(self.wsi_path))
#@Overload
#@signature("int", "int", "int", "int", "int")
def extract_tile_from_wsi_1(self,
ul_x:int,
ul_y:int,
width:int,
height:int,
level:int)->PIL.Image:
"""
Args:
ul_x: x-coordinate of the upper left pixel. The method assumes,
that you know the dimensions of your specified level.
ul_y: y-coordinate of the upper left pixel. The method assumes,
that you know the dimensions of your specified level.
width: tile width
height: tile height
level: Level of the WSI you want to extract the tile from.
0 means highest resolution.
Return:
tile as PIL.Image as RGB
"""
s = self.slide
wsi_width = s.level_dimensions[level][0]
wsi_height = s.level_dimensions[level][1]
#read_region() expects the coordinates of the upper left pixel with respect to level 0
ul_x_level_0 = adjust_level(value_to_adjust=ul_x, from_level=level, to_level=0)
ul_y_level_0 = adjust_level(value_to_adjust=ul_y, from_level=level, to_level=0)
ul_x_level_0 = int(ul_x_level_0)
ul_y_level_0 = int(ul_y_level_0)
width = int(width)
height = int(height)
tile_region = s.read_region((ul_x_level_0, ul_y_level_0), level, (width, height))
# RGBA to RGB
pil_img = tile_region.convert("RGB")
return pil_img
#@extract_tile_from_wsi.overload
#@signature("Rectangle")
def extract_tile_from_wsi_2(self,
rectangle_tile:Rectangle,
level:int)->PIL.Image:
"""
On how to extract a rotated rectangle:
https://jdhao.github.io/2019/02/23/crop_rotated_rectangle_opencv/
"""
# check if rectangle is rotated
if(not rectangle_tile.is_rotated()):
width = rectangle_tile.ur.x - rectangle_tile.ul.x
height = rectangle_tile.ll.y - rectangle_tile.ul.y
return self.extract_tile_from_wsi_1(ul_x=rectangle_tile.ul.x,
ul_y=rectangle_tile.ul.y,
width=width,
height=height,
level=level)
else:
rect_bounds = rectangle_tile.get_outer_bounds()
bounds_pil = self.extract_tile_from_wsi_1(ul_x=int(rect_bounds.ul.x),
ul_y=int(rect_bounds.ul.y),
width=int(rect_bounds.ur.x - rect_bounds.ul.x),
height=int(rect_bounds.ll.y - rect_bounds.ul.y),
level=level)
rect_tile_deepcopy = rectangle_tile.deepcopy()
rect_bounds_deepcopy = rect_bounds.deepcopy()
rect_tile_adjusted_origin = rect_tile_deepcopy - rect_bounds_deepcopy.ul
cnt = np.array([[[int(rect_tile_adjusted_origin.ul.x), int(rect_tile_adjusted_origin.ul.y)]],
[[int(rect_tile_adjusted_origin.ur.x), int(rect_tile_adjusted_origin.ur.y)]],
[[int(rect_tile_adjusted_origin.lr.x), int(rect_tile_adjusted_origin.lr.y)]],
[[int(rect_tile_adjusted_origin.ll.x), int(rect_tile_adjusted_origin.ll.y)]]])
rect = cv2.minAreaRect(cnt)
box = cv2.boxPoints(rect)
box = np.int0(box)
width = int(rect[1][0])
height = int(rect[1][1])
src_pts = box.astype("float32")
dst_pts = np.array([[0, height+1],
[0, 0],
[width+1, 0],
[width+1, height+1]], dtype="float32")
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
warped = cv2.warpPerspective(pil_to_open_cv(bounds_pil), M, (width+1, height+1))
return open_cv_to_pil(warped)
def get_wsi_as_pil_image(self, level = 5):
wsi = openslide.open_slide(str(self.wsi_path))
large_w, large_h = wsi.level_dimensions[level]
#best_level_for_downsample = wsi.get_best_level_for_downsample(scale_factor)
new_w, new_h = wsi.level_dimensions[level]
pil_img = wsi.read_region((0, 0), level, wsi.level_dimensions[level])
pil_img = pil_img.convert("RGB")
return pil_img
class TileSummary:
"""
Class for tile summary information.
"""
wsi_path = None
tiles_folder_path = None #only necessary, if the tiles shall be saved to disc, else None
orig_w = None #full width in pixels of the wsi on the specified level
orig_h = None #full height in pixels of the wsi on the specified level
orig_tile_w = None
orig_tile_h = None
scale_factor = None #for faster processing the wsi is scaled down internally,
#the resulting tiles are on maximum resolution
#depending on the specified level
scaled_w = None
scaled_h = None
scaled_tile_w = None
scaled_tile_h = None
#mask_percentage = None
tile_score_thresh = None
level = None
best_level_for_downsample = None
real_scale_factor = None
rois:RegionOfInterest
grid_manager = None
tiles = None
def __init__(self,
wsi_path,
tiles_folder_path,
orig_w,
orig_h,
orig_tile_w,
orig_tile_h,
scale_factor,
scaled_w,
scaled_h,
scaled_tile_w,
scaled_tile_h,
#tissue_percentage,
tile_score_thresh,
level,
best_level_for_downsample,
real_scale_factor,
rois:List[RegionOfInterest],
grid_manager:GridManager):
"""
Arguments:
level: whole-slide image's level, the tiles shall be extracted from
orig_w, orig_h: original height and original width depend on the specified level.
With each level, the dimensions half.
scale_factor: Downscaling is applied during tile calculation to speed up the process.
The tiles in the end get extracted from the full resolution.
The full resolution depends on the level, the user specifies.
The higher the level, the lower the resolution/magnification.
Therefore less downsampling needs to be applied during tile calculation,
to achieve same speed up.
So e.g. the wsi has dimensions of 10000x10000 pixels on level 0.
A scale_factor of 32 is speficied.
Then calculations will be applied on
a downscaled version of the wsi with dimensions on the level log2(32)
real_scale_factor: if a scale_factor of e.g. 32 is specified and a level of 0,
from which the tiles shall be extracted,
scale_factor==real_scale_factor.
For each level, the wsi dimensions half.
That means for a scale_factor of 32
and level 1 the real_scale_factor would be only 16.
downscaling is applied during tile calculation to speed up the process.
The tile in the end get extracted from the full resolution
The full resolution depends on the level, the user specifies.
The higher the level, the lower the
resolution/magnification.
Therefore less downsampling needs to be applied during tile calculation,
to achieve same speed up.
best_level_for_downsample: result of openslide.OpenSlide.get_best_level_for_downsample(scale_factor)
"""
self.wsi_path = wsi_path
self.tiles_folder_path = tiles_folder_path
self.orig_w = orig_w
self.orig_h = orig_h
self.orig_tile_w = orig_tile_w
self.orig_tile_h = orig_tile_h
self.scale_factor = scale_factor
self.scaled_w = scaled_w
self.scaled_h = scaled_h
self.scaled_tile_w = scaled_tile_w
self.scaled_tile_h = scaled_tile_h
#self.tissue_percentage = tissue_percentage
self.tile_score_thresh = tile_score_thresh
self.level = level
self.best_level_for_downsample = best_level_for_downsample
self.real_scale_factor = real_scale_factor
self.tiles = []
self.rois = rois
self.grid_manager = grid_manager
def change_level_of_rois(self, new_level:int):
"""
convenience function to change the level for all rois at once in place
"""
for roi in self.rois:
if roi != None:
roi.change_level_in_place(new_level)
#def __str__(self):
# return summary_title(self) + "\n" + summary_stats(self)
#def mask_percentage(self):
# """
# Obtain the percentage of the slide that is masked.
#
# Returns:
# The amount of the slide that is masked as a percentage.
# """
# return 100 - self.tissue_percentage
def tiles_by_tissue_percentage(self):
"""
Retrieve the tiles ranked by tissue percentage.
Returns:
List of the tiles ranked by tissue percentage.
"""
sorted_list = sorted(self.tiles, key=lambda t: t.tissue_percentage, reverse=True)
return sorted_list
def tiles_by_score(self):
"""
Retrieve the tiles ranked by score. If rois were specified,
only tiles within those rois will be taken into account.
Returns:
List of the tiles ranked by score.
"""
sorted_list = sorted(self.tiles, key=lambda t: t.score, reverse=True)
return sorted_list
def top_tiles(self, verbose=False):
"""
Retrieve only the tiles that pass scoring
Returns:
List of the top-scoring tiles.
"""
sorted_tiles = self.tiles_by_score()
top_tiles = [tile for tile in sorted_tiles
if self.check_tile(tile)]
if verbose:
print(f'{self.wsi_path}: Number of tiles that will be kept/all possible tiles: \
{len(top_tiles)}/{len(sorted_tiles)}')
return top_tiles
def check_tile(self, tile):
return tile.score > self.tile_score_thresh
def show_wsi(self,
figsize:tuple=(10,10),
axis_off:bool=False):
"""
Displays a scaled down overview image of the wsi.
Arguments:
figsize: Size of the plotted matplotlib figure containing the image.
axis_off: bool value that indicates, if axis shall be plotted with the picture
"""
wsi_pil, large_w, large_h, new_w, new_h, best_level_for_downsample = \
wsi_to_scaled_pil_image(wsi_filepath=self.wsi_path,
scale_factor=self.scale_factor,
level=0)
# Create figure and axes
fig,ax = plt.subplots(1,1,figsize=figsize)
# Display the image
ax.imshow(wsi_pil)
if(axis_off):
ax.axis('off')
plt.show()
def show_wsi_with_all_possible_tiles(self,
figsize:Tuple[int] = (10,10),
scale_factor:int = 32,
axis_off:bool = False):
"""
Loads a whole slide image, scales it down, converts it into a numpy array and displays
it with a grid overlay for all tiles
that could fit in the given rois or if no rois are given in the whole wsi.
Arguments:
figsize: Size of the plotted matplotlib figure containing the image.
scale_factor: The larger, the faster this method works,
but the plotted image has less resolution.
axis_off: bool value that indicates, if axis shall be plotted with the picture
"""
self.grid_manager.show_wsi_with_rois_and_grids(figsize=figsize,