|
a |
|
b/DigiPathAI/main_server.py |
|
|
1 |
#!/usr/bin/env python |
|
|
2 |
# |
|
|
3 |
from collections import OrderedDict |
|
|
4 |
from flask import Flask, abort, make_response, render_template, url_for |
|
|
5 |
from flask import flash |
|
|
6 |
from io import BytesIO |
|
|
7 |
import openslide |
|
|
8 |
from openslide import OpenSlide, OpenSlideError |
|
|
9 |
from openslide.deepzoom import DeepZoomGenerator |
|
|
10 |
import os |
|
|
11 |
from optparse import OptionParser |
|
|
12 |
from threading import Lock |
|
|
13 |
import json |
|
|
14 |
import threading |
|
|
15 |
import time |
|
|
16 |
from queue import Queue |
|
|
17 |
import sys |
|
|
18 |
import glob |
|
|
19 |
from flask import request |
|
|
20 |
|
|
|
21 |
SLIDE_DIR = 'examples' |
|
|
22 |
VIEWER_ONLY = True |
|
|
23 |
SLIDE_CACHE_SIZE = 10 |
|
|
24 |
DEEPZOOM_FORMAT = 'jpeg' |
|
|
25 |
DEEPZOOM_TILE_SIZE = 254 |
|
|
26 |
DEEPZOOM_OVERLAP = 1 |
|
|
27 |
DEEPZOOM_LIMIT_BOUNDS = True |
|
|
28 |
DEEPZOOM_TILE_QUALITY = 75 |
|
|
29 |
|
|
|
30 |
app = Flask(__name__) |
|
|
31 |
app.config.from_object(__name__) |
|
|
32 |
app.config.from_envvar('DEEPZOOM_MULTISERVER_SETTINGS', silent=True) |
|
|
33 |
|
|
|
34 |
class PILBytesIO(BytesIO): |
|
|
35 |
def fileno(self): |
|
|
36 |
'''Classic PIL doesn't understand io.UnsupportedOperation.''' |
|
|
37 |
raise AttributeError('Not supported') |
|
|
38 |
|
|
|
39 |
class _SlideCache(object): |
|
|
40 |
def __init__(self, cache_size, dz_opts): |
|
|
41 |
self.cache_size = cache_size |
|
|
42 |
self.dz_opts = dz_opts |
|
|
43 |
self._lock = Lock() |
|
|
44 |
self._cache = OrderedDict() |
|
|
45 |
|
|
|
46 |
def get(self, path): |
|
|
47 |
with self._lock: |
|
|
48 |
if path in self._cache: |
|
|
49 |
# Move to end of LRU |
|
|
50 |
slide = self._cache.pop(path) |
|
|
51 |
self._cache[path] = slide |
|
|
52 |
return slide |
|
|
53 |
|
|
|
54 |
osr = OpenSlide(path) |
|
|
55 |
slide = DeepZoomGenerator(osr, **self.dz_opts) |
|
|
56 |
try: |
|
|
57 |
mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] |
|
|
58 |
mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y] |
|
|
59 |
slide.mpp = (float(mpp_x) + float(mpp_y)) / 2 |
|
|
60 |
except (KeyError, ValueError): |
|
|
61 |
slide.mpp = 0 |
|
|
62 |
|
|
|
63 |
with self._lock: |
|
|
64 |
if path not in self._cache: |
|
|
65 |
if len(self._cache) == self.cache_size: |
|
|
66 |
self._cache.popitem(last=False) |
|
|
67 |
self._cache[path] = slide |
|
|
68 |
return slide |
|
|
69 |
|
|
|
70 |
class _Directory(object): |
|
|
71 |
def __init__(self, basedir, relpath=''): |
|
|
72 |
self.name = os.path.basename(relpath) |
|
|
73 |
self.full_name = '.' if relpath=='' else './'+relpath |
|
|
74 |
self.children = [] |
|
|
75 |
self.children_masks = [] |
|
|
76 |
for name in sorted(os.listdir(os.path.join(basedir, relpath))): |
|
|
77 |
cur_relpath = os.path.join(relpath, name) |
|
|
78 |
cur_path = os.path.join(basedir, cur_relpath) |
|
|
79 |
if os.path.isdir(cur_path): |
|
|
80 |
cur_dir = _Directory(basedir, cur_relpath) |
|
|
81 |
if cur_dir.children: |
|
|
82 |
self.children.append(cur_dir) |
|
|
83 |
elif OpenSlide.detect_format(cur_path): |
|
|
84 |
if not ('dgai-mask' in os.path.basename(cur_path)) and not ('dgai-uncertainty' in os.path.basename(cur_path)): |
|
|
85 |
#liver-slide-1-slide.tiff -> liver-slide-1-mask.tiff |
|
|
86 |
if get_mask_path(cur_path): |
|
|
87 |
self.children.append(_SlideFile(cur_relpath,True)) |
|
|
88 |
else: |
|
|
89 |
self.children.append(_SlideFile(cur_relpath,False)) |
|
|
90 |
|
|
|
91 |
class _SlideFile(object): |
|
|
92 |
def __init__(self, relpath, mask_present): |
|
|
93 |
self.name = os.path.basename(relpath) |
|
|
94 |
self.url_path = relpath |
|
|
95 |
self.mask_present = mask_present |
|
|
96 |
|
|
|
97 |
@app.before_first_request |
|
|
98 |
def _setup(): |
|
|
99 |
app.basedir = os.path.abspath(app.config['SLIDE_DIR']) |
|
|
100 |
config_map = { |
|
|
101 |
'DEEPZOOM_TILE_SIZE': 'tile_size', |
|
|
102 |
'DEEPZOOM_OVERLAP': 'overlap', |
|
|
103 |
'DEEPZOOM_LIMIT_BOUNDS': 'limit_bounds', |
|
|
104 |
} |
|
|
105 |
opts = dict((v, app.config[k]) for k, v in config_map.items()) |
|
|
106 |
app.cache = _SlideCache(app.config['SLIDE_CACHE_SIZE'], opts) |
|
|
107 |
app.segmentation_status = {"status":""} |
|
|
108 |
|
|
|
109 |
def get_mask_path_basename(path): |
|
|
110 |
return os.path.splitext(path)[0]+'-dgai-mask' |
|
|
111 |
|
|
|
112 |
def get_mask_path(path): |
|
|
113 |
''' |
|
|
114 |
Returns the path of the associated mask if it exists or returns False |
|
|
115 |
Example: 'folder/liver-cancer.svs > folder/liver-cancer-dgai-mask |
|
|
116 |
''' |
|
|
117 |
mask_path = glob.glob(get_mask_path_basename(path)+'*') |
|
|
118 |
if mask_path == []: |
|
|
119 |
return False |
|
|
120 |
elif len(mask_path) >1: |
|
|
121 |
raise ValueError("Duplicate masks found") |
|
|
122 |
else: |
|
|
123 |
return mask_path[0] |
|
|
124 |
|
|
|
125 |
def get_uncertainty_path(path): |
|
|
126 |
mask_path = '-'.join(path.split('-')[:-1]+["uncertainty"])+'.'+path.split('.')[-1] |
|
|
127 |
# mask_path = mask_path.replace('.svs', '.tiff') |
|
|
128 |
return mask_path |
|
|
129 |
|
|
|
130 |
def _get_slide(path): |
|
|
131 |
path = os.path.abspath(os.path.join(app.basedir, path)) |
|
|
132 |
if not path.startswith(app.basedir + os.path.sep): |
|
|
133 |
# Directory traversal |
|
|
134 |
abort(404) |
|
|
135 |
if not os.path.exists(path): |
|
|
136 |
abort(404) |
|
|
137 |
try: |
|
|
138 |
slide = app.cache.get(path) |
|
|
139 |
slide.filename = os.path.basename(path) |
|
|
140 |
return slide |
|
|
141 |
except OpenSlideError: |
|
|
142 |
abort(404) |
|
|
143 |
|
|
|
144 |
@app.route('/') |
|
|
145 |
def index(): |
|
|
146 |
return render_template('files.html', root_dir=_Directory(app.basedir)) |
|
|
147 |
|
|
|
148 |
@app.route('/segment', methods=['POST']) |
|
|
149 |
def segment(): |
|
|
150 |
app.segmentation_status['tissuetype'] = request.form['tissuetype'] |
|
|
151 |
if VIEWER_ONLY: |
|
|
152 |
app.segmentation_status['status'] = VIEWER_ONLY |
|
|
153 |
else: |
|
|
154 |
sys.path.append('..') |
|
|
155 |
from DigiPathAI.Segmentation import getSegmentation |
|
|
156 |
x = threading.Thread(target = run_segmentation, args = (app.segmentation_status, getSegmentation)) |
|
|
157 |
x.start() |
|
|
158 |
return app.segmentation_status |
|
|
159 |
|
|
|
160 |
|
|
|
161 |
def run_segmentation(status, getSegmentation): |
|
|
162 |
status['status'] = "Running" |
|
|
163 |
print(status) |
|
|
164 |
print("Starting segmentation") |
|
|
165 |
getSegmentation(img_path = status['slide_path'], |
|
|
166 |
mask_path = get_mask_path(status['slide_path']), |
|
|
167 |
uncertainty_path = get_uncertainty_path(status['slide_path']), |
|
|
168 |
status = status, |
|
|
169 |
mode = status['tissuetype']) |
|
|
170 |
time.sleep(0.1) |
|
|
171 |
print("Segmentation done") |
|
|
172 |
status['status'] = "Done" |
|
|
173 |
|
|
|
174 |
|
|
|
175 |
@app.route('/check_segment_status') |
|
|
176 |
def check_segment_status(): |
|
|
177 |
return app.segmentation_status |
|
|
178 |
|
|
|
179 |
def get_slide_properties(slide_path): |
|
|
180 |
''' |
|
|
181 |
Calculates and returns slide properties as a dictionary |
|
|
182 |
''' |
|
|
183 |
slide_dims = OpenSlide(slide_path).dimensions |
|
|
184 |
properties={'Dimensions':'%d x %d pixel' %(slide_dims[1],slide_dims[0]) } |
|
|
185 |
slide_area = slide_dims[0]*slide_dims[1] |
|
|
186 |
if (slide_area/1e6) != 0: |
|
|
187 |
properties['Area']='%d million pixels' % int(slide_dims[1]*slide_dims[0]/1e6) |
|
|
188 |
elif (slide_area/1e3) != 0: |
|
|
189 |
properties['Area']='%d thousand pixels' % int(slide_dims[1]*slide_dims[0]/1e4) |
|
|
190 |
else: |
|
|
191 |
properties['Area']='%d pixels' % int(slide_dims[1]*slide_dims[0]) |
|
|
192 |
return properties |
|
|
193 |
|
|
|
194 |
@app.route('/<path:path>') |
|
|
195 |
def slide(path): |
|
|
196 |
slide= _get_slide(path) |
|
|
197 |
slide_url = url_for('dzi', path=path) |
|
|
198 |
mask_url = get_mask_path(path) |
|
|
199 |
uncertainty_url = get_uncertainty_path(path) |
|
|
200 |
if mask_url != False: |
|
|
201 |
mask_url = '.'.join([slide_url.split('.')[0]+'-dgai-mask']+ slide_url.split('.')[1:]) |
|
|
202 |
if uncertainty_url != False: |
|
|
203 |
uncertainty_url = '.'.join([slide_url.split('.')[0]+'-dgai-uncertainty']+ slide_url.split('.')[1:]) |
|
|
204 |
print(slide_url) |
|
|
205 |
|
|
|
206 |
path = os.path.abspath(os.path.join(app.basedir, path)) |
|
|
207 |
app.segmentation_status['slide_path'] = path |
|
|
208 |
properties = get_slide_properties(path) |
|
|
209 |
|
|
|
210 |
return render_template('viewer.html', slide_url=slide_url,mask_url=mask_url,uncertainty_url=uncertainty_url,viewer_only=VIEWER_ONLY,properties=properties, |
|
|
211 |
slide_filename=slide.filename, slide_mpp=slide.mpp, root_dir=_Directory(app.basedir) ) |
|
|
212 |
|
|
|
213 |
|
|
|
214 |
@app.route('/about') |
|
|
215 |
def about_info(): |
|
|
216 |
return render_template('about.html') |
|
|
217 |
|
|
|
218 |
@app.route('/<path:path>.dzi') |
|
|
219 |
def dzi(path): |
|
|
220 |
slide = _get_slide(path) |
|
|
221 |
format = app.config['DEEPZOOM_FORMAT'] |
|
|
222 |
resp = make_response(slide.get_dzi(format)) |
|
|
223 |
resp.mimetype = 'application/xml' |
|
|
224 |
return resp |
|
|
225 |
|
|
|
226 |
@app.route('/<path:path>_files/<int:level>/<int:col>_<int:row>.<format>') |
|
|
227 |
def tile(path, level, col, row, format): |
|
|
228 |
slide = _get_slide(path) |
|
|
229 |
format = format.lower() |
|
|
230 |
if format != 'jpeg' and format != 'png': |
|
|
231 |
# Not supported by Deep Zoom |
|
|
232 |
abort(404) |
|
|
233 |
try: |
|
|
234 |
tile = slide.get_tile(level, (col, row)) |
|
|
235 |
except ValueError: |
|
|
236 |
# Invalid level or coordinates |
|
|
237 |
abort(404) |
|
|
238 |
buf = PILBytesIO() |
|
|
239 |
tile.save(buf, format, quality=app.config['DEEPZOOM_TILE_QUALITY']) |
|
|
240 |
resp = make_response(buf.getvalue()) |
|
|
241 |
resp.mimetype = 'image/%s' % format |
|
|
242 |
return resp |
|
|
243 |
|
|
|
244 |
|
|
|
245 |
def main(): |
|
|
246 |
parser = OptionParser(usage='Usage: %prog [options] [slide-directory]') |
|
|
247 |
parser.add_option('-s','--slide_dir',default='.',help="Directory containing the images. Defaults is current directory") |
|
|
248 |
parser.add_option('-B', '--ignore-bounds', dest='DEEPZOOM_LIMIT_BOUNDS', |
|
|
249 |
default=True, action='store_false', |
|
|
250 |
help='display entire scan area') |
|
|
251 |
parser.add_option('-c', '--config', metavar='FILE', dest='config', |
|
|
252 |
help='config file') |
|
|
253 |
parser.add_option('-d', '--debug', dest='DEBUG', action='store_true', |
|
|
254 |
help='run in debugging mode (insecure)') |
|
|
255 |
parser.add_option('-e', '--overlap', metavar='PIXELS', |
|
|
256 |
dest='DEEPZOOM_OVERLAP', type='int', |
|
|
257 |
help='overlap of adjacent tiles [1]') |
|
|
258 |
parser.add_option('-f', '--format', metavar='{jpeg|png}', |
|
|
259 |
dest='DEEPZOOM_FORMAT', |
|
|
260 |
help='image format for tiles [jpeg]') |
|
|
261 |
parser.add_option('-l', '--listen', metavar='ADDRESS', dest='host', |
|
|
262 |
default='127.0.0.1', |
|
|
263 |
help='address to listen on [127.0.0.1]') |
|
|
264 |
parser.add_option('-p', '--port', metavar='PORT', dest='port', |
|
|
265 |
type='int', default=8080, |
|
|
266 |
help='port to listen on [8080]') |
|
|
267 |
parser.add_option('-Q', '--quality', metavar='QUALITY', |
|
|
268 |
dest='DEEPZOOM_TILE_QUALITY', type='int', |
|
|
269 |
help='JPEG compression quality [75]') |
|
|
270 |
parser.add_option('-S', '--size', metavar='PIXELS', |
|
|
271 |
dest='DEEPZOOM_TILE_SIZE', type='int', |
|
|
272 |
help='tile size [254]') |
|
|
273 |
parser.add_option('--viewer-only', action='store_true',dest='viewer_only', |
|
|
274 |
help='disable segmentation') |
|
|
275 |
(opts, args) = parser.parse_args() |
|
|
276 |
|
|
|
277 |
global VIEWER_ONLY |
|
|
278 |
|
|
|
279 |
if opts.viewer_only==True: |
|
|
280 |
VIEWER_ONLY = True |
|
|
281 |
else: |
|
|
282 |
VIEWER_ONLY = False |
|
|
283 |
|
|
|
284 |
if opts.DEBUG == None: |
|
|
285 |
opts.DEBUG = False |
|
|
286 |
|
|
|
287 |
# Set slide directory |
|
|
288 |
app.config['SLIDE_DIR'] = opts.slide_dir |
|
|
289 |
|
|
|
290 |
if opts.config is not None: |
|
|
291 |
app.config.from_pyfile(opts.config) |
|
|
292 |
# Overwrite only those settings specified on the command line |
|
|
293 |
for k in dir(opts): |
|
|
294 |
if not k.startswith('_') and getattr(opts, k) is None: |
|
|
295 |
delattr(opts, k) |
|
|
296 |
app.config.from_object(opts) |
|
|
297 |
app.run(host=opts.host, port=opts.port,debug=opts.DEBUG, threaded=True) |
|
|
298 |
|
|
|
299 |
if __name__ == "__main__": |
|
|
300 |
main() |